Files
simstudioai--sim/apps/sim/lib/core/utils/sse.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

272 lines
8.7 KiB
TypeScript

/**
* Standard headers for Server-Sent Events responses
*/
export const SSE_HEADERS = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
} as const
/**
* Encodes data as a Server-Sent Events (SSE) message.
* Formats the data as a JSON string prefixed with "data:" and suffixed with two newlines,
* then encodes it as a Uint8Array for streaming.
*
* @param data - The data to encode and send via SSE
* @returns The encoded SSE message as a Uint8Array
*/
export function encodeSSE(data: any): Uint8Array {
return new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`)
}
/**
* The sentinel value servers emit to signal end-of-stream. Lines carrying this
* payload are skipped before reaching the consumer's `onEvent` callback.
*/
const DONE_SENTINEL = '[DONE]'
/**
* A source the SSE reader can consume: a fetch `Response`, its `ReadableStream`
* body, or an already-acquired reader. Passing a `Response`/stream lets the
* primitive own `getReader()` and the reader lifecycle (lock release); passing a
* reader is supported for callers that must acquire it first (e.g. to stash it
* for external cancellation).
*/
export type SSESource =
| Response
| ReadableStream<Uint8Array>
| ReadableStreamDefaultReader<Uint8Array>
/**
* The result of an SSE event/line callback. Only the literal `true` (returned
* synchronously or resolved from a `Promise`) stops processing and returns
* early — useful for terminal events. Any other value (including the
* `undefined` a handler that returns nothing produces) keeps processing.
*
* Typed as `unknown` rather than `boolean | void | Promise<boolean | void>` so
* both sync and `async` handlers — including `async` handlers that return
* nothing (`Promise<void>`) — stay assignable, without the confusing
* `void`-inside-a-`Promise` union that the precise type would require.
*/
export type SSEStopSignal = unknown
/**
* Options for {@link readSSELines} — the low-level line engine that delivers the
* raw `data:` payload string (no JSON parsing).
*/
export interface ReadSSELinesOptions {
/** Invoked once per SSE `data:` line with the raw (un-parsed) payload string. */
onData: (rawData: string) => SSEStopSignal
/** Aborts the read; checked before each chunk and between events. */
signal?: AbortSignal
}
/**
* Options for {@link readSSEEvents} — the JSON convenience layer over
* {@link readSSELines}.
*/
export interface ReadSSEEventsOptions<T> {
/**
* Invoked once per parsed SSE `data:` event with the JSON-parsed payload.
* Return (or resolve) `true` to stop processing and return early. Callers
* narrow the typed payload.
*/
onEvent: (event: T) => SSEStopSignal
/**
* Invoked for a `data:` line whose payload is not valid JSON. Defaults to
* silently skipping the line. Throw from here to surface a fatal parse error.
*/
onParseError?: (rawData: string, error: unknown) => void
/** Aborts the read; checked before each chunk and between events. */
signal?: AbortSignal
}
/**
* Resolves an {@link SSESource} to a reader, reporting whether this call
* acquired the lock (and is therefore responsible for releasing it).
*/
function toReader(source: SSESource): {
reader: ReadableStreamDefaultReader<Uint8Array>
ownsLock: boolean
} {
if (source instanceof ReadableStream) {
return { reader: source.getReader(), ownsLock: true }
}
if (source instanceof Response) {
if (!source.body) throw new Error('No response body')
return { reader: source.body.getReader(), ownsLock: true }
}
return { reader: source, ownsLock: false }
}
/**
* Strips an optional trailing carriage return from a single SSE line, so both
* `\n`- and `\r\n`-terminated framings parse identically.
*/
function stripCarriageReturn(line: string): string {
return line.endsWith('\r') ? line.slice(0, -1) : line
}
/**
* The single client-side SSE decode engine. Reads a byte stream, decodes it
* incrementally, splits it into lines, and invokes `onData` once per `data:`
* line with its raw (un-parsed) payload string.
*
* It splits on `\n` and processes each `data:` line individually, which makes it
* tolerant of BOTH `\n`- and `\n\n`-separated framings (the blank separator
* lines between events are simply ignored). Trailing `\r` is stripped, a single
* optional space after `data:` is consumed, and the `[DONE]` sentinel is
* skipped. The reader's lock is always released on completion, abort, or error
* (only when this function acquired it).
*
* This is the low-level engine. Most callers want {@link readSSEEvents}, which
* adds JSON parsing on top. Reach for `readSSELines` only when the payload needs
* custom parsing (e.g. schema-validated decoding).
*
* @param source - A `Response`, `ReadableStream`, or stream reader.
* @param options - The `onData` callback plus an optional `signal`.
*/
export async function readSSELines(source: SSESource, options: ReadSSELinesOptions): Promise<void> {
const { onData, signal } = options
const { reader, ownsLock } = toReader(source)
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
if (signal?.aborted) break
const { done, value } = await reader.read()
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = done ? '' : (lines.pop() ?? '')
for (const rawLine of lines) {
if (signal?.aborted) return
const line = stripCarriageReturn(rawLine)
if (!line.startsWith('data:')) continue
let data = line.slice(5)
if (data.startsWith(' ')) data = data.slice(1)
if (data === DONE_SENTINEL) continue
if ((await onData(data)) === true) return
}
if (done) break
}
} finally {
if (ownsLock) reader.releaseLock()
}
}
/**
* The JSON convenience layer over {@link readSSELines}: invokes `onEvent` once
* per `data:` event with its JSON-parsed payload. Unparseable lines are passed
* to `onParseError` (default: silently skipped). All framing, `\r`, `[DONE]`,
* abort, and reader-lifecycle behavior is inherited from `readSSELines`.
*
* Higher-level concerns — UI batching, reconnect, error classification, event
* dispatch — belong in the caller's `onEvent`, not here.
*
* @typeParam T - The parsed event type the caller expects (defaults to `unknown`).
* @param source - A `Response`, `ReadableStream`, or stream reader.
* @param options - The `onEvent` callback plus optional `signal`/`onParseError`.
*/
export async function readSSEEvents<T = unknown>(
source: SSESource,
options: ReadSSEEventsOptions<T>
): Promise<void> {
const { onEvent, onParseError, signal } = options
await readSSELines(source, {
signal,
onData: (data) => {
let parsed: T
try {
parsed = JSON.parse(data) as T
} catch (error) {
onParseError?.(data, error)
return
}
return onEvent(parsed)
},
})
}
/**
* Options for reading SSE stream
*/
export interface ReadSSEStreamOptions {
onChunk?: (chunk: string) => void
onAccumulated?: (accumulated: string) => void
signal?: AbortSignal
}
/**
* Reads and parses an SSE stream from a Response body.
* Handles the wand API SSE format with data chunks and done signals.
*
* @param body - The ReadableStream body from a fetch Response
* @param options - Callbacks for handling stream data
* @returns The accumulated content from the stream
*/
export async function readSSEStream(
body: ReadableStream<Uint8Array>,
options: ReadSSEStreamOptions = {}
): Promise<string> {
const { onChunk, onAccumulated, signal } = options
const reader = body.getReader()
const decoder = new TextDecoder()
let accumulatedContent = ''
let buffer = ''
try {
while (true) {
if (signal?.aborted) {
break
}
const { done, value } = await reader.read()
if (done) {
const remaining = decoder.decode()
if (remaining) {
buffer += remaining
}
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const lineData = line.substring(6)
if (lineData === '[DONE]') continue
try {
const data = JSON.parse(lineData)
if (data.error) throw new Error(data.error)
if (data.chunk) {
accumulatedContent += data.chunk
onChunk?.(data.chunk)
onAccumulated?.(accumulatedContent)
}
if (data.done) break
} catch {
// Skip unparseable lines
}
}
}
}
} finally {
reader.releaseLock()
}
return accumulatedContent
}