UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

599 lines 27.2 kB
import { createProviderInstrumentation, } from "../providers/instrumentation.js"; import { BroadcastValidationError, broadcastLimits, channelKey, parseChannelEvent, parseChannelParams, } from "./index.js"; /** Safe connection/protocol failure; application callback errors are reported separately. */ export class BroadcastClientError extends Error { retryable; status; constructor(message, retryable, status) { super(message); this.retryable = retryable; this.status = status; this.name = "BroadcastClientError"; } } function retryDelay(failures, minimum = 0) { 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) { 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(work, signal) { let cancel = () => { }; try { return await Promise.race([ work, new Promise((_, 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) { const fetcher = options.fetch ?? globalThis.fetch; const instrumentation = createProviderInstrumentation(options.instrumentation, { providerName: "broadcast-client", watcher: "broadcast", }); let connectionInfo = Object.freeze({ reason: "initial", }); let retryReason = "unknown"; const clientId = crypto.randomUUID(); const entries = new Map(); const observers = new Set(); let nextId = 0; let closed = false; let generation = 0; let interruptConnection; let retryTimer; let restartQueued = false; let attempted = false; function status(entry) { 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, error) { if (!observer.active) return; try { observer.onError?.(error); } catch { /* Error reporting cannot change transport state. */ } } function invoke(observer, callback) { if (!observer.active) return; try { void Promise.resolve(callback()).catch((error) => report(observer, error)); } catch (error) { report(observer, error); } } function notify(entry, info = connectionInfo) { entry.connectionInfo = info; for (const observer of entry.observers) invoke(observer, () => observer.onStatusChange?.(status(entry), info)); } function fail(entry, error, minimum = 0, info = 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 = "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) { 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 = "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; let timer; let lifetime; let readiness; const arm = (ms) => { 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 = []; let eventName = ""; let frameBytes = 0; let renewalAnnounced = false; async function line(value) { if (!value) { if (data.length) { renewalAnnounced = false; if (eventName !== "broadcast") throw new BroadcastClientError("Unknown broadcast frame", false); const frame = JSON.parse(data.join("\n")); if (!frame || typeof frame !== "object") throw new BroadcastClientError("Invalid broadcast frame", false); const message = frame; 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; let invalid = false; const observer = { ...subscriptionOptions, active: true, validation: new AbortController(), onEvent: (event) => subscriptionOptions.onEvent(event), }; 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(); }, }; } //# sourceMappingURL=client.js.map