/** * Minimal SSE writer for Route Handlers. Each event is one JSON object per `data:` line. * Heartbeats keep ngrok / proxies from closing idle connections during long reasoning. */ export function sseResponse(run: (emit: (event: unknown) => void, signal: AbortSignal) => Promise, opts: { requestId?: string; heartbeatMs?: number } = {}): Response { const encoder = new TextEncoder(); const abort = new AbortController(); let closed = false; const stream = new ReadableStream({ start(controller) { const emit = (event: unknown) => { if (closed) return; try { controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); } catch { closed = true; } }; const hb = setInterval(() => { if (closed) return; try { controller.enqueue(encoder.encode(`: ping\n\n`)); } catch { closed = true; } }, opts.heartbeatMs ?? 15_000); run(emit, abort.signal) .catch((e) => { emit({ type: "error", error: { code: "INTERNAL_ERROR", message: (e as Error)?.message ?? "Internal error", retryable: false } }); }) .finally(() => { clearInterval(hb); if (!closed) { closed = true; try { controller.close(); } catch { /* already closed */ } } }); }, cancel() { closed = true; abort.abort(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", ...(opts.requestId ? { "X-Request-Id": opts.requestId } : {}), }, }); }