@beignet/core
Version:
Core framework primitives for Beignet
372 lines • 14.3 kB
JavaScript
import { runWithResolvedTracingContext } from "../tracing/execution.js";
import { parseTraceCarrier, } from "../tracing/index.js";
import { isEventPayloadParsed, isEventPayloadTransportStable, markEventPayloadTransportStable, } from "./payload-state.js";
import { EventTransportError, eventTransportValuesEqual, toEventTransportValue, } from "./transport.js";
export { EventTransportError, } from "./transport.js";
const DEFAULT_LISTENER_READY_TIMEOUT_MS = 10_000;
const MAX_TIMER_MS = 2_147_483_647;
/** Error used when a subscription closes before initial readiness. */
export class EventSubscriptionClosedError extends Error {
constructor(message = "Event subscription closed before it became ready.") {
super(message);
this.name = "EventSubscriptionClosedError";
}
}
/** Error thrown when a listener registry misses its readiness deadline. */
export class ListenerRegistrationTimeoutError extends Error {
/** Configured readiness deadline in milliseconds. */
timeoutMs;
/** Listener names that were part of the registration. */
listenerNames;
constructor(args) {
const suffix = args.listenerNames.length
? `: ${args.listenerNames.join(", ")}`
: "";
super(`Listeners did not become ready within ${args.timeoutMs}ms${suffix}.`);
this.name = "ListenerRegistrationTimeoutError";
this.timeoutMs = args.timeoutMs;
this.listenerNames = [...args.listenerNames];
}
}
/** Error thrown when listener rollback misses the registration deadline. */
export class ListenerRegistrationCleanupTimeoutError extends Error {
/** Configured registration deadline in milliseconds. */
timeoutMs;
/** Listener names that were part of the registration. */
listenerNames;
constructor(args) {
const suffix = args.listenerNames.length
? `: ${args.listenerNames.join(", ")}`
: "";
super(`Listener cleanup did not finish before the ${args.timeoutMs}ms registration deadline${suffix}.`);
this.name = "ListenerRegistrationCleanupTimeoutError";
this.timeoutMs = args.timeoutMs;
this.listenerNames = [...args.listenerNames];
}
}
/**
* Error thrown when event payload validation fails.
*/
export class EventValidationError extends Error {
/**
* Raw Standard Schema validation issues.
*/
issues;
constructor(args) {
super(`Event "${args.name}" payload validation failed: ${formatIssues(args.issues)}`);
this.name = "EventValidationError";
this.issues = args.issues;
}
}
function formatPath(path) {
if (!path?.length)
return "";
return path
.map((segment) => typeof segment === "object" && segment !== null && "key" in segment
? String(segment.key)
: String(segment))
.join(".");
}
function formatIssues(issues) {
return issues
.map((issue) => {
const path = formatPath(issue.path);
return path ? `${path}: ${issue.message}` : issue.message;
})
.join("; ");
}
async function parsePayload(schema, input, args) {
const result = await schema["~standard"].validate(input);
if (result.issues?.length) {
throw new EventValidationError({
name: args.name,
issues: result.issues,
});
}
if ("value" in result) {
return result.value;
}
throw new Error("Invalid Standard Schema result: missing value");
}
/**
* Define a typed event.
*
* Event payloads are validated before publishing through `publishEvent(...)`
* and before registered listeners run. Producer helpers also require parsed
* output to be plain JSON that remains unchanged when validated again after a
* transport round trip.
*/
export function defineEvent(name, options) {
return {
kind: "event",
name,
payload: options.payload,
description: options.description,
};
}
function defineListenerImpl(name, options) {
return {
kind: "listener",
name,
event: options.event,
handle: options.handle,
};
}
/**
* Validate and parse an event payload with the event's Standard Schema.
*/
export async function parseEventPayload(event, payload) {
return (await parsePayload(event.payload, payload, {
name: event.name,
}));
}
/**
* Parse an event payload and prove that its canonical JSON survives transport.
*
* Custom event-bus providers must call this before publishing. The returned
* runtime payload is suitable for in-process listeners; `transportValue` is
* the exact JSON-safe value to encode for a serialized transport.
*/
export async function prepareEventPayloadForTransport(event, payload, options) {
const parsed = isEventPayloadParsed(event, payload, options)
? payload
: await parseEventPayload(event, payload);
const transportValue = toEventTransportValue(event.name, parsed);
let canonicalPayload = parsed;
let transportStateIsCurrent = false;
try {
transportStateIsCurrent = isEventPayloadTransportStable(event, parsed, transportValue, options);
}
catch {
// Revalidate if a mutated or proxied payload can no longer be compared.
}
if (!transportStateIsCurrent) {
let reparsed;
try {
reparsed = await parseEventPayload(event, transportValue);
}
catch (error) {
throw new EventTransportError({
eventName: event.name,
reason: "not-stable",
message: "is not transport-stable: its canonical JSON failed validation when decoded again. Event schemas must accept their own canonical JSON output.",
cause: error,
});
}
let reparsedTransportValue;
try {
reparsedTransportValue = toEventTransportValue(event.name, reparsed);
}
catch (error) {
throw new EventTransportError({
eventName: event.name,
reason: "not-stable",
path: error instanceof EventTransportError ? error.path : undefined,
message: "is not transport-stable: validating its canonical JSON produced a value that is not JSON-safe.",
cause: error,
});
}
if (!eventTransportValuesEqual(transportValue, reparsedTransportValue)) {
throw new EventTransportError({
eventName: event.name,
reason: "not-stable",
message: "is not transport-stable: its canonical JSON changed when validated again. Event schema transforms must be idempotent.",
});
}
canonicalPayload = reparsed;
}
try {
return {
payload: canonicalPayload,
transportValue,
publishOptions: markEventPayloadTransportStable(event, canonicalPayload, transportValue, options),
};
}
catch (error) {
throw new EventTransportError({
eventName: event.name,
reason: "not-stable",
message: "is not transport-stable: its canonical output could not be inspected consistently.",
cause: error,
});
}
}
/**
* Validate an event payload, prove transport stability, and publish it through
* an event bus.
*/
export async function publishEvent(eventBus, event, payload, options) {
const prepared = await prepareEventPayloadForTransport(event, payload, options);
await eventBus.publish(event, prepared.payload, prepared.publishOptions);
}
function assertReadyTimeoutMs(value) {
if (Number.isInteger(value) && value >= 1 && value <= MAX_TIMER_MS)
return;
throw new RangeError(`readyTimeoutMs must be an integer between 1 and ${MAX_TIMER_MS} milliseconds.`);
}
function errorsFromCleanup(error) {
return error instanceof AggregateError ? [...error.errors] : [error];
}
function unsubscribeAllSettled(subscriptions) {
const attempts = [];
for (const subscription of [...subscriptions].reverse()) {
try {
attempts.push(Promise.resolve(subscription.unsubscribe()));
}
catch (error) {
attempts.push(Promise.reject(error));
}
}
return Promise.allSettled(attempts);
}
function throwSubscriptionCleanupErrors(results) {
const errors = results.flatMap((result) => result.status === "rejected" ? errorsFromCleanup(result.reason) : []);
if (errors.length > 0) {
throw new AggregateError(errors, "Event subscription cleanup failed");
}
}
function withListenerDeadline(operation, args) {
let timeout;
const timedOut = new Promise((_, reject) => {
timeout = setTimeout(() => {
reject(args.timeoutError);
}, Math.max(0, args.deadlineAt - performance.now()));
});
return Promise.race([operation, timedOut]).finally(() => {
if (timeout !== undefined)
clearTimeout(timeout);
});
}
/**
* Register listeners against an event bus and return a composite lifecycle
* handle.
*
* Payloads are validated before listener handlers run. Listener context is
* resolved per delivery when `options.ctx` is a factory. Initial registration
* starts every child cleanup after a synchronous subscribe failure, rejected
* readiness promise, or readiness timeout. Cleanup that cannot finish inside
* the registration deadline is reported without extending startup forever.
*/
export function registerListeners(eventBus, listeners, options = {}) {
const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_LISTENER_READY_TIMEOUT_MS;
assertReadyTimeoutMs(readyTimeoutMs);
const registrationDeadlineAt = performance.now() + readyTimeoutMs;
const listenerNames = listeners.map(({ name }) => name);
const subscriptions = [];
let registrationError;
for (const listener of listeners) {
try {
subscriptions.push(eventBus.subscribe(listener.event, async (rawPayload, publishOptions) => {
try {
const prepared = await prepareEventPayloadForTransport(listener.event, rawPayload, publishOptions);
const traceAttributes = {
"beignet.listener.name": listener.name,
"beignet.event.name": listener.event.name,
};
await runWithResolvedTracingContext({
tracing: options.tracing,
ctx: options.ctx,
operation: {
name: `beignet.listener ${listener.name}`,
type: "listener",
kind: "consumer",
parent: parseTraceCarrier(prepared.publishOptions.trace),
attributes: traceAttributes,
metricAttributes: traceAttributes,
},
run: (ctx) => listener.handle({
event: listener.event,
payload: prepared.payload,
ctx,
}),
});
}
catch (error) {
options.onError?.(error, listener);
if (!options.onError)
throw error;
}
}));
}
catch (error) {
registrationError = error;
break;
}
}
let rejectClosed;
const closedBeforeReady = new Promise((_, reject) => {
rejectClosed = reject;
});
// A consumer may only await cleanup. Keep an explicit observer on the
// cancellation signal so that use does not create an unhandled rejection.
void closedBeforeReady.catch(() => undefined);
let cleanupPromise;
const cleanup = (deadlineAt) => {
if (cleanupPromise)
return cleanupPromise;
const operation = unsubscribeAllSettled(subscriptions);
cleanupPromise =
deadlineAt === undefined
? operation.then(throwSubscriptionCleanupErrors)
: withListenerDeadline(operation, {
deadlineAt,
timeoutError: new ListenerRegistrationCleanupTimeoutError({
timeoutMs: readyTimeoutMs,
listenerNames,
}),
}).then(throwSubscriptionCleanupErrors);
return cleanupPromise;
};
const initialReadiness = registrationError
? Promise.reject(registrationError)
: Promise.all(subscriptions.map(({ ready }) => ready)).then(() => undefined);
let readySettled = false;
const ready = withListenerDeadline(Promise.race([initialReadiness, closedBeforeReady]), {
deadlineAt: registrationDeadlineAt,
timeoutError: new ListenerRegistrationTimeoutError({
timeoutMs: readyTimeoutMs,
listenerNames,
}),
})
.catch(async (primaryError) => {
try {
await cleanup(registrationDeadlineAt);
}
catch (cleanupError) {
throw new AggregateError([primaryError, ...errorsFromCleanup(cleanupError)], "Listener registration failed and cleanup failed");
}
throw primaryError;
})
.finally(() => {
readySettled = true;
});
// EventSubscription allows callers that only own cleanup. Preserve the
// rejection for awaiters while preventing process-level unhandled noise.
void ready.catch(() => undefined);
return {
ready,
unsubscribe() {
if (!readySettled) {
rejectClosed(new EventSubscriptionClosedError());
}
return cleanup(readySettled ? undefined : registrationDeadlineAt);
},
};
}
/**
* Create listener helper methods bound to an application context type.
*
* Call it once in `lib/listeners.ts`:
*
* ```ts
* export const { defineListener } = createListeners<AppContext>();
* ```
*/
export function createListeners() {
return {
defineListener(name, options) {
return defineListenerImpl(name, options);
},
};
}
//# sourceMappingURL=index.js.map