230 lines
7.5 KiB
TypeScript
230 lines
7.5 KiB
TypeScript
import { createParser, type EventSourceMessage } from 'eventsource-parser'
|
|
import { apiFetch } from './api'
|
|
|
|
export type SseConnectionState = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'unsupported' | 'error'
|
|
|
|
export interface ParsedSseEvent<T = unknown> {
|
|
id: string | null
|
|
event: string
|
|
data: T
|
|
}
|
|
|
|
export interface SseSubscriptionOptions {
|
|
lastEventId?: string | null
|
|
onStateChange?: (state: SseConnectionState) => void
|
|
}
|
|
|
|
type Subscriber = {
|
|
onEvent: (event: ParsedSseEvent) => void
|
|
onStateChange?: (state: SseConnectionState) => void
|
|
}
|
|
|
|
type Connection = {
|
|
url: string
|
|
subscribers: Set<Subscriber>
|
|
controller: AbortController | null
|
|
loop: Promise<void> | null
|
|
lastEventId: string | null
|
|
retryMs: number
|
|
stopped: boolean
|
|
}
|
|
|
|
const MAX_BUFFER_SIZE = 256 * 1024
|
|
const MAX_BACKOFF_MS = 30_000
|
|
const HEARTBEAT_TIMEOUT_MS = 45_000
|
|
|
|
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if (signal.aborted) {
|
|
reject(signal.reason)
|
|
return
|
|
}
|
|
const timer = globalThis.setTimeout(resolve, ms)
|
|
signal.addEventListener('abort', () => {
|
|
globalThis.clearTimeout(timer)
|
|
reject(signal.reason)
|
|
}, { once: true })
|
|
})
|
|
}
|
|
|
|
function parseData(raw: string): unknown {
|
|
try {
|
|
return JSON.parse(raw)
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
export class AuthenticatedSseHub {
|
|
private readonly connections = new Map<string, Connection>()
|
|
|
|
subscribe(
|
|
url: string,
|
|
onEvent: (event: ParsedSseEvent) => void,
|
|
options: SseSubscriptionOptions = {},
|
|
): () => void {
|
|
let connection = this.connections.get(url)
|
|
if (!connection) {
|
|
connection = {
|
|
url,
|
|
subscribers: new Set(),
|
|
controller: null,
|
|
loop: null,
|
|
lastEventId: options.lastEventId ?? null,
|
|
retryMs: 1_000,
|
|
stopped: false,
|
|
}
|
|
this.connections.set(url, connection)
|
|
} else if (!connection.lastEventId && options.lastEventId) {
|
|
connection.lastEventId = options.lastEventId
|
|
}
|
|
|
|
const subscriber: Subscriber = { onEvent, onStateChange: options.onStateChange }
|
|
connection.subscribers.add(subscriber)
|
|
connection.stopped = false
|
|
if (!connection.loop) connection.loop = this.run(connection)
|
|
|
|
return () => {
|
|
connection?.subscribers.delete(subscriber)
|
|
if (connection && connection.subscribers.size === 0) {
|
|
connection.stopped = true
|
|
connection.controller?.abort('no-subscribers')
|
|
this.connections.delete(url)
|
|
}
|
|
}
|
|
}
|
|
|
|
closeAll(): void {
|
|
for (const connection of this.connections.values()) {
|
|
connection.stopped = true
|
|
connection.controller?.abort('hub-closed')
|
|
this.notify(connection, 'closed')
|
|
}
|
|
this.connections.clear()
|
|
}
|
|
|
|
private notify(connection: Connection, state: SseConnectionState): void {
|
|
for (const subscriber of connection.subscribers) subscriber.onStateChange?.(state)
|
|
}
|
|
|
|
private dispatch(connection: Connection, event: ParsedSseEvent): void {
|
|
queueMicrotask(() => {
|
|
for (const subscriber of connection.subscribers) subscriber.onEvent(event)
|
|
})
|
|
}
|
|
|
|
private async waitBeforeReconnect(
|
|
connection: Connection,
|
|
state: SseConnectionState,
|
|
): Promise<void> {
|
|
if (connection.stopped || connection.subscribers.size === 0) return
|
|
|
|
this.notify(connection, state)
|
|
const controller = new AbortController()
|
|
connection.controller = controller
|
|
const jitter = Math.round(connection.retryMs * (0.8 + Math.random() * 0.4))
|
|
await delay(jitter, controller.signal).catch(() => undefined)
|
|
if (!connection.stopped) {
|
|
connection.retryMs = Math.min(connection.retryMs * 2, MAX_BACKOFF_MS)
|
|
}
|
|
}
|
|
|
|
private async run(connection: Connection): Promise<void> {
|
|
try {
|
|
while (!connection.stopped && connection.subscribers.size > 0) {
|
|
const controller = new AbortController()
|
|
connection.controller = controller
|
|
this.notify(connection, connection.lastEventId ? 'reconnecting' : 'connecting')
|
|
|
|
try {
|
|
const headers = new Headers({
|
|
Accept: 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
})
|
|
if (connection.lastEventId) headers.set('Last-Event-ID', connection.lastEventId)
|
|
|
|
const response = await apiFetch(connection.url, { headers, signal: controller.signal })
|
|
if (response.status === 404 || response.status === 501) {
|
|
this.notify(connection, 'unsupported')
|
|
await delay(60_000, controller.signal)
|
|
continue
|
|
}
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`SSE unavailable: HTTP ${response.status}`)
|
|
}
|
|
|
|
let parserFailure: Error | null = null
|
|
let serverDirectedRetry = false
|
|
const parser = createParser({
|
|
maxBufferSize: MAX_BUFFER_SIZE,
|
|
onRetry: retry => {
|
|
serverDirectedRetry = true
|
|
connection.retryMs = Math.min(Math.max(retry, 500), MAX_BACKOFF_MS)
|
|
},
|
|
onError: error => {
|
|
if (error.type === 'max-buffer-size-exceeded') {
|
|
parserFailure = error
|
|
controller.abort(error)
|
|
}
|
|
},
|
|
onEvent: (message: EventSourceMessage) => {
|
|
if (!serverDirectedRetry) connection.retryMs = 1_000
|
|
if (message.id) connection.lastEventId = message.id
|
|
this.dispatch(connection, {
|
|
id: message.id ?? null,
|
|
event: message.event || 'message',
|
|
data: parseData(message.data),
|
|
})
|
|
},
|
|
})
|
|
|
|
this.notify(connection, 'open')
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let heartbeatTimer: ReturnType<typeof globalThis.setTimeout> | null = null
|
|
const armHeartbeatTimeout = () => {
|
|
if (heartbeatTimer !== null) globalThis.clearTimeout(heartbeatTimer)
|
|
heartbeatTimer = globalThis.setTimeout(() => {
|
|
controller.abort(new Error('SSE heartbeat timeout'))
|
|
}, HEARTBEAT_TIMEOUT_MS)
|
|
}
|
|
armHeartbeatTimeout()
|
|
try {
|
|
while (!controller.signal.aborted) {
|
|
const { value, done } = await reader.read()
|
|
if (done) break
|
|
armHeartbeatTimeout()
|
|
parser.feed(decoder.decode(value, { stream: true }))
|
|
if (parserFailure) throw parserFailure
|
|
}
|
|
const tail = decoder.decode()
|
|
if (tail) parser.feed(tail)
|
|
parser.reset({ consume: true })
|
|
} finally {
|
|
if (heartbeatTimer !== null) globalThis.clearTimeout(heartbeatTimer)
|
|
reader.releaseLock()
|
|
}
|
|
|
|
// A clean EOF is still a disconnected live stream. Back off before
|
|
// reconnecting so a server that closes immediately cannot trigger a
|
|
// tight request loop.
|
|
if (!controller.signal.aborted) {
|
|
await this.waitBeforeReconnect(connection, 'reconnecting')
|
|
}
|
|
} catch (error) {
|
|
if (connection.stopped || controller.signal.reason === 'no-subscribers' || controller.signal.reason === 'hub-closed') break
|
|
await this.waitBeforeReconnect(connection, 'error')
|
|
}
|
|
}
|
|
} finally {
|
|
connection.controller = null
|
|
connection.loop = null
|
|
if (!connection.stopped && connection.subscribers.size > 0) {
|
|
connection.loop = this.run(connection)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const sseHub = new AuthenticatedSseHub()
|