// SSE (Server-Sent Events) client for POST-based streaming endpoints // Works with fetch() + ReadableStream since EventSource only supports GET const SSE = { /** * POST to a streaming endpoint and handle SSE events. * @param {string} url - The endpoint URL * @param {object} body - JSON request body * @param {object} handlers - Map of event name → callback(data) * Special handlers: * 'error' - called on error events or fetch failures * 'done' - called when stream ends * 'start' - called when stream starts (first event) * @returns {object} controller with abort() method */ async post(url, body, handlers = {}) { const controller = new AbortController(); const run = async () => { try { const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body), signal: controller.signal, }); if (!resp.ok) { const errText = await resp.text(); let msg = `HTTP ${resp.status}`; try { const errJson = JSON.parse(errText); msg = errJson.error || errJson.message || msg; } catch (_) { msg = errText || msg; } if (handlers.error) handlers.error({ message: msg }); return; } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let currentEvent = ''; let doneReceived = false; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('event: ')) { currentEvent = line.slice(7).trim(); } else if (line.startsWith('data: ')) { const raw = line.slice(6); let data; try { data = JSON.parse(raw); } catch (_) { data = raw; } // Track explicit done event from backend if (currentEvent === 'done') doneReceived = true; // Call the matching handler if (currentEvent && handlers[currentEvent]) { handlers[currentEvent](data); } } } } // Stream ended — only fire done if backend didn't send an explicit one if (handlers.done && !doneReceived) handlers.done(); } catch (err) { if (err.name === 'AbortError') return; if (handlers.error) handlers.error({ message: err.message }); } }; run(); return { abort: () => controller.abort() }; } }; window.SSE = SSE;