UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

416 lines (383 loc) 12.6 kB
const DEFAULT_HEARTBEAT_MS = 25_000; const DEFAULT_MAX_BUFFERED_BYTES = 1_048_576; const MAX_BUFFERED_BYTES = 2_147_483_647; const MAX_TIMER_MS = 2_147_483_647; /** * One JSON-encoded Server-Sent Event. */ export interface ServerSentEventMessage<TData = unknown> { /** JSON-serializable event payload. */ data: TData; /** Optional event type consumed by `EventSource.addEventListener(...)`. */ event?: string; /** Optional event identifier used by browsers for `Last-Event-ID`. */ id?: string; /** Optional browser reconnection delay in milliseconds. */ retry?: number; } /** * Controls exposed while an SSE response is active. */ export interface ServerSentEventStream { /** * Aborts whenever this stream closes, including response cancellation. * Async setup should pass it to cancellable subscription APIs. */ readonly signal: AbortSignal; /** * Send a JSON-encoded event. * * Returns `false` after the stream closes, when encoding fails, or when the * unread byte limit would be exceeded. */ send<TData>(message: ServerSentEventMessage<TData>): boolean; /** * Send an SSE comment. Comments are useful as connection heartbeats. * * Returns `false` when the stream is no longer writable or its unread byte * limit would be exceeded. */ comment(value?: string): boolean; /** Close the stream and run its cleanup exactly once. */ close(): void; } /** Closeable subscription returned by an SSE stream's `start` callback. */ export interface ServerSentEventSubscription { /** Close the associated subscription. */ close(): Promise<void> | void; } /** Cleanup returned by an SSE stream's `start` callback. */ export type ServerSentEventCleanup = | (() => Promise<void> | void) | ServerSentEventSubscription; /** Options for `createServerSentEventResponse(...)`. */ export interface ServerSentEventResponseOptions { /** * Begin producing events. Return a cleanup callback or closeable * subscription for resources associated with this connection. Pending * asynchronous setup does not block response cancellation; honor the stream * signal and return cleanup when setup settles. */ start( stream: ServerSentEventStream, ): | Promise<ServerSentEventCleanup | undefined> | ServerSentEventCleanup | undefined; /** * Abort the stream with the surrounding request or application lifecycle. */ signal?: AbortSignal; /** * Interval for SSE heartbeat comments. Defaults to 25 seconds. Set to * `false` to disable heartbeats. */ heartbeatMs?: number | false; /** * Close the connection after this duration so clients can reconnect and * reconcile. Disabled by default. */ maxLifetimeMs?: number | false; /** * Maximum bytes of unread event data held by the response stream. Defaults * to 1 MiB. The connection closes when one frame or the accumulated queue * would exceed this limit. */ maxBufferedBytes?: number; /** Additional response headers such as CORS or `Vary`. */ headers?: HeadersInit; /** * Observe producer, serialization, buffer, stream, or cleanup failures. * Expected `AbortError` rejections caused by stream closure are ignored. */ onError?(error: unknown): Promise<void> | void; } function assertTimerOption( name: "heartbeatMs" | "maxLifetimeMs", value: number | false | undefined, ): void { if ( value === false || value === undefined || (Number.isInteger(value) && value >= 1 && value <= MAX_TIMER_MS) ) { return; } throw new RangeError( `${name} must be false or an integer between 1 and ${MAX_TIMER_MS} milliseconds.`, ); } function assertMaxBufferedBytes(value: number): void { if (Number.isInteger(value) && value >= 1 && value <= MAX_BUFFERED_BYTES) { return; } throw new RangeError( `maxBufferedBytes must be an integer between 1 and ${MAX_BUFFERED_BYTES} bytes.`, ); } function assertSingleLine(name: "event" | "id", value: string): void { if (value.includes("\r") || value.includes("\n")) { throw new TypeError(`SSE ${name} must not contain line breaks.`); } if (name === "id" && value.includes("\0")) { throw new TypeError("SSE id must not contain null characters."); } } function isPromiseLike<T>(value: T | PromiseLike<T>): value is PromiseLike<T> { return ( value !== null && (typeof value === "object" || typeof value === "function") && "then" in value && typeof value.then === "function" ); } function isAbortError(error: unknown): boolean { try { return ( typeof error === "object" && error !== null && "name" in error && error.name === "AbortError" ); } catch { return false; } } function encodeMessage<TData>(message: ServerSentEventMessage<TData>): string { if (message.event !== undefined) { assertSingleLine("event", message.event); } if (message.id !== undefined) { assertSingleLine("id", message.id); } if ( message.retry !== undefined && (!Number.isInteger(message.retry) || message.retry < 0 || message.retry > MAX_TIMER_MS) ) { throw new RangeError( `SSE retry must be an integer between 0 and ${MAX_TIMER_MS} milliseconds.`, ); } const data = JSON.stringify(message.data); if (data === undefined) { throw new TypeError("SSE data must be JSON-serializable."); } const fields: string[] = []; if (message.event !== undefined) fields.push(`event: ${message.event}`); if (message.id !== undefined) fields.push(`id: ${message.id}`); if (message.retry !== undefined) fields.push(`retry: ${message.retry}`); fields.push(`data: ${data}`); return `${fields.join("\n")}\n\n`; } function encodeComment(value: string): string { return `${value .split(/\r\n|\r|\n/) .map((line) => `: ${line}`) .join("\n")}\n\n`; } function createResponseHeaders(init: HeadersInit | undefined): Headers { const headers = new Headers(init); headers.set("Content-Type", "text/event-stream; charset=utf-8"); headers.set("Cache-Control", "no-store, no-transform"); headers.set("X-Accel-Buffering", "no"); headers.delete("Connection"); headers.delete("Content-Length"); headers.delete("Transfer-Encoding"); return headers; } /** * Create a portable Fetch `Response` that safely manages a Server-Sent Events * stream. * * The helper owns SSE framing, JSON encoding, heartbeats, abort handling, * bounded unread buffering, maximum lifetime, and cleanup. Authentication, * replay, authorization, connection limits, and application reconciliation * remain the caller's responsibility. */ export function createServerSentEventResponse( options: ServerSentEventResponseOptions, ): Response { const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS; const maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES; assertTimerOption("heartbeatMs", heartbeatMs); assertTimerOption("maxLifetimeMs", options.maxLifetimeMs); assertMaxBufferedBytes(maxBufferedBytes); const headers = createResponseHeaders(options.headers); const encoder = new TextEncoder(); const lifecycleController = new AbortController(); let cleanup: ServerSentEventCleanup | undefined; let cleanupPromise: Promise<void> | undefined; let closed = false; let heartbeatTimer: ReturnType<typeof setInterval> | undefined; let lifetimeTimer: ReturnType<typeof setTimeout> | undefined; let abortListener: (() => void) | undefined; let closeStream: (() => void) | undefined; const reportError = (error: unknown): void => { if (!options.onError) return; void Promise.resolve() .then(() => options.onError?.(error)) .catch(() => undefined); }; const runCleanup = (): Promise<void> => { if (cleanupPromise) return cleanupPromise; if (!cleanup) return Promise.resolve(); const activeCleanup = cleanup; cleanupPromise = Promise.resolve() .then(() => typeof activeCleanup === "function" ? activeCleanup() : activeCleanup.close(), ) .catch(reportError); return cleanupPromise; }; const stream = new ReadableStream<Uint8Array>( { start(controller) { const clearLifecycle = () => { if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer); if (lifetimeTimer !== undefined) clearTimeout(lifetimeTimer); if (abortListener) { options.signal?.removeEventListener("abort", abortListener); } }; const close = () => { if (closed) return; closed = true; clearLifecycle(); lifecycleController.abort(); void runCleanup(); try { controller.close(); } catch { // Cancellation may already have detached the stream controller. } }; closeStream = close; const enqueue = (value: string): boolean => { if (closed) return false; try { const chunk = encoder.encode(value); const availableBytes = controller.desiredSize; if (availableBytes === null || chunk.byteLength > availableBytes) { reportError( new RangeError( `SSE buffer limit of ${maxBufferedBytes} bytes exceeded.`, ), ); close(); return false; } controller.enqueue(chunk); return true; } catch (error) { reportError(error); close(); return false; } }; const controls: ServerSentEventStream = { signal: lifecycleController.signal, send(message) { if (closed) return false; try { return enqueue(encodeMessage(message)); } catch (error) { reportError(error); close(); return false; } }, comment(value = "") { if (closed) return false; try { return enqueue(encodeComment(value)); } catch (error) { reportError(error); close(); return false; } }, close, }; if (options.signal?.aborted) { close(); return; } abortListener = close; options.signal?.addEventListener("abort", abortListener, { once: true, }); if (heartbeatMs !== false) { heartbeatTimer = setInterval(() => { controls.comment("heartbeat"); }, heartbeatMs); } if ( options.maxLifetimeMs !== undefined && options.maxLifetimeMs !== false ) { lifetimeTimer = setTimeout(close, options.maxLifetimeMs); } const registerCleanup = ( resolvedCleanup: ServerSentEventCleanup | undefined, ): void => { try { if ( typeof resolvedCleanup === "function" || (typeof resolvedCleanup === "object" && resolvedCleanup !== null && "close" in resolvedCleanup && typeof resolvedCleanup.close === "function") ) { cleanup = resolvedCleanup; if (closed) void runCleanup(); } else if (resolvedCleanup !== undefined) { reportError( new TypeError( "SSE start must return a cleanup function, a closeable subscription, or nothing.", ), ); close(); } } catch (error) { reportError(error); close(); } }; const handleStartError = (error: unknown): void => { if (!(closed && isAbortError(error))) reportError(error); close(); }; try { const result = options.start(controls); if (isPromiseLike<ServerSentEventCleanup | undefined>(result)) { void Promise.resolve(result).then( registerCleanup, handleStartError, ); } else { registerCleanup(result); } } catch (error) { handleStartError(error); } }, cancel() { closeStream?.(); return runCleanup(); }, }, { highWaterMark: maxBufferedBytes, size: (chunk) => chunk?.byteLength ?? 0, }, ); return new Response(stream, { status: 200, headers, }); }