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.
60 lines
2.1 KiB
Swift
60 lines
2.1 KiB
Swift
import Foundation
|
|
#if canImport(Network)
|
|
import Network
|
|
#endif
|
|
|
|
/// A snapshot of the device's network path.
|
|
public struct NetworkPathUpdate: Equatable, Sendable {
|
|
/// Whether the path can carry traffic (`NWPath.Status.satisfied`).
|
|
public var isSatisfied: Bool
|
|
/// Cellular / personal-hotspot style paths (informational).
|
|
public var isExpensive: Bool
|
|
|
|
public init(isSatisfied: Bool, isExpensive: Bool = false) {
|
|
self.isSatisfied = isSatisfied
|
|
self.isExpensive = isExpensive
|
|
}
|
|
}
|
|
|
|
/// Source of network-path change notifications for `SSEClient`.
|
|
///
|
|
/// The stream's FIRST element is the baseline path reported on subscription
|
|
/// (NWPathMonitor always fires once immediately); every subsequent element is
|
|
/// an actual change. `SSEClient` skips the baseline and treats any later
|
|
/// update while connected as a transport error — the old socket is almost
|
|
/// certainly routed over a path that no longer exists, and reconnecting
|
|
/// immediately beats waiting for the 90 s staleness watchdog.
|
|
public protocol NetworkPathObserving: Sendable {
|
|
func pathUpdates() -> AsyncStream<NetworkPathUpdate>
|
|
}
|
|
|
|
#if canImport(Network)
|
|
/// Production observer backed by `NWPathMonitor`.
|
|
public struct NWPathObserver: NetworkPathObserving {
|
|
/// `NWPathMonitor` is not Sendable; it is confined to its own dispatch
|
|
/// queue and only ever touched from the update handler / termination
|
|
/// callback, so boxing it is safe.
|
|
private final class MonitorBox: @unchecked Sendable {
|
|
let monitor = NWPathMonitor()
|
|
}
|
|
|
|
public init() {}
|
|
|
|
public func pathUpdates() -> AsyncStream<NetworkPathUpdate> {
|
|
AsyncStream { continuation in
|
|
let box = MonitorBox()
|
|
box.monitor.pathUpdateHandler = { path in
|
|
continuation.yield(NetworkPathUpdate(
|
|
isSatisfied: path.status == .satisfied,
|
|
isExpensive: path.isExpensive
|
|
))
|
|
}
|
|
box.monitor.start(queue: DispatchQueue(label: "run.hapi.sse.path-monitor"))
|
|
continuation.onTermination = { _ in
|
|
box.monitor.cancel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|