/** * Minimal SSE helper — wraps `EventSource` so consumers get typed event * callbacks and a single `close()` for cleanup. * * Used by the upload flow to stream /api/progress/{task_id} events. * Kept framework-agnostic so any component can drive it; a thin React * hook layer sits on top in lib/api/documents.ts. */ export type SSEHandlers = { onMessage: (data: T) => void; onError?: (err: Event) => void; /* Called when the server closes the stream cleanly. EventSource has * no native "closed" event, so the backend signals completion via a * terminal payload and we close from the onMessage handler — callers * can return `true` to trigger this path. */ onClose?: () => void; }; export function openSSE( url: string, { onMessage, onError, onClose }: SSEHandlers, ): () => void { const es = new EventSource(url); let closed = false; const close = () => { if (closed) return; closed = true; es.close(); onClose?.(); }; es.addEventListener("message", (ev) => { if (closed) return; try { const payload = JSON.parse(ev.data) as T; onMessage(payload); } catch { /* backend sends heartbeats as comments — EventSource filters them, * so any non-JSON message here is a protocol bug worth ignoring */ } }); es.addEventListener("error", (ev) => { if (closed) return; onError?.(ev); /* EventSource auto-reconnects; we only close on an explicit terminal * payload from the server, not on transient network errors */ }); return close; }