@beignet/core
Version:
Core framework primitives for Beignet
1,430 lines (1,324 loc) • 39.1 kB
text/typescript
import type { StandardSchemaV1 } from "@standard-schema/spec";
import {
createJobs,
type JobDef,
type JobDispatcher,
type JobHook,
type JobRetryOptions,
type JobTimeoutDuration,
retry,
} from "../jobs/index.js";
import type { SendMailOptions } from "../mail/index.js";
import type { ProviderInstrumentationTarget } from "../providers/index.js";
import {
createProvider,
createProviderInstrumentation,
} from "../providers/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 notification definition shape accepted by notification ports.
*/
export interface NotificationPayloadDef<
Name extends string = string,
Payload extends StandardSchema = StandardSchema,
> {
/**
* Stable notification name used by dispatchers, tests, and tooling.
*/
readonly name: Name;
/**
* Standard Schema payload validator.
*/
readonly payload: Payload;
/**
* Optional human-readable description for docs and tooling.
*/
readonly description?: string;
}
/**
* Infer the parsed payload type for a notification definition.
*/
export type InferNotificationPayload<N extends NotificationPayloadDef> =
N["payload"] extends StandardSchemaV1<unknown, infer Output> ? Output : never;
/**
* Result for one notification channel.
*/
export interface NotificationChannelResult {
/**
* Channel name, such as `email`, `sms`, `push`, or `inApp`.
*/
channel: string;
/**
* Delivery outcome for this channel.
*/
status: "queued" | "sent" | "skipped" | "failed";
/**
* Provider delivery ID when available.
*/
id?: string;
/**
* Provider name when available.
*/
provider?: string;
/**
* Human-readable skip or failure reason.
*/
reason?: string;
/**
* Channel-specific metadata. Dispatchers should keep this safe to log.
*/
details?: Record<string, unknown>;
}
/**
* Original error captured for one failed notification channel.
*/
export interface NotificationChannelError {
channel: string;
error: unknown;
}
/**
* Arguments passed to a notification channel handler.
*/
export interface NotificationChannelHandleArgs<
Payload extends StandardSchema,
Ctx,
> {
/**
* Notification definition being delivered.
*/
notification: NotificationDef<string, Payload, Ctx>;
/**
* Parsed notification payload.
*/
payload: InferSchemaOutput<Payload>;
/**
* Handler context.
*/
ctx: Ctx;
/**
* Channel name being delivered.
*/
channel: string;
}
/**
* Handler for one notification channel.
*/
export type NotificationChannelHandler<Payload extends StandardSchema, Ctx> = (
args: NotificationChannelHandleArgs<Payload, Ctx>,
) => MaybePromise<NotificationChannelResult | undefined>;
/**
* Notification channel handlers keyed by channel name.
*/
export type NotificationChannels<Payload extends StandardSchema, Ctx> = Record<
string,
NotificationChannelHandler<Payload, Ctx>
>;
/**
* Arguments passed to an app-owned notification preference evaluator.
*/
export interface NotificationPreferenceArgs<
Payload extends StandardSchema = StandardSchema,
Ctx = unknown,
> extends NotificationChannelHandleArgs<Payload, Ctx> {
/**
* Optional metadata supplied by the notification sender.
*/
metadata?: Record<string, unknown>;
}
/**
* App-owned decision for one notification channel.
*/
export interface NotificationPreferenceDecision {
/**
* Whether this channel should deliver.
*/
deliver: boolean;
/**
* Optional reason recorded when delivery is skipped.
*/
reason?: string;
}
/**
* Optional app-facing port for notification channel preferences and opt-outs.
*/
export interface NotificationPreferencesPort<Ctx = unknown> {
/**
* Evaluate the current preference immediately before channel delivery.
*/
evaluate(
args: NotificationPreferenceArgs<StandardSchema, Ctx>,
): MaybePromise<NotificationPreferenceDecision>;
}
/**
* Notification definition created by `defineNotification(...)`.
*/
export interface NotificationDef<
Name extends string = string,
Payload extends StandardSchema = StandardSchema,
Ctx = unknown,
> extends NotificationPayloadDef<Name, Payload> {
/**
* Discriminator for notification definitions.
*/
readonly kind: "notification";
/**
* Channel handlers that deliver the notification.
*/
readonly channels: NotificationChannels<Payload, Ctx>;
}
/**
* Options for declaring a typed notification.
*/
export interface DefineNotificationOptions<
Payload extends StandardSchema,
Ctx,
> {
/**
* Standard Schema payload validator.
*/
payload: Payload;
/**
* Optional human-readable description for docs and tooling.
*/
description?: string;
/**
* Channel handlers that deliver the notification.
*/
channels: NotificationChannels<Payload, Ctx>;
}
/**
* Options passed when sending a notification.
*/
export interface SendNotificationOptions {
/**
* Subset of channels to deliver. Defaults to all channels on the definition.
*/
channels?: readonly string[];
/**
* Optional app metadata attached to memory deliveries and instrumentation.
*/
metadata?: Record<string, unknown>;
/**
* Request correlation ID for instrumentation.
*/
requestId?: string;
/**
* Trace identifier for instrumentation.
*/
traceId?: string;
/**
* Span identifier for instrumentation.
*/
spanId?: string;
/**
* Parent span identifier for instrumentation.
*/
parentSpanId?: string;
/**
* W3C traceparent header value for instrumentation.
*/
traceparent?: string;
}
/**
* Result returned after a notification send attempt.
*/
export interface SendNotificationResult {
/**
* Notification name.
*/
notificationName: string;
/**
* Parsed notification payload.
*/
payload: unknown;
/**
* Channels selected for delivery.
*/
channels: readonly string[];
/**
* Per-channel delivery results.
*/
results: readonly NotificationChannelResult[];
}
/**
* App-facing notification port.
*/
export interface NotificationPort {
/**
* Send a typed notification.
*/
send<N extends NotificationDef>(
notification: N,
payload: InferNotificationPayload<N>,
options?: SendNotificationOptions,
): Promise<SendNotificationResult>;
}
/**
* Options for the inline notification dispatcher.
*/
export interface InlineNotificationDispatcherOptions<Ctx> {
/**
* Static notification context or factory evaluated for each send.
*/
ctx?: Ctx | (() => MaybePromise<Ctx>);
/**
* Called when a channel handler or preference check fails. A returned result
* replaces the default failed result. Observer failures are ignored so the
* remaining channels still run.
*/
onError?: (
error: unknown,
args: NotificationChannelHandleArgs<StandardSchema, Ctx>,
) => MaybePromise<NotificationChannelResult | undefined>;
/**
* How completed channel failures are surfaced. Defaults to `"report"`.
* `"throw"` still runs every selected channel before rejecting.
*/
failureMode?: "report" | "throw";
/**
* Optional app-owned notification preference evaluator.
*/
preferences?: NotificationPreferencesPort<Ctx>;
/**
* Optional devtools/provider instrumentation target.
*/
instrumentation?: ProviderInstrumentationTarget;
}
/**
* Delivery captured by the memory notification port.
*/
export interface MemoryNotificationDelivery {
/**
* Generated delivery ID.
*/
id: string;
/**
* Notification name.
*/
notificationName: string;
/**
* Parsed payload that would have been sent.
*/
payload: unknown;
/**
* Selected channels.
*/
channels: readonly string[];
/**
* Optional app metadata supplied by the caller.
*/
metadata?: Record<string, unknown>;
/**
* Timestamp assigned by the memory port.
*/
sentAt: Date;
}
/**
* In-memory notification port for tests and local examples.
*/
export interface MemoryNotificationPort extends NotificationPort {
/**
* Captured notification sends.
*/
readonly deliveries: readonly MemoryNotificationDelivery[];
/**
* Clear captured notification sends.
*/
clear(): void;
}
/**
* Options for `createMemoryNotificationPort(...)`.
*/
export interface CreateMemoryNotificationPortOptions {
/**
* Clock used for captured deliveries.
*/
now?: () => Date;
/**
* ID factory used for captured deliveries.
*/
id?: () => string;
/**
* Observer called after a delivery is captured.
*/
onSend?: (delivery: MemoryNotificationDelivery) => MaybePromise<void>;
}
/**
* Context shape required by `defineMailNotificationChannel(...)`.
*/
export interface MailNotificationContext {
ports: {
mailer: {
send(message: SendMailOptions): MaybePromise<{
id?: string;
provider?: string;
}>;
};
};
}
/**
* Render a mail message for one notification payload.
*/
export type MailNotificationRenderer<
Payload extends StandardSchema,
Ctx extends MailNotificationContext,
> = (
args: NotificationChannelHandleArgs<Payload, Ctx>,
) => MaybePromise<SendMailOptions | undefined>;
/**
* Context-bound notification helper factory.
*/
export interface Notifications<Ctx> {
/**
* Define a notification with the bound context type.
*/
defineNotification<Name extends string, Payload extends StandardSchema>(
name: Name,
options: DefineNotificationOptions<Payload, Ctx>,
): NotificationDef<Name, Payload, Ctx>;
}
/**
* Notification definitions available to durable delivery workers.
*/
export interface NotificationRegistry<Ctx = unknown> {
/**
* Registered definitions in declaration order.
*/
readonly definitions: readonly NotificationDef<string, StandardSchema, Ctx>[];
/**
* Resolve a notification definition by its stable name.
*/
get(name: string): NotificationDef<string, StandardSchema, Ctx> | undefined;
}
/**
* Payload carried by the first-party notification delivery job.
*/
export interface NotificationDeliveryJobPayload {
notificationName: string;
channel: string;
payload: unknown;
options: Omit<SendNotificationOptions, "channels">;
}
type NotificationDeliveryPayloadSchema = StandardSchemaV1<
unknown,
NotificationDeliveryJobPayload
>;
/**
* Job definition used by queued notification dispatchers and workers.
*/
export interface NotificationDeliveryJob<
Name extends string = string,
Ctx = unknown,
> extends JobDef<Name, NotificationDeliveryPayloadSchema, Ctx> {
/**
* Registry used by both enqueue-time checks and worker delivery.
*/
readonly registry: NotificationRegistry<Ctx>;
}
/**
* Options for the first-party notification delivery job.
*/
export interface DefineNotificationDeliveryJobOptions<
Name extends string,
Ctx,
> {
/**
* Stable job name. Defaults to `"notifications.deliver"`.
*/
name?: Name;
/**
* Notification definitions available to the worker.
*/
registry: NotificationRegistry<Ctx>;
/**
* Optional app-owned preferences evaluated when the job runs.
*/
preferences?: NotificationPreferencesPort<Ctx>;
/**
* Retry policy. Defaults to exponential backoff with three attempts.
*/
retry?: JobRetryOptions;
/**
* Optional maximum duration for each channel delivery attempt.
*/
timeout?: JobTimeoutDuration;
/**
* Optional execution hooks applied to each delivery attempt.
*/
hooks?: readonly JobHook<
JobDef<Name, NotificationDeliveryPayloadSchema, Ctx>,
Ctx
>[];
}
/**
* Options for a notification dispatcher backed by Beignet jobs.
*/
export interface QueuedNotificationDispatcherOptions<Name extends string, Ctx> {
/**
* Job dispatcher used to enqueue one delivery job per channel.
*/
jobs: JobDispatcher;
/**
* Registered notification delivery job.
*/
deliveryJob: NotificationDeliveryJob<Name, Ctx>;
/**
* Optional devtools/provider instrumentation target.
*/
instrumentation?: ProviderInstrumentationTarget;
}
/**
* Error thrown when notification payload validation fails.
*/
export class NotificationValidationError extends Error {
/**
* Raw Standard Schema validation issues.
*/
readonly issues: readonly StandardSchemaV1.Issue[];
constructor(args: {
name: string;
issues: readonly StandardSchemaV1.Issue[];
}) {
super(
`Notification "${args.name}" payload validation failed: ${formatIssues(args.issues)}`,
);
this.name = "NotificationValidationError";
this.issues = args.issues;
}
}
/**
* Error thrown when notification delivery fails.
*/
export class NotificationDeliveryError extends Error {
/**
* Notification name.
*/
readonly notificationName: string;
/**
* First channel that failed, retained for concise error handling.
*/
readonly channel: string;
/**
* Original error for the first failed channel when available.
*/
readonly cause: unknown;
/**
* Complete notification result after every selected channel ran.
*/
readonly result: SendNotificationResult;
/**
* Failed channel results.
*/
readonly failures: readonly NotificationChannelResult[];
/**
* Original channel errors in delivery order.
*/
readonly errors: readonly NotificationChannelError[];
constructor(args: {
result: SendNotificationResult;
errors?: readonly NotificationChannelError[];
}) {
const failures = args.result.results.filter(
(result) => result.status === "failed",
);
const firstFailure = failures[0];
const errors = args.errors ?? [];
super(
`Notification "${args.result.notificationName}" failed on ${failures.length} channel${failures.length === 1 ? "" : "s"}: ${failures.map((failure) => failure.channel).join(", ")}.`,
);
this.name = "NotificationDeliveryError";
this.notificationName = args.result.notificationName;
this.channel = firstFailure?.channel ?? "unknown";
this.cause = errors.find(
(entry) => entry.channel === firstFailure?.channel,
)?.error;
this.result = args.result;
this.failures = failures;
this.errors = errors;
}
}
/**
* Error thrown when a notification registry cannot safely resolve a delivery.
*/
export class NotificationRegistryError extends Error {
constructor(message: string) {
super(message);
this.name = "NotificationRegistryError";
}
}
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 NotificationValidationError({
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");
}
async function resolveCtx<Ctx>(
ctx: Ctx | (() => MaybePromise<Ctx>) | undefined,
): Promise<Ctx> {
if (typeof ctx === "function") {
return (ctx as () => MaybePromise<Ctx>)();
}
return ctx as Ctx;
}
function resolveChannelNames(
notification: {
name: string;
channels: Record<string, unknown>;
},
options:
| {
channels?: readonly string[];
}
| undefined,
): readonly string[] {
const available = Object.keys(notification.channels);
const selected = options?.channels ?? available;
for (const channel of selected) {
if (typeof notification.channels[channel] !== "function") {
throw new Error(
`Notification "${notification.name}" does not define channel "${channel}".`,
);
}
}
return selected;
}
function summarizeResults(results: readonly NotificationChannelResult[]) {
const queued = results.filter((result) => result.status === "queued").length;
const sent = results.filter((result) => result.status === "sent").length;
const skipped = results.filter(
(result) => result.status === "skipped",
).length;
const failed = results.filter((result) => result.status === "failed").length;
return { queued, sent, skipped, failed };
}
function resultSummary(results: readonly NotificationChannelResult[]): string {
const summary = summarizeResults(results);
const parts = [
summary.queued ? `${summary.queued} queued` : undefined,
`${summary.sent} sent`,
`${summary.skipped} skipped`,
`${summary.failed} failed`,
].filter((part): part is string => Boolean(part));
return parts.join(", ");
}
function recordChannelResult(
instrumentation: ReturnType<typeof createProviderInstrumentation>,
notificationName: string,
result: NotificationChannelResult,
options: SendNotificationOptions,
): void {
instrumentation.custom({
name: `notification.channel.${result.status}`,
label: `Notification channel ${result.status}`,
summary: `${notificationName} (${result.channel})`,
requestId: options.requestId,
traceId: options.traceId,
spanId: options.spanId,
parentSpanId: options.parentSpanId,
traceparent: options.traceparent,
details: {
notificationName,
channel: result.channel,
status: result.status,
id: result.id,
provider: result.provider,
reason: result.reason,
resultDetails: result.details,
metadata: options.metadata,
},
});
}
async function failedChannelResult<Ctx>(
error: unknown,
args: NotificationChannelHandleArgs<StandardSchema, Ctx>,
onError: InlineNotificationDispatcherOptions<Ctx>["onError"],
): Promise<NotificationChannelResult> {
if (onError) {
try {
const handled = await onError(error, args);
if (handled) return { ...handled, channel: args.channel };
} catch {
// Error observers cannot interrupt delivery of the remaining channels.
}
}
return {
channel: args.channel,
status: "failed",
reason: error instanceof Error ? error.message : String(error),
};
}
function defineNotificationImpl<
Name extends string,
Payload extends StandardSchema,
Ctx = unknown,
>(
name: Name,
options: DefineNotificationOptions<Payload, Ctx>,
): NotificationDef<Name, Payload, Ctx> {
return {
kind: "notification",
name,
payload: options.payload,
description: options.description,
channels: options.channels,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function contextInstrumentationTarget(
ctx: unknown,
): ProviderInstrumentationTarget {
if (!isRecord(ctx)) return undefined;
return isRecord(ctx.ports) ? ctx.ports : ctx;
}
const notificationDeliveryPayloadSchema: NotificationDeliveryPayloadSchema = {
"~standard": {
version: 1,
vendor: "beignet",
validate(value: unknown) {
if (!isRecord(value)) {
return { issues: [{ message: "Expected an object." }] };
}
const issues: StandardSchemaV1.Issue[] = [];
if (
typeof value.notificationName !== "string" ||
value.notificationName.length === 0
) {
issues.push({
message: "Expected a non-empty string.",
path: ["notificationName"],
});
}
if (typeof value.channel !== "string" || value.channel.length === 0) {
issues.push({
message: "Expected a non-empty string.",
path: ["channel"],
});
}
if (!("payload" in value)) {
issues.push({ message: "Required.", path: ["payload"] });
}
if (!isRecord(value.options)) {
issues.push({ message: "Expected an object.", path: ["options"] });
}
const deliveryOptions = isRecord(value.options) ? value.options : {};
const correlationFields = [
"requestId",
"traceId",
"spanId",
"parentSpanId",
"traceparent",
] as const;
for (const field of correlationFields) {
if (
deliveryOptions[field] !== undefined &&
typeof deliveryOptions[field] !== "string"
) {
issues.push({
message: "Expected a string.",
path: ["options", field],
});
}
}
if (
deliveryOptions.metadata !== undefined &&
!isRecord(deliveryOptions.metadata)
) {
issues.push({
message: "Expected an object.",
path: ["options", "metadata"],
});
}
if (issues.length > 0) return { issues };
return {
value: {
notificationName: value.notificationName as string,
channel: value.channel as string,
payload: value.payload,
options: queuedSendOptions(deliveryOptions),
},
};
},
},
};
/**
* Define the notification catalog available to durable delivery workers.
* Duplicate names throw because queued delivery resolves definitions by name.
*/
export function defineNotificationRegistry<Ctx>(
definitions: readonly NotificationDef<string, StandardSchema, Ctx>[],
): NotificationRegistry<Ctx> {
const byName = new Map<
string,
NotificationDef<string, StandardSchema, Ctx>
>();
for (const notification of definitions) {
if (byName.has(notification.name)) {
throw new NotificationRegistryError(
`Duplicate notification definition "${notification.name}" in notification registry.`,
);
}
byName.set(notification.name, notification);
}
return {
definitions: [...definitions],
get(name) {
return byName.get(name);
},
};
}
/**
* Define the generic job that resolves and delivers one notification channel.
* Register the returned job with every worker or outbox registry that can
* receive queued notifications.
*/
export function defineNotificationDeliveryJob<
Ctx,
Name extends string = "notifications.deliver",
>(
options: DefineNotificationDeliveryJobOptions<Name, Ctx>,
): NotificationDeliveryJob<Name, Ctx> {
const name = (options.name ?? "notifications.deliver") as Name;
const { defineJob } = createJobs<Ctx>();
const job = defineJob(name, {
payload: notificationDeliveryPayloadSchema,
description: "Deliver one notification channel.",
retry: options.retry ?? retry.exponential({ attempts: 3 }),
timeout: options.timeout,
hooks: options.hooks,
async handle({ payload, ctx }) {
const notification = options.registry.get(payload.notificationName);
if (!notification) {
throw new NotificationRegistryError(
`Notification "${payload.notificationName}" is not registered for queued delivery.`,
);
}
if (!notification.channels[payload.channel]) {
throw new NotificationRegistryError(
`Notification "${payload.notificationName}" does not define queued channel "${payload.channel}".`,
);
}
const dispatcher = createInlineNotificationDispatcher<Ctx>({
ctx,
preferences: options.preferences,
failureMode: "throw",
instrumentation: contextInstrumentationTarget(ctx),
});
await dispatcher.send(notification as NotificationDef, payload.payload, {
...payload.options,
channels: [payload.channel],
});
},
});
return Object.assign(job, {
registry: options.registry,
});
}
/**
* Validate and parse a notification payload with the notification's Standard
* Schema.
*/
export async function parseNotificationPayload<
N extends NotificationPayloadDef,
>(notification: N, payload: unknown): Promise<InferNotificationPayload<N>> {
return (await parsePayload(notification.payload, payload, {
name: notification.name,
})) as InferNotificationPayload<N>;
}
/**
* Create an inline notification dispatcher.
*
* The dispatcher validates payloads and runs selected channel handlers
* immediately. Channel failures are isolated and reported after every selected
* channel runs. Use this directly in tests and local apps, or use
* `createQueuedNotificationDispatcher(...)` for background execution.
*/
export function createInlineNotificationDispatcher<Ctx>(
options: InlineNotificationDispatcherOptions<Ctx> = {},
): NotificationPort {
const instrumentation = createProviderInstrumentation(
options.instrumentation,
{
providerName: "notifications",
watcher: "notifications",
},
);
return {
async send<N extends NotificationDef<string, StandardSchema, Ctx>>(
notification: N,
payload: InferNotificationPayload<N>,
sendOptions: SendNotificationOptions = {},
) {
const parsed = await parseNotificationPayload(notification, payload);
const channels = resolveChannelNames(notification, sendOptions);
const ctx = await resolveCtx(options.ctx);
const results: NotificationChannelResult[] = [];
const errors: NotificationChannelError[] = [];
instrumentation.custom({
name: "notification.send.started",
label: "Notification started",
summary: notification.name,
requestId: sendOptions.requestId,
traceId: sendOptions.traceId,
spanId: sendOptions.spanId,
parentSpanId: sendOptions.parentSpanId,
traceparent: sendOptions.traceparent,
details: {
notificationName: notification.name,
channels,
metadata: sendOptions.metadata,
},
});
for (const channel of channels) {
const handler = notification.channels[channel];
const args = {
notification,
payload: parsed,
ctx,
channel,
} satisfies NotificationChannelHandleArgs<StandardSchema, Ctx>;
try {
const preference = await options.preferences?.evaluate({
...args,
metadata: sendOptions.metadata,
});
if (preference && !preference.deliver) {
const result = {
channel,
status: "skipped",
reason:
preference.reason ?? "Disabled by notification preferences.",
details: {
source: "preferences",
},
} satisfies NotificationChannelResult;
results.push(result);
recordChannelResult(
instrumentation,
notification.name,
result,
sendOptions,
);
continue;
}
const handled = await handler(args);
const result = handled
? { ...handled, channel }
: ({
channel,
status: "sent",
} satisfies NotificationChannelResult);
results.push(result);
recordChannelResult(
instrumentation,
notification.name,
result,
sendOptions,
);
} catch (error) {
errors.push({ channel, error });
const result = await failedChannelResult(
error,
args,
options.onError,
);
results.push(result);
recordChannelResult(
instrumentation,
notification.name,
result,
sendOptions,
);
}
}
const result = {
notificationName: notification.name,
payload: parsed,
channels,
results,
} satisfies SendNotificationResult;
instrumentation.custom({
name: "notification.send.completed",
label: "Notification completed",
summary: `${notification.name} (${resultSummary(results)})`,
requestId: sendOptions.requestId,
traceId: sendOptions.traceId,
spanId: sendOptions.spanId,
parentSpanId: sendOptions.parentSpanId,
traceparent: sendOptions.traceparent,
details: {
notificationName: notification.name,
channels,
results,
metadata: sendOptions.metadata,
},
});
if (
options.failureMode === "throw" &&
results.some((channelResult) => channelResult.status === "failed")
) {
throw new NotificationDeliveryError({ result, errors });
}
return result;
},
};
}
/**
* Options for the inline notifications provider.
*/
export interface InlineNotificationsProviderOptions
extends Omit<
InlineNotificationDispatcherOptions<unknown>,
"ctx" | "instrumentation"
> {
/**
* Provider name. Defaults to "inline-notifications".
*/
name?: string;
}
/**
* Ports contributed by the inline notifications provider.
*/
export interface InlineNotificationsProviderPorts {
/**
* Beignet notification port.
*/
notifications: NotificationPort;
}
/**
* Create a provider that contributes an inline notification dispatcher.
*
* Use it as the dev-default `notifications` port in `server/providers.ts`.
* Channel handlers run with an app service context built lazily through the
* server context blueprint on each send, so the provider is safe to register
* before all providers have started. Sends are recorded as devtools events
* through the `notifications` watcher when an instrumentation port is
* installed.
*/
export function createInlineNotificationsProvider(
options: InlineNotificationsProviderOptions = {},
) {
const { name = "inline-notifications", ...dispatcherOptions } = options;
return createProvider({
name,
setup({ ports, createServiceContext }) {
const notifications = createInlineNotificationDispatcher({
...dispatcherOptions,
ctx: () => createServiceContext(),
instrumentation: ports,
});
return {
ports: {
notifications,
} satisfies InlineNotificationsProviderPorts,
};
},
});
}
function queuedSendOptions(
options: Partial<Record<keyof SendNotificationOptions, unknown>>,
): Omit<SendNotificationOptions, "channels"> {
const queued: Omit<SendNotificationOptions, "channels"> = {};
if (isRecord(options.metadata)) queued.metadata = options.metadata;
if (typeof options.requestId === "string")
queued.requestId = options.requestId;
if (typeof options.traceId === "string") queued.traceId = options.traceId;
if (typeof options.spanId === "string") queued.spanId = options.spanId;
if (typeof options.parentSpanId === "string") {
queued.parentSpanId = options.parentSpanId;
}
if (typeof options.traceparent === "string")
queued.traceparent = options.traceparent;
return queued;
}
/**
* Create a notification dispatcher that enqueues one delivery job per channel.
* Separate jobs keep provider retries from resending channels that already
* completed successfully.
*/
export function createQueuedNotificationDispatcher<Name extends string, Ctx>(
options: QueuedNotificationDispatcherOptions<Name, Ctx>,
): NotificationPort {
const instrumentation = createProviderInstrumentation(
options.instrumentation,
{
providerName: "queued-notifications",
watcher: "notifications",
},
);
return {
async send<N extends NotificationDef>(
notification: N,
payload: InferNotificationPayload<N>,
sendOptions: SendNotificationOptions = {},
) {
const registered = options.deliveryJob.registry.get(notification.name);
if (!registered) {
throw new NotificationRegistryError(
`Notification "${notification.name}" is not registered for queued delivery.`,
);
}
const parsed = await parseNotificationPayload(registered, payload);
const channels = resolveChannelNames(registered, sendOptions);
const results: NotificationChannelResult[] = [];
instrumentation.custom({
name: "notification.enqueue.started",
label: "Notification enqueue started",
summary: notification.name,
requestId: sendOptions.requestId,
traceId: sendOptions.traceId,
spanId: sendOptions.spanId,
parentSpanId: sendOptions.parentSpanId,
traceparent: sendOptions.traceparent,
details: {
notificationName: notification.name,
channels,
metadata: sendOptions.metadata,
jobName: options.deliveryJob.name,
},
});
for (const channel of channels) {
let result: NotificationChannelResult;
try {
await options.jobs.dispatch(options.deliveryJob, {
notificationName: notification.name,
channel,
payload,
options: queuedSendOptions(sendOptions),
});
result = {
channel,
status: "queued",
provider: "jobs",
details: {
jobName: options.deliveryJob.name,
},
};
} catch (error) {
result = {
channel,
status: "failed",
reason: error instanceof Error ? error.message : String(error),
details: {
phase: "enqueue",
jobName: options.deliveryJob.name,
},
};
}
results.push(result);
recordChannelResult(
instrumentation,
notification.name,
result,
sendOptions,
);
}
const result = {
notificationName: notification.name,
payload: parsed,
channels,
results,
} satisfies SendNotificationResult;
instrumentation.custom({
name: "notification.enqueue.completed",
label: "Notification enqueue completed",
summary: `${notification.name} (${resultSummary(results)})`,
requestId: sendOptions.requestId,
traceId: sendOptions.traceId,
spanId: sendOptions.spanId,
parentSpanId: sendOptions.parentSpanId,
traceparent: sendOptions.traceparent,
details: {
notificationName: notification.name,
channels,
results,
metadata: sendOptions.metadata,
jobName: options.deliveryJob.name,
},
});
return result;
},
};
}
/**
* Options for the queued notifications provider.
*/
export interface QueuedNotificationsProviderOptions<Name extends string, Ctx> {
/**
* Registered notification delivery job.
*/
deliveryJob: NotificationDeliveryJob<Name, Ctx>;
/**
* Provider name. Defaults to `"queued-notifications"`.
*/
name?: string;
}
/**
* Create a provider that contributes a job-backed notification dispatcher.
*/
export function createQueuedNotificationsProvider<Name extends string, Ctx>(
options: QueuedNotificationsProviderOptions<Name, Ctx>,
) {
return createProvider<{ jobs: JobDispatcher }>()({
name: options.name ?? "queued-notifications",
setup({ ports }) {
return {
ports: {
notifications: createQueuedNotificationDispatcher({
jobs: ports.jobs,
deliveryJob: options.deliveryJob,
instrumentation: ports,
}),
} satisfies InlineNotificationsProviderPorts,
};
},
});
}
/**
* Define a mail-backed notification channel.
*
* Return `undefined` from the renderer when the channel should be skipped, for
* example when a recipient does not have an email address.
*/
export function defineMailNotificationChannel<
Payload extends StandardSchema,
Ctx extends MailNotificationContext,
>(
render: MailNotificationRenderer<Payload, Ctx>,
): NotificationChannelHandler<Payload, Ctx> {
return async (args) => {
const message = await render(args);
if (!message) {
return {
channel: args.channel,
status: "skipped",
reason: "No mail message was returned.",
};
}
const result = await args.ctx.ports.mailer.send(message);
return {
channel: args.channel,
status: "sent",
id: result.id,
provider: result.provider,
};
};
}
/**
* Create an in-memory notification port for tests and examples.
*
* The memory port validates payloads and records notification intent without
* running channel handlers.
*/
export function createMemoryNotificationPort(
options: CreateMemoryNotificationPortOptions = {},
): MemoryNotificationPort {
const deliveries: MemoryNotificationDelivery[] = [];
const now = options.now ?? (() => new Date());
const id = options.id ?? (() => crypto.randomUUID());
return {
get deliveries() {
return deliveries;
},
async send<N extends NotificationDef>(
notification: N,
payload: InferNotificationPayload<N>,
sendOptions: SendNotificationOptions = {},
) {
const parsed = await parseNotificationPayload(notification, payload);
const channels = resolveChannelNames(notification, sendOptions);
const delivery: MemoryNotificationDelivery = {
id: id(),
notificationName: notification.name,
payload: parsed,
channels,
metadata: sendOptions.metadata,
sentAt: now(),
};
deliveries.push(delivery);
await options.onSend?.(delivery);
return {
notificationName: notification.name,
payload: parsed,
channels,
results: channels.map((channel) => ({
channel,
status: "sent",
id: delivery.id,
provider: "memory",
details: {
mode: "intent",
},
})),
};
},
clear() {
deliveries.length = 0;
},
};
}
/**
* Create notification helper methods bound to an application context type.
*
* Call it once in `lib/notifications.ts`:
*
* ```ts
* export const { defineNotification } = createNotifications<AppContext>();
* ```
*
* Notifications represent user-facing communication intent. Channel handlers
* decide how that intent becomes mail, SMS, push, in-app delivery, or another
* app-owned channel.
*/
export function createNotifications<Ctx>(): Notifications<Ctx> {
return {
defineNotification<Name extends string, Payload extends StandardSchema>(
name: Name,
options: DefineNotificationOptions<Payload, Ctx>,
): NotificationDef<Name, Payload, Ctx> {
return defineNotificationImpl(name, options);
},
};
}