Files
nexus/frontend/src/services/sseHub.ts
T
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

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