import type { ChatSseEvent } from "./public-contracts.js"; import { parseChatSseEvent } from "./contracts.js"; import { WidgetError } from "./errors.js"; /** * Parses arbitrary byte chunks into validated SSE data events. Neither UTF-8 * characters nor SSE frame boundaries are assumed to align with stream reads. */ export async function* parseSseStream( stream: ReadableStream, ): AsyncGenerator { const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ""; try { while (true) { const reading = await reader.read(); if (reading.done) { buffer += decoder.decode(); break; } buffer += decoder.decode(reading.value, { stream: true }); // The final section is retained because its terminating blank line may // arrive in a later read. const extraction = extractCompleteFrames(buffer); buffer = extraction.remainder; for (const frame of extraction.frames) { const event = parseFrame(frame); if (event !== undefined) yield event; } } // Accept a valid final frame without a trailing blank line, while the API // client still requires a terminal `done` event for stream completeness. if (buffer.trim().length > 0) { const event = parseFrame(buffer); if (event !== undefined) yield event; } } finally { reader.releaseLock(); } } function extractCompleteFrames(buffer: string): { frames: string[]; remainder: string; } { const sections = buffer.split(/\r?\n\r?\n/); return { frames: sections.slice(0, -1), remainder: sections.at(-1) ?? "", }; } function parseFrame(frame: string): ChatSseEvent | undefined { // Multiple data lines are legal SSE and are joined according to the protocol. // Comments and unrelated SSE fields are intentionally ignored. const payload = frame .split(/\r?\n/) .filter((line) => line.startsWith("data:")) .map((line) => line.slice(5).trimStart()) .join("\n"); if (payload.length === 0) return undefined; let value: unknown; try { value = JSON.parse(payload) as unknown; } catch { throw new WidgetError("stream_error"); } return parseChatSseEvent(value); }