@beignet/core
Version:
Core framework primitives for Beignet
676 lines • 25 kB
JavaScript
import { createJobs, retry, } from "../jobs/index.js";
import { createProvider, createProviderInstrumentation, } from "../providers/index.js";
/**
* Error thrown when notification payload validation fails.
*/
export class NotificationValidationError extends Error {
/**
* Raw Standard Schema validation issues.
*/
issues;
constructor(args) {
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.
*/
notificationName;
/**
* First channel that failed, retained for concise error handling.
*/
channel;
/**
* Original error for the first failed channel when available.
*/
cause;
/**
* Complete notification result after every selected channel ran.
*/
result;
/**
* Failed channel results.
*/
failures;
/**
* Original channel errors in delivery order.
*/
errors;
constructor(args) {
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) {
super(message);
this.name = "NotificationRegistryError";
}
}
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 NotificationValidationError({
name: args.name,
issues: result.issues,
});
}
if ("value" in result) {
return result.value;
}
throw new Error("Invalid Standard Schema result: missing value");
}
async function resolveCtx(ctx) {
if (typeof ctx === "function") {
return ctx();
}
return ctx;
}
function resolveChannelNames(notification, options) {
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) {
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) {
const summary = summarizeResults(results);
const parts = [
summary.queued ? `${summary.queued} queued` : undefined,
`${summary.sent} sent`,
`${summary.skipped} skipped`,
`${summary.failed} failed`,
].filter((part) => Boolean(part));
return parts.join(", ");
}
function recordChannelResult(instrumentation, notificationName, result, options) {
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(error, args, onError) {
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, options) {
return {
kind: "notification",
name,
payload: options.payload,
description: options.description,
channels: options.channels,
};
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function contextInstrumentationTarget(ctx) {
if (!isRecord(ctx))
return undefined;
return isRecord(ctx.ports) ? ctx.ports : ctx;
}
const notificationDeliveryPayloadSchema = {
"~standard": {
version: 1,
vendor: "beignet",
validate(value) {
if (!isRecord(value)) {
return { issues: [{ message: "Expected an object." }] };
}
const issues = [];
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",
];
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,
channel: value.channel,
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(definitions) {
const byName = new Map();
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(options) {
const name = (options.name ?? "notifications.deliver");
const { defineJob } = createJobs();
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,
preferences: options.preferences,
failureMode: "throw",
instrumentation: contextInstrumentationTarget(ctx),
});
await dispatcher.send(notification, 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(notification, payload) {
return (await parsePayload(notification.payload, payload, {
name: notification.name,
}));
}
/**
* 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(options = {}) {
const instrumentation = createProviderInstrumentation(options.instrumentation, {
providerName: "notifications",
watcher: "notifications",
});
return {
async send(notification, payload, sendOptions = {}) {
const parsed = await parseNotificationPayload(notification, payload);
const channels = resolveChannelNames(notification, sendOptions);
const ctx = await resolveCtx(options.ctx);
const results = [];
const errors = [];
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,
};
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",
},
};
results.push(result);
recordChannelResult(instrumentation, notification.name, result, sendOptions);
continue;
}
const handled = await handler(args);
const result = handled
? { ...handled, channel }
: {
channel,
status: "sent",
};
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,
};
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;
},
};
}
/**
* 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 = {}) {
const { name = "inline-notifications", ...dispatcherOptions } = options;
return createProvider({
name,
setup({ ports, createServiceContext }) {
const notifications = createInlineNotificationDispatcher({
...dispatcherOptions,
ctx: () => createServiceContext(),
instrumentation: ports,
});
return {
ports: {
notifications,
},
};
},
});
}
function queuedSendOptions(options) {
const queued = {};
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(options) {
const instrumentation = createProviderInstrumentation(options.instrumentation, {
providerName: "queued-notifications",
watcher: "notifications",
});
return {
async send(notification, payload, sendOptions = {}) {
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 = [];
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;
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,
};
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;
},
};
}
/**
* Create a provider that contributes a job-backed notification dispatcher.
*/
export function createQueuedNotificationsProvider(options) {
return createProvider()({
name: options.name ?? "queued-notifications",
setup({ ports }) {
return {
ports: {
notifications: createQueuedNotificationDispatcher({
jobs: ports.jobs,
deliveryJob: options.deliveryJob,
instrumentation: ports,
}),
},
};
},
});
}
/**
* 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(render) {
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 = {}) {
const deliveries = [];
const now = options.now ?? (() => new Date());
const id = options.id ?? (() => crypto.randomUUID());
return {
get deliveries() {
return deliveries;
},
async send(notification, payload, sendOptions = {}) {
const parsed = await parseNotificationPayload(notification, payload);
const channels = resolveChannelNames(notification, sendOptions);
const delivery = {
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() {
return {
defineNotification(name, options) {
return defineNotificationImpl(name, options);
},
};
}
//# sourceMappingURL=index.js.map