@beignet/core
Version:
Core framework primitives for Beignet
416 lines (383 loc) • 12.6 kB
text/typescript
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,
});
}