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