UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

857 lines (838 loc) 28.4 kB
import { createProviderInstrumentation, type ProviderInstrumentationPort, } from "../providers/instrumentation.js"; import { BroadcastValidationError, broadcastLimits, type ChannelDefinition, channelKey, type InferChannelEvent, type InferChannelParams, parseChannelEvent, parseChannelParams, } from "./index.js"; export type BroadcastClientStatus = | "connecting" | "connected" | "reconnecting" | "blocked" | "closed"; /** Why this physical connection was opened, or why it is reconnecting. */ export interface BroadcastConnectionInfo { readonly reason: | "initial" | "planned-renewal" | "subscription-change" | "interruption" | "unknown"; } /** Safe connection/protocol failure; application callback errors are reported separately. */ export class BroadcastClientError extends Error { constructor( message: string, readonly retryable: boolean, readonly status?: number, ) { super(message); this.name = "BroadcastClientError"; } } export interface BroadcastClientSubscription { unsubscribe(): void; getStatus(): BroadcastClientStatus; } export interface BroadcastClient { subscribe<C extends ChannelDefinition>( channel: C, options: { params: InferChannelParams<C>; onEvent: (event: InferChannelEvent<C>) => void | Promise<void>; /** Refetch authoritative state after initial readiness and every recovered connection. */ onSync: (info: BroadcastConnectionInfo) => void | Promise<void>; onError?: (error: unknown) => void; onStatusChange?: ( status: BroadcastClientStatus, info: BroadcastConnectionInfo, ) => void; }, ): BroadcastClientSubscription; /** Merge these optional headers into the app's typed HTTP client to opt into exclusion. */ getRequestHeaders(): Record<string, string>; getStatus(): BroadcastClientStatus; /** Explicit app action after credentials/access change. Renewals never unblock denied subscriptions. */ resume(): void; close(): void; } export interface BroadcastClientOptions { url: string; /** Optional existing instrumentation sink; records causes and counts, never credentials or channel params. */ instrumentation?: ProviderInstrumentationPort; /** Resolved afresh for every request. Never place credentials in the URL. */ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>); credentials?: RequestCredentials; fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; } interface Observer { active: boolean; validation: AbortController; onEvent: (event: { event: string; data: unknown }) => void | Promise<void>; onSync: (info: BroadcastConnectionInfo) => void | Promise<void>; onError?: (error: unknown) => void; onStatusChange?: ( status: BroadcastClientStatus, info: BroadcastConnectionInfo, ) => void; } interface Entry { id: string; key: string; channel: ChannelDefinition; params: Record<string, string>; observers: Set<Observer>; blocked: boolean; ready: boolean; failures: number; nextAttemptAt: number; connectionInfo?: BroadcastConnectionInfo; } function retryDelay(failures: number, minimum = 0): number { const ceiling = Math.min(30_000, 1_000 * 2 ** Math.min(failures, 5)); return Math.max(minimum, Math.round(ceiling * (0.5 + Math.random() * 0.5))); } function responseRetryDelay(response: Response): number { const value = response.headers.get("retry-after"); if (!value) return 0; const seconds = Number(value); const delay = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(value) - Date.now(); return Number.isFinite(delay) ? Math.max(0, delay) : 0; } /** Stop waiting even if an application schema/header callback ignores cancellation. */ async function abortable<T>(work: Promise<T>, signal: AbortSignal): Promise<T> { let cancel = () => {}; try { return await Promise.race([ work, new Promise<never>((_, reject) => { cancel = () => reject( new BroadcastClientError("Broadcast operation interrupted", true), ); if (signal.aborted) cancel(); else signal.addEventListener("abort", cancel, { once: true }); }), ]); } finally { signal.removeEventListener("abort", cancel); } } /** One multiplexed streaming Fetch connection per instance. Create an instance per app auth session. */ export function createBroadcastClient( options: BroadcastClientOptions, ): BroadcastClient { const fetcher = options.fetch ?? globalThis.fetch; const instrumentation = createProviderInstrumentation( options.instrumentation, { providerName: "broadcast-client", watcher: "broadcast", }, ); let connectionInfo: BroadcastConnectionInfo = Object.freeze({ reason: "initial", }); let retryReason: BroadcastConnectionInfo["reason"] = "unknown"; const clientId = crypto.randomUUID(); const entries = new Map<string, Entry>(); const observers = new Set<Observer>(); let nextId = 0; let closed = false; let generation = 0; let interruptConnection: | ((reason: BroadcastConnectionInfo["reason"]) => void) | undefined; let retryTimer: ReturnType<typeof setTimeout> | undefined; let restartQueued = false; let attempted = false; function status(entry?: Entry): BroadcastClientStatus { if (closed) return "closed"; if (entry?.blocked) return "blocked"; if (entry?.ready) return "connected"; if (!entry && [...entries.values()].some((value) => value.ready)) return "connected"; if ( !entry && entries.size > 0 && [...entries.values()].every((value) => value.blocked) ) return "blocked"; return attempted ? "reconnecting" : "connecting"; } function report(observer: Observer, error: unknown) { if (!observer.active) return; try { observer.onError?.(error); } catch { /* Error reporting cannot change transport state. */ } } function invoke(observer: Observer, callback: () => void | Promise<void>) { if (!observer.active) return; try { void Promise.resolve(callback()).catch((error) => report(observer, error), ); } catch (error) { report(observer, error); } } function notify(entry: Entry, info = connectionInfo) { entry.connectionInfo = info; for (const observer of entry.observers) invoke(observer, () => observer.onStatusChange?.(status(entry), info)); } function fail( entry: Entry, error: BroadcastClientError, minimum = 0, info: BroadcastConnectionInfo = Object.freeze({ reason: "interruption" }), ) { retryReason = info.reason; entry.ready = false; entry.blocked = !error.retryable; if (error.retryable) entry.nextAttemptAt = Date.now() + retryDelay(entry.failures++, minimum); notify(entry, info); if (info.reason !== "planned-renewal") for (const observer of entry.observers) report(observer, error); } function clearRetry() { if (retryTimer !== undefined) clearTimeout(retryTimer); retryTimer = undefined; } function scheduleRetry() { clearRetry(); const waiting = [...entries.values()].filter( (entry) => !entry.blocked && !entry.ready && entry.nextAttemptAt > 0, ); if (!waiting.length || closed) return; const delay = Math.max( 0, Math.min(...waiting.map((entry) => entry.nextAttemptAt)) - Date.now(), ); // Long Retry-After values retain their deadline across timer chunks and subscription changes. retryTimer = setTimeout( () => { if ( waiting.some( (entry) => !entry.blocked && entry.nextAttemptAt <= Date.now(), ) ) restart(retryReason); else scheduleRetry(); }, Math.min(delay, 2_147_483_647), ); } function restart(reason: BroadcastConnectionInfo["reason"] = "unknown") { if (closed) return; connectionInfo = Object.freeze({ reason: attempted ? reason : "initial" }); generation++; interruptConnection?.(connectionInfo.reason); interruptConnection = undefined; clearRetry(); for (const entry of entries.values()) { entry.ready = false; notify(entry); } if (restartQueued) return; restartQueued = true; queueMicrotask(() => { restartQueued = false; if (!closed) void connect(generation); }); } async function connect(current: number) { const included = [...entries.values()].filter( (entry) => !entry.blocked && entry.nextAttemptAt <= Date.now(), ); if (!included.length) { scheduleRetry(); return; } for (const entry of included) entry.nextAttemptAt = 0; // Joining channels must not cancel deadlines for subscriptions still waiting to retry. scheduleRetry(); attempted = true; const info = connectionInfo; let endReason: BroadcastConnectionInfo["reason"] = "unknown"; instrumentation.custom({ name: "broadcast.connecting", details: { reason: info.reason, subscriptions: included.length }, }); const abort = new AbortController(); interruptConnection = (reason) => { endReason = reason; abort.abort(); }; const live = () => !closed && current === generation && !abort.signal.aborted; let reader: ReadableStreamDefaultReader<Uint8Array> | undefined; let timer: ReturnType<typeof setTimeout> | undefined; let lifetime: ReturnType<typeof setTimeout> | undefined; let readiness: ReturnType<typeof setTimeout> | undefined; const arm = (ms: number) => { if (timer !== undefined) clearTimeout(timer); timer = setTimeout(() => { endReason = "interruption"; abort.abort(); }, ms); }; let terminal = false; let reading = false; try { arm(broadcastLimits.readyTimeoutMs); const url = new URL( options.url, typeof location === "undefined" ? "http://localhost" : location.href, ); if ( url.username || url.password || !["http:", "https:"].includes(url.protocol) ) throw new BroadcastClientError("Invalid broadcast endpoint URL", false); url.searchParams.set( "subscriptions", JSON.stringify({ version: 1, channels: included.map((entry) => ({ id: entry.id, name: entry.channel.name, params: entry.params, })), }), ); if ( new TextEncoder().encode(url.search).byteLength > broadcastLimits.requestBytes ) throw new BroadcastClientError( "Broadcast subscriptions exceed the request size limit", false, ); const headers = new Headers( typeof options.headers === "function" ? await abortable( Promise.resolve().then(options.headers), abort.signal, ) : options.headers, ); if (!live()) return; headers.set("Accept", "text/event-stream"); headers.set("X-Beignet-Broadcast-Client", clientId); headers.delete("Last-Event-ID"); const fetching = fetcher(url, { method: "GET", headers, credentials: options.credentials ?? "same-origin", cache: "no-store", redirect: "error", signal: abort.signal, }); void fetching.then( (response) => { if (!live()) void response.body?.cancel().catch(() => undefined); }, () => undefined, ); const response = await abortable(fetching, abort.signal); if (!live()) return; if (!response.ok) { endReason = "interruption"; terminal = true; const retryable = response.status === 429 || response.status >= 500; for (const entry of included) fail( entry, new BroadcastClientError( "Broadcast connection was rejected", retryable, response.status, ), responseRetryDelay(response), ); void response.body?.cancel().catch(() => undefined); return; } if ( response.headers.get("content-type")?.split(";", 1)[0]?.trim() !== "text/event-stream" || !response.body ) throw new BroadcastClientError("Invalid broadcast response", false); reader = response.body.getReader(); reading = true; arm(broadcastLimits.heartbeatMs * 2 + 5_000); const connectedAt = performance.now(); let receivedReadiness = false; readiness = setTimeout(() => { endReason = "interruption"; for (const entry of included) if ( !entry.ready && !entry.blocked && entry.nextAttemptAt <= Date.now() ) fail( entry, new BroadcastClientError("Broadcast readiness timed out", true), ); restart("interruption"); }, broadcastLimits.readyTimeoutMs); const byId = new Map(included.map((entry) => [entry.id, entry])); const decoder = new TextDecoder("utf-8", { fatal: true }); let buffer = ""; let data: string[] = []; let eventName = ""; let frameBytes = 0; let renewalAnnounced = false; async function line(value: string) { if (!value) { if (data.length) { renewalAnnounced = false; if (eventName !== "broadcast") throw new BroadcastClientError("Unknown broadcast frame", false); const frame: unknown = JSON.parse(data.join("\n")); if (!frame || typeof frame !== "object") throw new BroadcastClientError("Invalid broadcast frame", false); const message = frame as Record<string, unknown>; if (message.type === "renewal") { if (Object.keys(message).length !== 1) throw new BroadcastClientError( "Invalid broadcast renewal", false, ); renewalAnnounced = true; data = []; eventName = ""; frameBytes = 0; return; } const entry = typeof message.id === "string" ? byId.get(message.id) : undefined; if (!entry || typeof message.type !== "string") throw new BroadcastClientError( "Invalid broadcast subscription frame", false, ); if (entry.blocked || entry.nextAttemptAt > Date.now()) { data = []; eventName = ""; frameBytes = 0; return; } if (message.type === "ready") { if (entry.ready) throw new BroadcastClientError( "Duplicate broadcast readiness", false, ); const advertisedLifetime = message.maxLifetimeMs; if ( typeof advertisedLifetime !== "number" || !Number.isSafeInteger(advertisedLifetime) || advertisedLifetime < 1 || advertisedLifetime > broadcastLimits.maxLifetimeMs ) throw new BroadcastClientError( "Invalid broadcast connection lifetime", false, ); // Only the first readiness can configure this connection's deadline. // Count from response arrival, never from a subscription or heartbeat. if (!receivedReadiness) { lifetime = setTimeout( () => { endReason = "interruption"; abort.abort(); }, Math.max( 0, advertisedLifetime + broadcastLimits.lifetimeGraceMs - (performance.now() - connectedAt), ), ); } receivedReadiness = true; entry.ready = true; entry.failures = 0; entry.nextAttemptAt = 0; notify(entry, info); instrumentation.custom({ name: "broadcast.ready", details: { reason: info.reason }, }); for (const observer of entry.observers) invoke(observer, () => observer.onSync(info)); } else if (message.type === "event") { if (!entry.ready || typeof message.event !== "string") throw new BroadcastClientError( "Event before broadcast readiness", false, ); const event = await abortable( parseChannelEvent(entry.channel, { event: message.event, data: message.data, }), abort.signal, ); if (live()) for (const observer of entry.observers) invoke(observer, () => observer.onEvent(event)); } else if ( message.type === "rejected" || message.type === "unavailable" ) { if ( typeof message.status !== "number" || !Number.isInteger(message.status) || message.status < 400 || message.status > 599 ) throw new BroadcastClientError( "Invalid broadcast failure", false, ); const retryable = message.type === "unavailable"; if ( retryable !== (message.status === 429 || message.status >= 500) ) throw new BroadcastClientError( "Invalid broadcast failure classification", false, ); const retryAfter = message.retryAfterMs; if ( retryAfter !== undefined && (typeof retryAfter !== "number" || !Number.isFinite(retryAfter) || retryAfter < 0) ) throw new BroadcastClientError( "Invalid broadcast retry delay", false, ); endReason = "interruption"; fail( entry, new BroadcastClientError( "Broadcast subscription is unavailable", retryable, message.status, ), typeof retryAfter === "number" ? retryAfter : 0, ); scheduleRetry(); } else throw new BroadcastClientError( "Unknown broadcast control frame", false, ); if ( included.every( (entry) => entry.ready || entry.blocked || entry.nextAttemptAt > Date.now(), ) ) { if (readiness !== undefined) clearTimeout(readiness); readiness = undefined; } if ( included.every( (entry) => entry.blocked || entry.nextAttemptAt > Date.now(), ) ) { terminal = true; abort.abort(); } } data = []; eventName = ""; frameBytes = 0; return; } // Renewal is terminal: subsequent traffic makes the eventual EOF unexplained. renewalAnnounced = false; frameBytes += new TextEncoder().encode(value).byteLength; if ( frameBytes > broadcastLimits.payloadBytes + broadcastLimits.requestBytes ) throw new BroadcastClientError( "Broadcast frame exceeds the size limit", false, ); if (value.startsWith(":")) return; const colon = value.indexOf(":"); const field = colon < 0 ? value : value.slice(0, colon); const content = colon < 0 ? "" : value.slice(colon + 1).replace(/^ /, ""); if (field === "data") data.push(content); else if (field === "event") eventName = content; else if (field === "id" || field === "retry") throw new BroadcastClientError( "Broadcast replay fields are not supported", false, ); } while (live()) { const chunk = await abortable(reader.read(), abort.signal); if (!live()) break; if (chunk.done) { endReason = renewalAnnounced && !buffer && frameBytes === 0 ? "planned-renewal" : "unknown"; break; } arm(broadcastLimits.heartbeatMs * 2 + 5_000); if (chunk.value.byteLength > broadcastLimits.bufferedBytes) throw new BroadcastClientError( "Broadcast stream exceeds the buffer limit", false, ); try { buffer += decoder.decode(chunk.value, { stream: true }); } catch { throw new BroadcastClientError( "Invalid broadcast text encoding", false, ); } if ( new TextEncoder().encode(buffer).byteLength > broadcastLimits.bufferedBytes ) throw new BroadcastClientError( "Broadcast stream exceeds the buffer limit", false, ); for (;;) { const match = /\r\n|\r|\n/.exec(buffer); if (!match) break; if (match[0] === "\r" && match.index === buffer.length - 1) break; const value = buffer.slice(0, match.index); buffer = buffer.slice(match.index + match[0].length); await line(value); if (!live()) break; } } } catch (error) { if (current === generation && !closed) endReason = "interruption"; if ( current === generation && !closed && error instanceof BroadcastClientError && !error.retryable ) { terminal = true; for (const entry of included) fail(entry, error); } else if ( current === generation && !closed && (error instanceof BroadcastValidationError || (reading && !abort.signal.aborted && error instanceof SyntaxError)) ) { terminal = true; for (const entry of included) fail( entry, new BroadcastClientError("Invalid broadcast payload", false), ); } } finally { if (timer !== undefined) clearTimeout(timer); if (lifetime !== undefined) clearTimeout(lifetime); if (readiness !== undefined) clearTimeout(readiness); abort.abort(); void reader?.cancel().catch(() => undefined); const ended = Object.freeze({ reason: endReason }); instrumentation.custom({ name: "broadcast.closed", details: { reason: ended.reason }, }); if (current === generation && !closed) { retryReason = endReason; interruptConnection = undefined; if (!terminal) for (const entry of included) { if (!entry.blocked && entry.nextAttemptAt <= Date.now()) fail( entry, new BroadcastClientError( endReason === "planned-renewal" ? "Broadcast connection renewed" : "Broadcast connection interrupted", true, ), 0, ended, ); } scheduleRetry(); } } } function wake() { if ( typeof document !== "undefined" && document.visibilityState === "hidden" ) return; restart(); } if (typeof window !== "undefined") window.addEventListener("online", wake); if (typeof document !== "undefined") document.addEventListener("visibilitychange", wake); return { subscribe(channel, subscriptionOptions) { if (closed) throw new BroadcastClientError("Broadcast client is closed", false); let entry: Entry | undefined; let invalid = false; const observer: Observer = { ...subscriptionOptions, active: true, validation: new AbortController(), onEvent: (event) => subscriptionOptions.onEvent( event as InferChannelEvent<typeof channel>, ), }; observers.add(observer); const validationTimeout = setTimeout( () => observer.validation.abort(), broadcastLimits.readyTimeoutMs, ); void abortable( parseChannelParams(channel, subscriptionOptions.params), observer.validation.signal, ) .then((params) => { if (!observer.active || closed) return; const key = channelKey(channel.name, params); entry = entries.get(key); if (!entry) { if (entries.size >= broadcastLimits.subscriptions) throw new BroadcastClientError( "Broadcast client supports at most 20 distinct subscriptions", false, ); entry = { id: String(++nextId), key, channel, params, observers: new Set(), blocked: false, ready: false, failures: 0, nextAttemptAt: 0, }; entries.set(key, entry); entry.observers.add(observer); restart("subscription-change"); } else { if (entry.channel !== channel) throw new BroadcastClientError( `Conflicting definitions for channel ${channel.name}`, false, ); entry.observers.add(observer); invoke(observer, () => observer.onStatusChange?.( status(entry), entry?.connectionInfo ?? connectionInfo, ), ); if (entry.ready) invoke(observer, () => observer.onSync(entry?.connectionInfo ?? connectionInfo), ); } }) .catch((error) => { invalid = true; report(observer, error); invoke(observer, () => observer.onStatusChange?.("blocked", connectionInfo), ); }) .finally(() => clearTimeout(validationTimeout)); return { getStatus: () => !observer.active || closed ? "closed" : invalid ? "blocked" : status(entry), unsubscribe() { if (!observer.active) return; observer.active = false; observer.validation.abort(); observers.delete(observer); entry?.observers.delete(observer); if ( entry && !entry.observers.size && entries.get(entry.key) === entry ) { entries.delete(entry.key); restart("subscription-change"); } }, }; }, getRequestHeaders: () => ({ "X-Beignet-Broadcast-Client": clientId }), getStatus: () => status(), resume() { for (const entry of entries.values()) { entry.blocked = false; entry.nextAttemptAt = 0; entry.failures = 0; } restart(); }, close() { if (closed) return; closed = true; generation++; interruptConnection?.("unknown"); clearRetry(); if (typeof window !== "undefined") window.removeEventListener("online", wake); if (typeof document !== "undefined") document.removeEventListener("visibilitychange", wake); for (const observer of observers) { invoke(observer, () => observer.onStatusChange?.( "closed", Object.freeze({ reason: "unknown" }), ), ); observer.active = false; observer.validation.abort(); } observers.clear(); entries.clear(); }, }; }