@durable-streams/client
Version:
TypeScript client for the Durable Streams protocol
156 lines (141 loc) • 5.11 kB
text/typescript
/**
* 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()
}
}