import type { InvalidationEvent } from "./invalidation"; export const SSE_HEARTBEAT_MS = 90_000; export const SSE_MAX_LIFETIME_MS = 30 * 60 * 1_000; const encoder = new TextEncoder(); export interface EventStreamOptions { signal?: AbortSignal; heartbeatMs?: number; maximumLifetimeMs?: number; onClose?: () => void; } export interface EventStreamChannel { stream: ReadableStream; send(event: InvalidationEvent): boolean; close(): void; } export function eventStreamHeaders(): Headers { return new Headers({ "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "Content-Type": "text/event-stream; charset=utf-8", "X-Accel-Buffering": "no", }); } export function createEventStream( options: EventStreamOptions = {}, ): EventStreamChannel { const heartbeatMs = options.heartbeatMs ?? SSE_HEARTBEAT_MS; const maximumLifetimeMs = options.maximumLifetimeMs ?? SSE_MAX_LIFETIME_MS; let controller: ReadableStreamDefaultController | undefined; let heartbeat: ReturnType | undefined; let lifetime: ReturnType | undefined; let closed = false; const enqueue = (value: string): boolean => { if (closed || !controller) return false; try { controller.enqueue(encoder.encode(value)); return true; } catch { close(false); return false; } }; const abort = () => close(true); const close = (closeController: boolean) => { if (closed) return; closed = true; if (heartbeat) clearInterval(heartbeat); if (lifetime) clearTimeout(lifetime); options.signal?.removeEventListener("abort", abort); if (closeController) { try { controller?.close(); } catch { // The browser may have cancelled the stream before cleanup ran. } } options.onClose?.(); }; const stream = new ReadableStream({ start(streamController) { controller = streamController; enqueue(": connected\n\n"); heartbeat = setInterval(() => { enqueue(`: heartbeat ${Date.now()}\n\n`); }, heartbeatMs); lifetime = setTimeout(() => close(true), maximumLifetimeMs); if (options.signal?.aborted) close(true); else options.signal?.addEventListener("abort", abort, { once: true }); }, cancel() { close(false); }, }); return { stream, send(event) { return enqueue(`event: invalidation\ndata: ${JSON.stringify(event)}\n\n`); }, close() { close(true); }, }; }