@beignet/core
Version:
Core framework primitives for Beignet
392 lines (361 loc) • 9.67 kB
text/typescript
import type { StandardSchemaV1 } from "@standard-schema/spec";
import { runWithResolvedTracingContext } from "../tracing/execution.js";
import {
parseTraceCarrier,
type TraceCarrier,
type TracingPort,
} from "../tracing/index.js";
/**
* Any Standard Schema compatible validator.
*/
export type StandardSchema = StandardSchemaV1<unknown, unknown>;
/**
* Value or promise of that value.
*/
export type MaybePromise<T> = T | Promise<T>;
/**
* Infer the parsed output type from a Standard Schema.
*/
export type InferSchemaOutput<T extends StandardSchemaV1> =
StandardSchemaV1.InferOutput<T>;
/**
* Minimal event definition shape accepted by event bus helpers.
*/
export interface EventPayloadDef<
Name extends string = string,
Payload extends StandardSchema = StandardSchema,
> {
/**
* Stable event name.
*/
readonly name: Name;
/**
* Standard Schema payload validator.
*/
readonly payload: Payload;
/**
* Optional human-readable description for docs and tooling.
*/
readonly description?: string;
}
/**
* Event definition created by `defineEvent(...)`.
*/
export interface EventDef<
Name extends string = string,
Payload extends StandardSchema = StandardSchema,
> extends EventPayloadDef<Name, Payload> {
/**
* Discriminator for event definitions.
*/
readonly kind: "event";
}
/**
* Infer the parsed payload type for an event definition.
*/
export type InferEventPayload<E extends EventPayloadDef> =
E["payload"] extends StandardSchemaV1<unknown, infer Output> ? Output : never;
/** Metadata propagated with an event delivery. */
export interface EventPublishOptions {
/** Versioned trace context captured by the event producer. */
trace?: TraceCarrier;
}
/**
* Options for `defineEvent(...)`.
*/
export interface DefineEventOptions<Payload extends StandardSchema> {
/**
* Standard Schema payload validator.
*/
payload: Payload;
/**
* Optional human-readable description for docs and tooling.
*/
description?: string;
}
/**
* Arguments passed to a listener handler.
*/
export interface ListenerHandleArgs<E extends EventDef, Ctx> {
/**
* Event definition being handled.
*/
event: E;
/**
* Parsed event payload.
*/
payload: InferEventPayload<E>;
/**
* Listener context.
*/
ctx: Ctx;
}
/**
* Listener definition created by `defineListener(...)`.
*/
export interface ListenerDef<
E extends EventDef = EventDef,
Ctx = unknown,
Name extends string = string,
> {
/**
* Discriminator for listener definitions.
*/
readonly kind: "listener";
/**
* Stable listener name.
*/
readonly name: Name;
/**
* Event this listener handles.
*/
readonly event: E;
/**
* Handle a parsed event payload.
*/
handle(args: ListenerHandleArgs<E, Ctx>): MaybePromise<void>;
}
/**
* Options for `defineListener(...)`.
*/
export interface DefineListenerOptions<E extends EventDef, Ctx> {
/**
* Event this listener handles.
*/
event: E;
/**
* Handle a parsed event payload.
*/
handle(args: ListenerHandleArgs<E, Ctx>): MaybePromise<void>;
}
/**
* Event bus shape required by Beignet listener registration helpers.
*/
export interface EventBusLike {
/**
* Publish an event payload.
*/
publish<E extends EventPayloadDef>(
event: E,
payload: InferEventPayload<E>,
options?: EventPublishOptions,
): MaybePromise<void>;
/**
* Subscribe to an event and return an unsubscribe function.
*/
subscribe<E extends EventPayloadDef>(
event: E,
handler: (
payload: InferEventPayload<E>,
options?: EventPublishOptions,
) => MaybePromise<void>,
): () => void;
}
/**
* Options for `registerListeners(...)`.
*/
export interface RegisterListenersOptions<Ctx> {
/**
* Static listener context or factory evaluated for each delivered event.
*/
ctx?: Ctx | (() => MaybePromise<Ctx>);
/**
* Runtime tracing port used to start the listener span before a lazy context
* factory runs.
*/
tracing?: TracingPort;
/**
* Called when a listener fails. When omitted, listener errors are rethrown to
* the event bus subscription callback.
*/
onError?: (error: unknown, listener: ListenerDef<EventDef, Ctx>) => void;
}
/**
* Context-bound listener helper factory.
*/
export interface Listeners<Ctx> {
/**
* Define a listener with the bound context type.
*/
defineListener<Name extends string, E extends EventDef>(
name: Name,
options: DefineListenerOptions<E, Ctx>,
): ListenerDef<E, Ctx, Name>;
}
/**
* Error thrown when event payload validation fails.
*/
export class EventValidationError extends Error {
/**
* Raw Standard Schema validation issues.
*/
readonly issues: readonly StandardSchemaV1.Issue[];
constructor(args: {
name: string;
issues: readonly StandardSchemaV1.Issue[];
}) {
super(
`Event "${args.name}" payload validation failed: ${formatIssues(args.issues)}`,
);
this.name = "EventValidationError";
this.issues = args.issues;
}
}
function formatPath(path: StandardSchemaV1.Issue["path"]): string {
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: readonly StandardSchemaV1.Issue[]): string {
return issues
.map((issue) => {
const path = formatPath(issue.path);
return path ? `${path}: ${issue.message}` : issue.message;
})
.join("; ");
}
async function parsePayload<Schema extends StandardSchemaV1>(
schema: Schema,
input: unknown,
args: { name: string },
): Promise<InferSchemaOutput<Schema>> {
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 as InferSchemaOutput<Schema>;
}
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.
*/
export function defineEvent<
Name extends string,
Payload extends StandardSchema,
>(name: Name, options: DefineEventOptions<Payload>): EventDef<Name, Payload> {
return {
kind: "event",
name,
payload: options.payload,
description: options.description,
};
}
function defineListenerImpl<
Name extends string = string,
E extends EventDef = EventDef,
Ctx = unknown,
>(
name: Name,
options: DefineListenerOptions<E, Ctx>,
): ListenerDef<E, Ctx, Name> {
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<E extends EventPayloadDef>(
event: E,
payload: unknown,
): Promise<InferEventPayload<E>> {
return (await parsePayload(event.payload, payload, {
name: event.name,
})) as InferEventPayload<E>;
}
/**
* Validate an event payload and publish it through an event bus.
*/
export async function publishEvent<E extends EventPayloadDef>(
eventBus: EventBusLike,
event: E,
payload: InferEventPayload<E>,
options?: EventPublishOptions,
): Promise<void> {
await parseEventPayload(event, payload);
await eventBus.publish(event, payload, options);
}
/**
* Register listeners against an event bus and return an unsubscribe function.
*
* Payloads are validated before listener handlers run. Listener context is
* resolved per delivery when `options.ctx` is a factory.
*/
export function registerListeners<Ctx>(
eventBus: EventBusLike,
listeners: readonly ListenerDef<EventDef, Ctx>[],
options: RegisterListenersOptions<Ctx> = {},
): () => void {
const unsubscribes = listeners.map((listener) =>
eventBus.subscribe(listener.event, async (rawPayload, publishOptions) => {
try {
const payload = await parseEventPayload(listener.event, rawPayload);
const traceAttributes = {
"beignet.listener.name": listener.name,
"beignet.event.name": listener.event.name,
} as const;
await runWithResolvedTracingContext({
tracing: options.tracing,
ctx: options.ctx as Ctx | (() => MaybePromise<Ctx>),
operation: {
name: `beignet.listener ${listener.name}`,
type: "listener",
kind: "consumer",
parent: parseTraceCarrier(publishOptions?.trace),
attributes: traceAttributes,
metricAttributes: traceAttributes,
},
run: (ctx) =>
listener.handle({
event: listener.event,
payload,
ctx,
}),
});
} catch (error) {
options.onError?.(error, listener);
if (!options.onError) throw error;
}
}),
);
return () => {
for (const unsubscribe of [...unsubscribes].reverse()) {
unsubscribe();
}
};
}
/**
* 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<Ctx>(): Listeners<Ctx> {
return {
defineListener<Name extends string, E extends EventDef>(
name: Name,
options: DefineListenerOptions<E, Ctx>,
): ListenerDef<E, Ctx, Name> {
return defineListenerImpl(name, options);
},
};
}