import { type LlamaSwapConfig } from "./util"; import { parseSse, type SseMessage } from "./sse"; import { type FeedEvent, type InflightRequest, type ModelState } from "./inflight-tracker"; export function decodeEvent(msg: SseMessage): FeedEvent | null { try { const outer = JSON.parse(msg.data) as { type?: string; data?: string }; if (!outer.data) return null; const inner = JSON.parse(outer.data) as Record; if (outer.type === "activity") { const id = typeof inner.id === "number" ? inner.id : Number.NaN; if (Number.isFinite(id)) return { type: "activity", id }; return null; } if (outer.type === "inflight") { const operation = inner.operation as string; if (operation === "remove") { const id = typeof inner.id === "string" ? inner.id : undefined; if (id) return { type: "inflight", operation: "remove", id }; return null; } if (operation !== "snapshot" && operation !== "upsert" && operation !== "add") return null; const requests: InflightRequest[] = Array.isArray(inner.requests) ? (inner.requests as InflightRequest[]) : Array.isArray(inner.request) ? (inner.request as InflightRequest[]) : inner.request && typeof inner.request === "object" ? [inner.request as InflightRequest] : []; return { type: "inflight", operation: operation === "snapshot" ? "snapshot" : "add", requests, }; } if (outer.type === "modelStatus") { if (!Array.isArray(inner)) return null; return { type: "modelStatus", models: inner as unknown as ModelState[] }; } return null; } catch { return null; } } const MAX_RETRY_MS = 30000; const INITIAL_RETRY_MS = 1000; export class EventFeed { private controller?: AbortController; private closed = false; private retryMs = INITIAL_RETRY_MS; private statusHandler?: (connected: boolean) => void; constructor( private cfg: LlamaSwapConfig, private onEvent: (ev: FeedEvent) => void, ) {} setStatusHandler(fn: (connected: boolean) => void): void { this.statusHandler = fn; } start(): void { void this.connect(); } stop(): void { this.closed = true; this.controller?.abort(); } private async connect(): Promise { while (!this.closed) { this.controller = new AbortController(); try { const headers: Record = { Accept: "text/event-stream" }; if (this.cfg.apiKey) headers["Authorization"] = `Bearer ${this.cfg.apiKey}`; const res = await fetch(`${this.cfg.baseUrl}/api/events`, { headers, signal: this.controller.signal, }); if (!res.ok || !res.body) throw new Error(`events HTTP ${res.status}`); this.statusHandler?.(true); this.retryMs = INITIAL_RETRY_MS; await this.readStream(res.body.getReader()); } catch { if (this.closed) return; } this.statusHandler?.(false); if (this.closed) return; await sleep(this.retryMs); this.retryMs = Math.min(this.retryMs * 2, MAX_RETRY_MS); } } private async readStream(reader: ReadableStreamDefaultReader): Promise { const decoder = new TextDecoder(); let buffer = ""; try { while (!this.closed) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const { messages, rest } = parseSse(buffer); buffer = rest; for (const msg of messages) { const ev = decodeEvent(msg); if (ev) this.onEvent(ev); } } } finally { reader.releaseLock(); } } } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }