UNPKG

@durable-streams/client

Version:

TypeScript client for the Durable Streams protocol

156 lines (141 loc) 5.11 kB
/** * SSE (Server-Sent Events) parsing utilities for the durable streams protocol. * * SSE format from protocol: * - `event: data` events contain the stream data * - `event: control` events contain `streamNextOffset` and optional `streamCursor` and `upToDate` */ import { DurableStreamError } from "./error" import type { Offset } from "./types" /** * Parsed SSE event from the stream. */ export interface SSEDataEvent { type: `data` data: string } export interface SSEControlEvent { type: `control` streamNextOffset: Offset streamCursor?: string upToDate?: boolean streamClosed?: boolean } export type SSEEvent = SSEDataEvent | SSEControlEvent /** * Parse SSE events from a ReadableStream<Uint8Array>. * Yields parsed events as they arrive. */ export async function* parseSSEStream( stream: ReadableStream<Uint8Array>, signal?: AbortSignal ): AsyncGenerator<SSEEvent, void, undefined> { const reader = stream.getReader() const decoder = new TextDecoder() let buffer = `` let currentEvent: { type?: string; data: Array<string> } = { data: [] } try { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { if (signal?.aborted) { break } const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) // Normalize line endings: CRLF → LF, lone CR → LF (per SSE spec) buffer = buffer.replace(/\r\n/g, `\n`).replace(/\r/g, `\n`) // Process complete lines const lines = buffer.split(`\n`) // Keep the last incomplete line in the buffer buffer = lines.pop() ?? `` for (const line of lines) { if (line === ``) { // Empty line signals end of event if (currentEvent.type && currentEvent.data.length > 0) { const dataStr = currentEvent.data.join(`\n`) if (currentEvent.type === `data`) { yield { type: `data`, data: dataStr } } else if (currentEvent.type === `control`) { try { const control = JSON.parse(dataStr) as { streamNextOffset: Offset streamCursor?: string upToDate?: boolean streamClosed?: boolean } yield { type: `control`, streamNextOffset: control.streamNextOffset, streamCursor: control.streamCursor, upToDate: control.upToDate, streamClosed: control.streamClosed, } } catch (err) { // Control events contain critical offset data - don't silently ignore const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr throw new DurableStreamError( `Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR` ) } } // Unknown event types are silently skipped per protocol } currentEvent = { data: [] } } else if (line.startsWith(`event:`)) { // Per SSE spec, strip only one optional space after "event:" const eventType = line.slice(6) currentEvent.type = eventType.startsWith(` `) ? eventType.slice(1) : eventType } else if (line.startsWith(`data:`)) { // Per SSE spec, strip the optional space after "data:" const content = line.slice(5) currentEvent.data.push( content.startsWith(` `) ? content.slice(1) : content ) } // Ignore other fields (id, retry, comments) } } // Handle any remaining data const remaining = decoder.decode() if (remaining) { buffer += remaining } // Process any final event if (buffer && currentEvent.type && currentEvent.data.length > 0) { const dataStr = currentEvent.data.join(`\n`) if (currentEvent.type === `data`) { yield { type: `data`, data: dataStr } } else if (currentEvent.type === `control`) { try { const control = JSON.parse(dataStr) as { streamNextOffset: Offset streamCursor?: string upToDate?: boolean streamClosed?: boolean } yield { type: `control`, streamNextOffset: control.streamNextOffset, streamCursor: control.streamCursor, upToDate: control.upToDate, streamClosed: control.streamClosed, } } catch (err) { const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr throw new DurableStreamError( `Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR` ) } } } } finally { reader.releaseLock() } }