UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

676 lines 25 kB
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