Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions clients/swift/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/
.netrc
30 changes: 30 additions & 0 deletions clients/swift/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// swift-tools-version: 6.0

import PackageDescription

let package = Package(
name: "SpectrayanSSE",
platforms: [
.iOS(.v15),
.macOS(.v12),
.watchOS(.v8),
.tvOS(.v15),
.visionOS(.v1)
],
products: [
.library(
name: "SpectrayanSSE",
targets: ["SpectrayanSSE"]
)
],
targets: [
.target(
name: "SpectrayanSSE"
),
.testTarget(
name: "SpectrayanSSETests",
dependencies: ["SpectrayanSSE"]
)
],
swiftLanguageModes: [.v6]
)
122 changes: 122 additions & 0 deletions clients/swift/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Spectrayan Swift SSE Client (`SpectrayanSSE`)

[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Swift](https://img.shields.io/badge/Swift-6.0-orange.svg)](https://swift.org)
[![Platforms](https://img.shields.io/badge/Platforms-iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS%20%7C%20visionOS-lightgrey.svg)](#requirements)

Lightweight Swift client for consuming Server-Sent Events (SSE / W3C EventSource) on Apple platforms. Built on Swift Concurrency (`AsyncThrowingStream<ServerSentEvent, Error>`) and `URLSession`, with automatic reconnection, randomized exponential backoff, and `Last-Event-ID` resumption.

Part of the [Spectrayan Server-Sent Events](https://github.com/spectrayan/server-sent-events) polyglot client ecosystem.

---

## Features

- **Swift Concurrency**: Consume events with `for try await`; types are `Sendable` and build cleanly under Swift 6 strict concurrency.
- **Cancellation-Aware**: Cancelling the consuming task (or leaving the loop, or a SwiftUI `.task` ending) cancels the in-flight request and stops reconnecting.
- **W3C Standard Compliance**: Byte-level streaming parser supporting multi-line `data:`, custom `event:` names, `id:` tracking, `retry:` delays, `:keepalive` comments, CR / LF / CRLF line endings, and a leading UTF-8 BOM.
- **Resilient Reconnection**: Reconnects after network drops, timeouts, and 408 / 429 / 5xx responses, sending `Last-Event-ID` so the server can resume. HTTP 204 stops the stream; other 4xx responses and non-`text/event-stream` responses fail it.
- **Randomized Exponential Backoff**: Full jitter avoids thundering herds; the server's `retry:` value replaces the base delay:
$$\text{delay} = \text{random}\big(0,\ \min(\text{initialDelay} \times \text{multiplier}^{\text{attempt}},\ \text{maxDelay})\big)$$
- **Zero External Dependencies**: Implemented with Foundation (`URLSession`, `URLSessionDataDelegate`) only.

---

## Requirements

- Swift 6.0+ (Swift Package Manager)
- iOS 15+, macOS 12+, watchOS 8+, tvOS 15+, visionOS 1+

---

## Installation

### Swift Package Manager

The package manifest lives in `clients/swift/`. Swift Package Manager resolves remote packages from a repository's root, so add a checkout of this repository as a local package:

```swift
// Package.swift
dependencies: [
.package(path: "path/to/server-sent-events/clients/swift")
],
targets: [
.target(
name: "MyApp",
dependencies: [.product(name: "SpectrayanSSE", package: "swift")]
)
]
```

In Xcode, use **File → Add Package Dependencies… → Add Local…** and select `clients/swift`.

---

## Quick Start

```swift
import Foundation
import SpectrayanSSE

let client = SSEClient(
url: URL(string: "https://example.com/events")!
)

for try await event in client.events() {
print(event.data)
}
```

Each `ServerSentEvent` exposes `id`, `event` (defaults to `"message"`), `data`, `retryMilliseconds`, and the `comments` received before it.

### SwiftUI

```swift
.task {
do {
for try await event in client.events() {
messages.append(event.data)
}
} catch {
self.error = error
}
}
```

The connection closes automatically when the view disappears.

---

## Reconnection Settings (`ReconnectionPolicy`)

```swift
let client = SSEClient(
url: URL(string: "https://example.com/events")!,
session: .shared, // any URLSession
reconnectionPolicy: ReconnectionPolicy(
initialDelayMilliseconds: 1_000, // base delay for the first reconnect
maxDelayMilliseconds: 30_000, // upper bound for any single delay
multiplier: 2.0, // exponential growth factor
maxRetries: nil // nil = unlimited, 0 = never reconnect
)
)
```

On iOS 16 / macOS 13 and later, `ReconnectionPolicy(initialDelay: .seconds(1), maxDelay: .seconds(30))` accepts `Duration` values.

When retries run out, the stream throws the last error (`URLError` or `SSEClientError`).

---

## Running Tests

```bash
cd clients/swift
swift test
```

---

## License

Apache License 2.0. See [LICENSE](../../LICENSE) for details.
82 changes: 82 additions & 0 deletions clients/swift/Sources/SpectrayanSSE/ReconnectionPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Foundation

/// Controls how the client waits between reconnection attempts.
///
/// Delays are stored in milliseconds because `Duration` requires iOS 16 / macOS 13, above this
/// package's deployment targets. Where `Duration` is available, use
/// ``init(initialDelay:maxDelay:multiplier:maxRetries:)`` and the `Duration` accessors.
public struct ReconnectionPolicy: Sendable, Equatable {
/// Delay before the first reconnection attempt, in milliseconds.
public let initialDelayMilliseconds: Int
/// Upper bound for any single delay, in milliseconds.
public let maxDelayMilliseconds: Int
public let multiplier: Double
public let maxRetries: Int?

public init(
initialDelayMilliseconds: Int = 1_000,
maxDelayMilliseconds: Int = 30_000,
multiplier: Double = 2.0,
maxRetries: Int? = nil
) {
self.initialDelayMilliseconds = initialDelayMilliseconds
self.maxDelayMilliseconds = maxDelayMilliseconds
self.multiplier = multiplier
self.maxRetries = maxRetries
}
}

@available(iOS 16, macOS 13, watchOS 9, tvOS 16, *)
extension ReconnectionPolicy {
/// Creates a policy from `Duration` values, truncated to whole milliseconds.
public init(
initialDelay: Duration,
maxDelay: Duration = .seconds(30),
multiplier: Double = 2.0,
maxRetries: Int? = nil
) {
self.init(
initialDelayMilliseconds: Self.wholeMilliseconds(initialDelay),
maxDelayMilliseconds: Self.wholeMilliseconds(maxDelay),
multiplier: multiplier,
maxRetries: maxRetries
)
}

/// ``initialDelayMilliseconds`` as a `Duration`.
public var initialDelay: Duration {
.milliseconds(initialDelayMilliseconds)
}

/// ``maxDelayMilliseconds`` as a `Duration`.
public var maxDelay: Duration {
.milliseconds(maxDelayMilliseconds)
}

private static func wholeMilliseconds(_ duration: Duration) -> Int {
let (seconds, attoseconds) = duration.components
return Int(seconds) * 1_000 + Int(attoseconds / 1_000_000_000_000_000)
}
}

extension ReconnectionPolicy {
/// Computes the delay before reconnection attempt `attempt` (0 for the first reconnect) using
/// exponential backoff with full jitter: `random(0...min(base * multiplier^attempt, cap))`.
///
/// - Parameters:
/// - serverDelayMilliseconds: The stream's `retry:` value. When present it replaces
/// ``initialDelayMilliseconds`` as the base, and the cap is raised to at least this value
/// so ``maxDelayMilliseconds`` never overrides the server.
/// - randomFactor: A value in `0...1`; injectable for deterministic tests.
func delayMilliseconds(
forAttempt attempt: Int,
serverDelayMilliseconds: Int? = nil,
randomFactor: Double = .random(in: 0...1)
) -> Int {
let base = Double(serverDelayMilliseconds ?? initialDelayMilliseconds)
let cap = max(Double(maxDelayMilliseconds), Double(serverDelayMilliseconds ?? 0))
let exponential = base * pow(max(multiplier, 1), Double(max(attempt, 0)))
let capped = min(exponential, cap)
return max(0, Int(capped * min(max(randomFactor, 0), 1)))
}
}
Loading
Loading