Server-Sent Events for Swift, as an async/await stream. Point it at an
LLM's streaming endpoint and for try await the tokens. Zero dependencies —
just Foundation and Swift Concurrency.
import AsyncSSE
let client = SSEClient()
let body = try JSONSerialization.data(withJSONObject: ["message": "Tell me a joke"])
for try await event in client.events(posting: body, to: url, headers: ["Authorization": "Bearer …"]) {
if event.isDone { break } // OpenAI-style [DONE] sentinel
guard let chunk = event.decode(Delta.self) else { continue }
await MainActor.run { text += chunk.content } // stream into your SwiftUI view
}URLSession.bytes(for:).lines gets you lines, not events — you still have to
reassemble data: frames, join multi-line payloads, skip : keep-alive comments,
strip the one leading space, and dispatch on blank lines. Everyone building an LLM
chat UI re-writes that loop, usually a little bit wrong. The popular Swift SSE
libraries predate async/await and hand you a callback onMessage closure.
AsyncSSE is the missing 120 lines: a spec-compliant parser
(WHATWG §9.2) wrapped
in an AsyncThrowingStream, so streaming from Claude, OpenAI, Ollama, or your own
backend is a plain for try await loop that cancels when the Task does.
Swift Package Manager — add to Package.swift:
.package(url: "https://github.com/oratis/AsyncSSE.git", from: "1.0.0")…or in Xcode: File ▸ Add Package Dependencies… and paste the URL.
Requires iOS 15 / macOS 12 / tvOS 15 / watchOS 8 (for URLSession.bytes).
struct Delta: Decodable { let content: String }
let client = SSEClient()
let payload = try JSONEncoder().encode(ChatRequest(messages: messages, stream: true))
do {
for try await event in client.events(posting: payload, to: endpoint,
headers: ["Authorization": "Bearer \(key)"]) {
if event.isDone { break }
if let delta = event.decode(Delta.self) { print(delta.content, terminator: "") }
}
} catch SSEError.httpStatus(let code) {
print("server returned \(code)")
}for try await event in client.events(from: url) {
print(event.event ?? "message", event.data)
}var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
for try await event in client.events(for: request) { … }The stream is backed by a Task. Break out of the loop, or cancel the task that
runs it, and the underlying connection is torn down via onTermination:
let streaming = Task {
for try await event in client.events(from: url) { render(event) }
}
// later…
streaming.cancel() // closes the HTTP connectionpublic struct ServerSentEvent: Sendable, Equatable {
public var event: String? // the `event:` field (nil ⇒ default "message")
public var data: String // all `data:` lines, joined with "\n"
public var id: String? // the `id:` field (sticky across events, per spec)
public var retry: Int? // the `retry:` reconnection hint, in ms
}
extension ServerSentEvent {
var isDone: Bool // true for the `[DONE]` sentinel
func decode<T: Decodable>(_ type: T.Type) -> T? // JSON-decode `data`, nil on mismatch
}The parser is a plain value type with no I/O, so you can unit-test your event handling against fixtures — no server, no mocking:
var parser = SSEParser()
for line in fixture.split(separator: "\n", omittingEmptySubsequences: false) {
if let event = parser.consume(line: String(line)) { assertOnEvent(event) }
}That's exactly how AsyncSSE tests itself — see Tests/.
- Multi-line
data:payloads joined with\n; single trailing newline trimmed. - One optional leading space stripped after the field colon.
:-prefixed comment lines (keep-alives) ignored.- Bare field names with no colon treated as an empty value.
- Blocks with no
data:field dispatch nothing;event/retryreset per block. idis sticky (the stream's last-event-ID) until the server sends a new one.- Line endings (
\n,\r\n,\r) are handled byURLSession.AsyncBytes.lines.
Auto-reconnect with Last-Event-ID is intentionally out of scope — for LLM
streaming you want one shot and a clean error, not a silent replay. Wrap the loop
in your own retry if you need it.
MIT © 2026 oratis