Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AsyncSSE

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
}

Why this exists

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.

Install

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).

Usage

Stream from an LLM (POST + JSON, the common case)

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)")
}

GET an event stream

for try await event in client.events(from: url) {
    print(event.event ?? "message", event.data)
}

Drive a fully-custom request

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) {  }

Cancellation

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 connection

The ServerSentEvent

public 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
}

Parse without the network

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/.

Spec conformance

  • 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/retry reset per block.
  • id is sticky (the stream's last-event-ID) until the server sends a new one.
  • Line endings (\n, \r\n, \r) are handled by URLSession.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.

License

MIT © 2026 oratis

About

Server-Sent Events for Swift as an async/await stream. Point it at an LLM endpoint (Claude, OpenAI, Ollama) and `for try await` the tokens. Spec-compliant parser, zero dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages