@beignet/core
Version:
Core framework primitives for Beignet
599 lines • 27.2 kB
JavaScript
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