export interface SSEOptions { /** Additional headers merged into default SSE headers */ headers?: HeadersInit; /** HTTP status code (default: 200) */ status?: number; } export interface SSESendOptions { event?: string; id?: string; retry?: number; } export type SSECleanup = () => void | Promise; function sanitizeSingleLineField(value: string): string { return value.replace(/[\r\n]+/g, " ").trim(); } function splitSSELines(value: string): string[] { return value.split(/\r\n|[\r\n]/); } const DEFAULT_SSE_HEADERS: HeadersInit = { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", }; export class SSEConnection { readonly response: Response; private controller: ReadableStreamDefaultController | null = null; private encoder = new TextEncoder(); private pending: string[] = []; private closed = false; private cleanupHandlers: SSECleanup[] = []; constructor(signal?: AbortSignal, options: SSEOptions = {}) { const stream = new ReadableStream({ start: (controller) => { this.controller = controller; for (const chunk of this.pending) { controller.enqueue(this.encoder.encode(chunk)); } this.pending = []; }, cancel: () => { void this.close(); }, }); const headers = new Headers(DEFAULT_SSE_HEADERS); if (options.headers) { const extra = new Headers(options.headers); extra.forEach((value, key) => headers.set(key, value)); } this.response = new Response(stream, { status: options.status ?? 200, headers, }); signal?.addEventListener("abort", () => this.close(), { once: true }); } send(data: unknown, options: SSESendOptions = {}): void { if (this.closed) return; const lines: string[] = []; if (options.event) { const safeEvent = sanitizeSingleLineField(options.event); if (safeEvent) lines.push(`event: ${safeEvent}`); } if (options.id) { const safeId = sanitizeSingleLineField(options.id); if (safeId) lines.push(`id: ${safeId}`); } if (typeof options.retry === "number") lines.push(`retry: ${Math.max(0, Math.floor(options.retry))}`); const payload = typeof data === "string" ? data : JSON.stringify(data); const payloadLines = splitSSELines(payload); for (const line of payloadLines) { lines.push(`data: ${line}`); } this.enqueue(`${lines.join("\n")}\n\n`); } event(name: string, data: unknown, options: Omit = {}): void { this.send(data, { ...options, event: name }); } comment(text: string): void { if (this.closed) return; const lines = splitSSELines(text).map((line) => `: ${line}`); this.enqueue(`${lines.join("\n")}\n\n`); } heartbeat(intervalMs = 15000, comment = "heartbeat"): () => void { const timer = setInterval(() => { if (this.closed) return; this.comment(comment); }, Math.max(1000, intervalMs)); const stop = () => clearInterval(timer); this.onClose(stop); return stop; } onClose(handler: SSECleanup): void { if (this.closed) { void Promise.resolve(handler()).catch(() => { // ignore cleanup errors after close }); return; } this.cleanupHandlers.push(handler); } async close(): Promise { if (this.closed) return; this.closed = true; try { this.controller?.close(); } catch { // no-op } const handlers = [...this.cleanupHandlers]; this.cleanupHandlers = []; for (const handler of handlers) { try { await handler(); } catch { // continue running remaining cleanup handlers } } } private enqueue(chunk: string): void { if (this.controller) { try { this.controller.enqueue(this.encoder.encode(chunk)); } catch { void this.close(); } return; } this.pending.push(chunk); } } /** * Create a production-safe Server-Sent Events connection helper. */ export function createSSEConnection(signal?: AbortSignal, options: SSEOptions = {}): SSEConnection { return new SSEConnection(signal, options); }