mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
HapiClient/SSE per docs/api/client-contract/sse.md and the web reference (web/src/hooks/useSSE.ts): - SSELineParser: incremental frame parser (LF/CRLF, multi-line data, comments, retry ignored); per-block ids only — sticky-cursor semantics live in the caller so id-less heartbeats can never blank the cursor. - ReconnectPolicy + SSETimings: 0s first retry, 1s x2 capped at 30s, 300s slow ceiling after 8 attempts, 0-500ms injectable jitter; connect timeout 10s, staleness 90s, foreground-resume 45s, watchdog tick 10s. - SSETransport protocol + URLSessionSSETransport (URLSession.bytes with dedicated config: 300s idle timeout, 7d resource, no waitsForConnectivity). - actor SSEClient: idle/connecting/connected/backoff/suspended machine; handshake-gated connected state surfacing resume ok|gap; per-subscription cursor sent as ?lastEventId, advanced only after consumer yield (at-least-once); one free token-refresh retry per connect cycle on 401; suspend defers retries, resume applies the 45s staleness check; acceptEncodingIdentity escape hatch (gzip streaming verification TODO). - NetworkPathObserving + NWPathObserver: path change while connected is treated as a transport error (immediate reconnect). - swift-testing suite on a fake transport + manual clock: parser edge cases, exact backoff schedule + seeded jitter bounds, handshake gating, ok/gap verdicts, cursor replay + isolation, heartbeat vs watchdog, 90s staleness, connect timeout, suspend/resume, event ordering, unknown-type passthrough, 401 bypass cap, path-change reconnect.
116 lines
4.5 KiB
Swift
116 lines
4.5 KiB
Swift
import Foundation
|
|
|
|
/// One dispatched SSE message.
|
|
///
|
|
/// `id` is the value of the `id:` field seen in THIS event block only — `nil`
|
|
/// when the block carried no `id:` line (heartbeat / connection-changed /
|
|
/// toast frames). Cursor stickiness is deliberately NOT the parser's job: the
|
|
/// caller keeps its last-seen id and lets only non-nil (and non-empty) frame
|
|
/// ids overwrite it, so an id-less heartbeat can never reset the cursor.
|
|
public struct SSEFrame: Equatable, Sendable {
|
|
public var id: String?
|
|
/// Value of the `event:` field, `nil` for default `message` frames.
|
|
/// The hub only ever sends unnamed frames; kept for completeness.
|
|
public var event: String?
|
|
/// All `data:` lines of the block joined with `\n`.
|
|
public var data: String
|
|
|
|
public init(id: String? = nil, event: String? = nil, data: String) {
|
|
self.id = id
|
|
self.event = event
|
|
self.data = data
|
|
}
|
|
}
|
|
|
|
/// Incremental parser for a `text/event-stream` byte stream.
|
|
///
|
|
/// Feed it raw chunks as they arrive (`consume`); it buffers partial lines
|
|
/// across chunk boundaries, understands LF and CRLF line endings, and emits
|
|
/// an `SSEFrame` for every blank-line dispatch that accumulated at least one
|
|
/// `data:` line. Field handling follows the WHATWG EventSource algorithm:
|
|
///
|
|
/// - `data:` — multi-line, joined with `\n` at dispatch.
|
|
/// - `id:` — sets the block id (ignored when it contains U+0000, per spec).
|
|
/// Like EventSource's last-event-id buffer, an id from a block that never
|
|
/// dispatched (no data lines) carries over into the next block; a real
|
|
/// dispatch consumes it.
|
|
/// - `event:` — sets the frame's event name.
|
|
/// - `retry:` — ignored; reconnect policy is entirely client-owned (sse.md).
|
|
/// - `: comment` lines and unknown fields — ignored.
|
|
///
|
|
/// Not handled (the hub never produces them): lone-CR line endings and a
|
|
/// leading UTF-8 BOM. Create a fresh parser per connection; state must never
|
|
/// bleed across reconnects.
|
|
public struct SSELineParser: Sendable {
|
|
private var pendingLine = Data()
|
|
private var dataLines: [String] = []
|
|
private var eventType: String?
|
|
private var eventId: String?
|
|
|
|
public init() {}
|
|
|
|
/// Consumes one chunk and returns every frame completed by it, in order.
|
|
public mutating func consume(_ chunk: Data) -> [SSEFrame] {
|
|
var frames: [SSEFrame] = []
|
|
for byte in chunk {
|
|
if byte == 0x0A { // LF terminates a line; strip an optional CR.
|
|
if pendingLine.last == 0x0D {
|
|
pendingLine.removeLast()
|
|
}
|
|
process(line: pendingLine, into: &frames)
|
|
pendingLine.removeAll(keepingCapacity: true)
|
|
} else {
|
|
pendingLine.append(byte)
|
|
}
|
|
}
|
|
return frames
|
|
}
|
|
|
|
private mutating func process(line: Data, into frames: inout [SSEFrame]) {
|
|
if line.isEmpty {
|
|
// Blank line: dispatch. Per spec, no data lines means no event —
|
|
// the event type buffer resets, but the id buffer survives so a
|
|
// (hypothetical) id-only block still tags the next dispatch.
|
|
if !dataLines.isEmpty {
|
|
frames.append(SSEFrame(id: eventId, event: eventType, data: dataLines.joined(separator: "\n")))
|
|
dataLines.removeAll(keepingCapacity: true)
|
|
eventId = nil
|
|
}
|
|
eventType = nil
|
|
return
|
|
}
|
|
if line.first == 0x3A { // ':' — comment line.
|
|
return
|
|
}
|
|
|
|
let field: String
|
|
let value: String
|
|
if let colonIndex = line.firstIndex(of: 0x3A) {
|
|
field = String(decoding: line[line.startIndex..<colonIndex], as: UTF8.self)
|
|
var valueBytes = line[line.index(after: colonIndex)...]
|
|
if valueBytes.first == 0x20 { // A single leading space is stripped.
|
|
valueBytes = valueBytes.dropFirst()
|
|
}
|
|
value = String(decoding: valueBytes, as: UTF8.self)
|
|
} else {
|
|
field = String(decoding: line, as: UTF8.self)
|
|
value = ""
|
|
}
|
|
|
|
switch field {
|
|
case "data":
|
|
dataLines.append(value)
|
|
case "event":
|
|
eventType = value
|
|
case "id":
|
|
if !value.contains("\u{0000}") {
|
|
eventId = value
|
|
}
|
|
case "retry":
|
|
break // Server-suggested delays are ignored; policy is ours.
|
|
default:
|
|
break // Unknown fields are ignored per spec.
|
|
}
|
|
}
|
|
}
|