Initial Commit
This commit is contained in:
117
packages/communication/messaging/notification-channel/README.md
Normal file
117
packages/communication/messaging/notification-channel/README.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# @suaveplan/notification-channel - Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentation, and Zod-validated boundaries. Universal: runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentation, and Zod-validated boundaries. Universal: runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [delivery-channel](./src/channels/delivery-channel.md) — `delivery-channel.ts` defines the structural contract every concrete delivery channel implements. The contract is inten…
|
||||
- [email-channel](./src/channels/email-channel.md) — `email-channel.ts` ships the `EmailChannel` `DeliveryChannel` implementation. The class is provider-neutral: the integr…
|
||||
- [in-app-channel](./src/channels/in-app-channel.md) — `in-app-channel.ts` ships the `InAppChannel` implementation that stores notifications for an in-app inbox / unread badg…
|
||||
- [push-channel](./src/channels/push-channel.md) — `push-channel.ts` ships the `PushChannel` implementation that delivers notifications via the Web Push Protocol (RFC 803…
|
||||
- [sms-channel](./src/channels/sms-channel.md) — `sms-channel.ts` ships the `SmsChannel` `DeliveryChannel` implementation. Like the email channel, it is provider-neutra…
|
||||
- [webhook-channel](./src/channels/webhook-channel.md) — `webhook-channel.ts` ships the `WebhookChannel` implementation that POSTs notifications to an arbitrary HTTPS endpoint.…
|
||||
- [digest](./src/digest/digest-channel.md) — `digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries deliver…
|
||||
- [digest-channel](./src/digest/digest-channel.md) — `digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries deliver…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw.…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw.…
|
||||
- [error-mapping](./src/error-mapping/error-mapping.md) — `error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed su…
|
||||
- [error-mapping](./src/error-mapping/error-mapping.md) — `error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed su…
|
||||
- [fakes](./src/fakes/fake-fetch.md) — `fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue an…
|
||||
- [fake-fetch](./src/fakes/fake-fetch.md) — `fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue an…
|
||||
- [hooks](./src/hooks/hooks.md) — `hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. Th…
|
||||
- [hooks](./src/hooks/hooks.md) — `hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. Th…
|
||||
- [otel](./src/otel/metrics.md) — `otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-chan…
|
||||
- [metrics](./src/otel/metrics.md) — `otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-chan…
|
||||
- [preferences](./src/preferences/preference-manager.md) — `preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification…
|
||||
- [preference-manager](./src/preferences/preference-manager.md) — `preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification…
|
||||
- [rate-limiter](./src/rate-limiter/channel-rate-limiter.md) — `channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter.…
|
||||
- [channel-rate-limiter](./src/rate-limiter/channel-rate-limiter.md) — `channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter.…
|
||||
- [retry](./src/retry/delivery-retry.md) — `delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backo…
|
||||
- [delivery-retry](./src/retry/delivery-retry.md) — `delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backo…
|
||||
- [router](./src/router/notification-router.md) — `notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves…
|
||||
- [notification-router](./src/router/notification-router.md) — `notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves…
|
||||
- [scheduler](./src/scheduler/notification-scheduler.md) — `notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. T…
|
||||
- [notification-scheduler](./src/scheduler/notification-scheduler.md) — `notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. T…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input…
|
||||
- [templates](./src/templates/template-engine.md) — `template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered outp…
|
||||
- [template-engine](./src/templates/template-engine.md) — `template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered outp…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/notification-channel
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
EmailChannel,
|
||||
NotificationRouter,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const email = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async (msg) => ({ messageId: await provider.send(msg) }),
|
||||
});
|
||||
|
||||
const router = new NotificationRouter({
|
||||
channels: [{ channel: "email", enabled: true, rateLimit: 100 }],
|
||||
defaultChannel: "email",
|
||||
deliveryChannels: [email],
|
||||
});
|
||||
|
||||
router.on("notification.failed", (n) => audit.record(n));
|
||||
|
||||
await router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Welcome to Suaveplan",
|
||||
subject: "Welcome",
|
||||
});
|
||||
```
|
||||
|
||||
## Surface
|
||||
|
||||
- **Five sending channels** — `EmailChannel`, `SmsChannel`, `PushChannel`, `InAppChannel`, `WebhookChannel`.
|
||||
- **One aggregating channel** — `DigestChannel` (wraps any sending channel).
|
||||
- **Router** — `NotificationRouter` with templates, routing rules, lifecycle events, OTel meters.
|
||||
- **Composables** — `ChannelRateLimiter`, `DeliveryRetry`, `NotificationScheduler`, `PreferenceManager`, `TemplateEngine`.
|
||||
- **Hooks** — `notification:routing` pre-routing pipeline (`onNotificationRouting`).
|
||||
- **Errors** — stable `NOTIFICATION_CHANNEL_CODES` registry with eleven reasons.
|
||||
- **Test double** — `FakeFetch` for deterministic `FetchLike` injection.
|
||||
- **OpenTelemetry** — four instruments under the `@suaveplan/notification-channel` meter.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes`](./src/error-codes.md) — `NOTIFICATION_CHANNEL_CODES` + typed error subclasses.
|
||||
- [`hooks/hooks`](./src/hooks/hooks.md) — generic hook bus + `notification:routing` pipeline.
|
||||
- [`schemas/schemas`](./src/schemas/schemas.md) — Zod schemas for every public option bundle.
|
||||
- [`channels/delivery-channel`](./src/channels/delivery-channel.md) — common transport contract.
|
||||
- [`channels/email-channel`](./src/channels/email-channel.md) — email delivery.
|
||||
- [`channels/sms-channel`](./src/channels/sms-channel.md) — SMS delivery.
|
||||
- [`channels/push-channel`](./src/channels/push-channel.md) — Web Push delivery (RFC 8030).
|
||||
- [`channels/in-app-channel`](./src/channels/in-app-channel.md) — in-app inbox delivery.
|
||||
- [`channels/webhook-channel`](./src/channels/webhook-channel.md) — HTTP webhook delivery.
|
||||
- [`digest/digest-channel`](./src/digest/digest-channel.md) — digest / summary delivery.
|
||||
- [`templates/template-engine`](./src/templates/template-engine.md) — template registry + renderer.
|
||||
- [`rate-limiter/channel-rate-limiter`](./src/rate-limiter/channel-rate-limiter.md) — sliding-window rate limits.
|
||||
- [`retry/delivery-retry`](./src/retry/delivery-retry.md) — retry wrapper + dead-letter queue.
|
||||
- [`scheduler/notification-scheduler`](./src/scheduler/notification-scheduler.md) — future-delivery scheduler.
|
||||
- [`preferences/preference-manager`](./src/preferences/preference-manager.md) — opt-in/out + quiet hours.
|
||||
- [`router/notification-router`](./src/router/notification-router.md) — primary multi-channel router.
|
||||
- [`otel/metrics`](./src/otel/metrics.md) — OpenTelemetry instruments.
|
||||
- [`fakes/fake-fetch`](./src/fakes/fake-fetch.md) — deterministic `FetchLike` test double.
|
||||
|
||||
## Architecture
|
||||
|
||||
Every dependency is injectable: `Clock` and `FetchLike` come from `@suaveplan/types`, the storage adapter behind `PreferenceManager` is caller-supplied, and every channel takes a `sendFn` callback so the package never bundles a specific provider SDK. The router uses `EventBus<NotificationEventMap>` so lifecycle events flow through the same typed-pub/sub primitive as the rest of the project.
|
||||
|
||||
The 11-code `defineCodes("NOTIFICATION_CHANNEL", […])` registry preserves literal-typed unions through the public surface, so an exhaustive `switch (err.code)` reports unhandled codes at type-check time. OpenTelemetry meters fire on every router send and every dead-letter event so dashboards reflect real production behaviour out of the box.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"name": "@suaveplan/notification-channel",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentation, and Zod-validated boundaries. Universal: runs unchanged in Bun, Node, and the browser.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./types/notification": {
|
||||
"import": {
|
||||
"types": "./dist/types/notification.types.d.ts",
|
||||
"default": "./dist/types/notification.types.js"
|
||||
}
|
||||
},
|
||||
"./schemas/schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/schemas.d.ts",
|
||||
"default": "./dist/schemas/schemas.js"
|
||||
}
|
||||
},
|
||||
"./hooks/hooks": {
|
||||
"import": {
|
||||
"types": "./dist/hooks/hooks.d.ts",
|
||||
"default": "./dist/hooks/hooks.js"
|
||||
}
|
||||
},
|
||||
"./channels/delivery-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/delivery-channel.d.ts",
|
||||
"default": "./dist/channels/delivery-channel.js"
|
||||
}
|
||||
},
|
||||
"./channels/email-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/email-channel.d.ts",
|
||||
"default": "./dist/channels/email-channel.js"
|
||||
}
|
||||
},
|
||||
"./channels/sms-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/sms-channel.d.ts",
|
||||
"default": "./dist/channels/sms-channel.js"
|
||||
}
|
||||
},
|
||||
"./channels/push-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/push-channel.d.ts",
|
||||
"default": "./dist/channels/push-channel.js"
|
||||
}
|
||||
},
|
||||
"./channels/in-app-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/in-app-channel.d.ts",
|
||||
"default": "./dist/channels/in-app-channel.js"
|
||||
}
|
||||
},
|
||||
"./channels/webhook-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/webhook-channel.d.ts",
|
||||
"default": "./dist/channels/webhook-channel.js"
|
||||
}
|
||||
},
|
||||
"./digest/digest-channel": {
|
||||
"import": {
|
||||
"types": "./dist/digest/digest-channel.d.ts",
|
||||
"default": "./dist/digest/digest-channel.js"
|
||||
}
|
||||
},
|
||||
"./templates/template-engine": {
|
||||
"import": {
|
||||
"types": "./dist/templates/template-engine.d.ts",
|
||||
"default": "./dist/templates/template-engine.js"
|
||||
}
|
||||
},
|
||||
"./rate-limiter/channel-rate-limiter": {
|
||||
"import": {
|
||||
"types": "./dist/rate-limiter/channel-rate-limiter.d.ts",
|
||||
"default": "./dist/rate-limiter/channel-rate-limiter.js"
|
||||
}
|
||||
},
|
||||
"./retry/delivery-retry": {
|
||||
"import": {
|
||||
"types": "./dist/retry/delivery-retry.d.ts",
|
||||
"default": "./dist/retry/delivery-retry.js"
|
||||
}
|
||||
},
|
||||
"./scheduler/notification-scheduler": {
|
||||
"import": {
|
||||
"types": "./dist/scheduler/notification-scheduler.d.ts",
|
||||
"default": "./dist/scheduler/notification-scheduler.js"
|
||||
}
|
||||
},
|
||||
"./preferences/preference-manager": {
|
||||
"import": {
|
||||
"types": "./dist/preferences/preference-manager.d.ts",
|
||||
"default": "./dist/preferences/preference-manager.js"
|
||||
}
|
||||
},
|
||||
"./router/notification-router": {
|
||||
"import": {
|
||||
"types": "./dist/router/notification-router.d.ts",
|
||||
"default": "./dist/router/notification-router.js"
|
||||
}
|
||||
},
|
||||
"./otel/metrics": {
|
||||
"import": {
|
||||
"types": "./dist/otel/metrics.d.ts",
|
||||
"default": "./dist/otel/metrics.js"
|
||||
}
|
||||
},
|
||||
"./fakes/fake-fetch": {
|
||||
"import": {
|
||||
"types": "./dist/fakes/fake-fetch.d.ts",
|
||||
"default": "./dist/fakes/fake-fetch.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# `delivery-channel` — Common transport contract
|
||||
|
||||
## Purpose
|
||||
|
||||
`delivery-channel.ts` defines the structural contract every concrete delivery channel implements. The contract is intentionally tiny: a `name`, a synchronous `canSend(notification)` filter, and an async `send(notification)` that returns a `DeliveryResult`. Channels MUST swallow transport-level exceptions and surface failures via `DeliveryResult { success: false, error }` so the router can decide whether to retry, dead-letter, or emit `notification.failed` without try/catching every channel.
|
||||
|
||||
## Features
|
||||
|
||||
- Single `DeliveryChannel` interface for every transport.
|
||||
- `DeliveryResult` shape carries success flag, channel name, optional messageId / error, and a timestamp.
|
||||
- Synchronous `canSend` so routers can probe support without awaiting.
|
||||
- No coupling to a specific provider — the integration point is each channel's own `sendFn`/`fetchFn` callback.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const noop: DeliveryChannel = {
|
||||
name: "noop",
|
||||
canSend: () => true,
|
||||
async send(): Promise<DeliveryResult> {
|
||||
return {
|
||||
success: true,
|
||||
channel: "noop",
|
||||
messageId: "noop-0",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { DeliveryChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
class LoggingChannel implements DeliveryChannel {
|
||||
public readonly name = "logging";
|
||||
constructor(private readonly inner: DeliveryChannel) {}
|
||||
canSend(n: import("@suaveplan/notification-channel").Notification) {
|
||||
return this.inner.canSend(n);
|
||||
}
|
||||
async send(n: import("@suaveplan/notification-channel").Notification) {
|
||||
const start = Date.now();
|
||||
const r = await this.inner.send(n);
|
||||
console.error(`[${this.inner.name}] ${r.success ? "ok" : "fail"} +${Date.now() - start}ms`);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DeliveryChannel` — interface.
|
||||
- `name: string` — unique transport identifier (matches the channel key the router routes to).
|
||||
- `canSend(notification): boolean` — synchronous probe.
|
||||
- `send(notification): Promise<DeliveryResult>` — main entry point; MUST NOT throw on transport failure.
|
||||
- `DeliveryResult` — `{ success, channel, messageId?, error?, timestamp }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both fields `messageId` and `error` are optional and mutually exclusive in practice — `success: true` should set `messageId`, `success: false` should set `error`. Tests cover both polarities. The interface deliberately uses `string` for `channel` (not `NotificationChannel`) so wrappers like `DigestChannel` (whose `name` is the literal `"digest"`) and custom decorators can satisfy the contract without widening the package-wide channel enum.
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
|
||||
/** Result of delivering a notification through a channel. */
|
||||
export interface DeliveryResult {
|
||||
readonly success: boolean;
|
||||
readonly channel: string;
|
||||
readonly messageId?: string;
|
||||
readonly error?: string;
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract every concrete delivery channel implements. Channels are
|
||||
* intentionally small — `send` MUST swallow transport-level exceptions
|
||||
* and return a `DeliveryResult` so the router can decide whether to
|
||||
* retry, dead-letter, or surface the failure to the bus.
|
||||
*/
|
||||
export interface DeliveryChannel {
|
||||
/** Unique name identifying this channel. */
|
||||
readonly name: string;
|
||||
/** Send a notification through this channel. */
|
||||
send(notification: Notification): Promise<DeliveryResult>;
|
||||
/** Check whether this channel can handle the given notification. */
|
||||
canSend(notification: Notification): boolean;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# `email-channel` — Email delivery channel
|
||||
|
||||
## Purpose
|
||||
|
||||
`email-channel.ts` ships the `EmailChannel` `DeliveryChannel` implementation. The class is provider-neutral: the integration seam is a caller-supplied `sendFn(message)` callback that performs the actual SMTP / API call. Wiring a real provider (Amazon SES, SendGrid, Postmark, Mailgun, or an internal MTA) is a matter of supplying a function with the documented signature. The channel swallows transport errors and returns them as `DeliveryResult { success: false, error }`, never propagating exceptions to the router.
|
||||
|
||||
## Features
|
||||
|
||||
- Provider-neutral via injectable `sendFn`.
|
||||
- `canSend` accepts `email` channel + `@`-bearing recipient — keeps malformed addresses from reaching upstream providers.
|
||||
- Falls back to `"(No subject)"` when the notification carries no subject.
|
||||
- Propagates `metadata` to the provider message — useful for tracking IDs.
|
||||
- Injectable `Clock` for deterministic `timestamp` fields in tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EmailChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async (msg) => ({ messageId: await sesClient.send(msg) }),
|
||||
});
|
||||
|
||||
const result = await channel.send(notification);
|
||||
if (!result.success) reportError(result.error);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { EmailChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const channel = new EmailChannel({
|
||||
from: "alerts@example.com",
|
||||
sendFn: async (msg) => {
|
||||
const messageId = await sendgrid.send({
|
||||
to: msg.to,
|
||||
from: "alerts@example.com",
|
||||
subject: msg.subject,
|
||||
content: [{ type: "text/plain", value: msg.body }],
|
||||
customArgs: msg.metadata as Record<string, string> | undefined,
|
||||
});
|
||||
return { messageId };
|
||||
},
|
||||
clock: () => testNow,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EmailChannel(config: EmailChannelConfig)` — constructor.
|
||||
- `EmailChannelConfig` — `{ from, sendFn, clock? }`.
|
||||
- `EmailMessage` — `{ to, subject, body, metadata? }` passed to `sendFn`.
|
||||
- `canSend(n): boolean`, `send(n): Promise<DeliveryResult>` — `DeliveryChannel` impl.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default subject `"(No subject)"` matches the upstream `@suaveplan/notification-channel@1.2.6` behaviour so consumer dashboards keep showing a non-empty subject column. Non-`Error` throws inside `sendFn` are coerced via `String(err)` so the `DeliveryResult.error` is always a string. The injected `clock` only affects the `timestamp` field — the upstream provider's own timestamps remain authoritative for SLA tracking.
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import { EmailChannel } from "./email-channel.js";
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: "notif_1",
|
||||
channel: "email",
|
||||
recipient: "user@example.com",
|
||||
subject: "Test Subject",
|
||||
body: "Test body",
|
||||
priority: "normal",
|
||||
status: "sending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("EmailChannel", () => {
|
||||
it('has name "email"', { timeout: 5000 }, () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_1" }),
|
||||
});
|
||||
expect(channel.name).toBe("email");
|
||||
});
|
||||
|
||||
it("canSend returns true for email channel with @ recipient", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_1" }),
|
||||
});
|
||||
expect(channel.canSend(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns false for non-email channel", { timeout: 5000 }, () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_1" }),
|
||||
});
|
||||
expect(channel.canSend(makeNotification({ channel: "sms" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("canSend returns false for recipient without @", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_1" }),
|
||||
});
|
||||
expect(
|
||||
channel.canSend(makeNotification({ recipient: "no-at-sign" }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("sends successfully and returns result", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_42" }),
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe("email");
|
||||
expect(result.messageId).toBe("msg_42");
|
||||
expect(result.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns failure when sendFn throws", { timeout: 30000 }, async () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => {
|
||||
throw new Error("SMTP connection failed");
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("SMTP connection failed");
|
||||
});
|
||||
|
||||
it('uses "(No subject)" when subject is missing', {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let capturedSubject = "";
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async (msg) => {
|
||||
capturedSubject = msg.subject;
|
||||
return { messageId: "msg_1" };
|
||||
},
|
||||
});
|
||||
const n = makeNotification();
|
||||
const { subject: _drop, ...rest } = n;
|
||||
await channel.send(rest as Notification);
|
||||
expect(capturedSubject).toBe("(No subject)");
|
||||
});
|
||||
|
||||
it("passes metadata to sendFn", { timeout: 30000 }, async () => {
|
||||
let capturedMeta: Record<string, unknown> | undefined;
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async (msg) => {
|
||||
capturedMeta = msg.metadata;
|
||||
return { messageId: "msg_1" };
|
||||
},
|
||||
});
|
||||
await channel.send(makeNotification({ metadata: { key: "val" } }));
|
||||
expect(capturedMeta).toEqual({ key: "val" });
|
||||
});
|
||||
|
||||
it("handles non-Error throw in sendFn", { timeout: 30000 }, async () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => {
|
||||
const literal: unknown = "string error";
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("string error");
|
||||
});
|
||||
|
||||
it("honours injected clock for timestamp", { timeout: 5000 }, async () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_1" }),
|
||||
clock: () => Date.UTC(2026, 0, 2, 3, 4, 5),
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.timestamp).toBe("2026-01-02T03:04:05.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
|
||||
|
||||
/** Email message passed to the send function. */
|
||||
export interface EmailMessage {
|
||||
readonly to: string;
|
||||
readonly subject: string;
|
||||
readonly body: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Configuration for the email channel. */
|
||||
export interface EmailChannelConfig {
|
||||
readonly from: string;
|
||||
readonly sendFn: (message: EmailMessage) => Promise<{ messageId: string }>;
|
||||
/** Optional clock for `timestamp` field (default: `Date.now`). */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Delivers notifications via email using a configurable send function.
|
||||
* The `sendFn` is the integration seam — wire a real provider (SES,
|
||||
* SendGrid, Postmark) by implementing the callback signature.
|
||||
*/
|
||||
export class EmailChannel implements DeliveryChannel {
|
||||
public readonly name = "email";
|
||||
private readonly config: EmailChannelConfig;
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(config: EmailChannelConfig) {
|
||||
this.config = config;
|
||||
this.clock = config.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return (
|
||||
notification.channel === "email" &&
|
||||
notification.recipient.includes("@")
|
||||
);
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
const message: EmailMessage = {
|
||||
to: notification.recipient,
|
||||
subject: notification.subject ?? "(No subject)",
|
||||
body: notification.body,
|
||||
...(notification.metadata !== undefined
|
||||
? { metadata: notification.metadata }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.config.sendFn(message);
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: result.messageId,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# `in-app-channel` — In-app notification channel
|
||||
|
||||
## Purpose
|
||||
|
||||
`in-app-channel.ts` ships the `InAppChannel` implementation that stores notifications for an in-app inbox / unread badge. The default storage is an in-memory `Map<recipientId, InAppNotification[]>`, but the constructor accepts any `InAppStore` implementation so consumers can persist to SurrealDB, Postgres, Redis, or a local IndexedDB browser store. The class is universal — no Node-only or browser-only APIs.
|
||||
|
||||
## Features
|
||||
|
||||
- Default in-memory store — useful for tests and prototypes.
|
||||
- Pluggable `InAppStore` adapter for persistence.
|
||||
- `getByRecipient(recipientId)` and `markRead(id)` convenience methods.
|
||||
- Records unread by default — flips to read via `markRead`.
|
||||
- Returns `DeliveryResult { success: false }` when the store throws, never propagates.
|
||||
- Injectable `Clock` for deterministic `createdAt` and `timestamp` fields.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InAppChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const channel = new InAppChannel();
|
||||
await channel.send(notification);
|
||||
const unread = (await channel.getByRecipient(notification.recipient)).filter(
|
||||
(n) => !n.read
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
InAppChannel,
|
||||
type InAppNotification,
|
||||
type InAppStore,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const dbStore: InAppStore = {
|
||||
async save(n) {
|
||||
await db.execute("INSERT INTO notifications ...", [n]);
|
||||
},
|
||||
async getByRecipient(recipientId) {
|
||||
return db.query<InAppNotification>(
|
||||
"SELECT * FROM notifications WHERE recipientId = ?",
|
||||
[recipientId]
|
||||
);
|
||||
},
|
||||
async markRead(id) {
|
||||
await db.execute(
|
||||
"UPDATE notifications SET read = true WHERE id = ?",
|
||||
[id]
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const channel = new InAppChannel(dbStore);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `InAppChannel(store?: InAppStore, clock?: Clock)` — constructor.
|
||||
- `InAppNotification` — `{ id, recipientId, subject?, body, read, createdAt, metadata? }`.
|
||||
- `InAppStore` — `save`, `getByRecipient`, `markRead`.
|
||||
- `channel.canSend(n)`, `channel.send(n)`, `channel.getByRecipient(id)`, `channel.markRead(id)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default in-memory store backs onto a private `Map` so multiple `InAppChannel` instances do not share state. The store factory closes over the per-instance map to avoid leaking persistence between independent channel objects (useful in tests that construct multiple channels). `markRead` is implemented by scanning every recipient's list — for production-scale deployments the consumer must inject a real store with an indexed lookup; the in-memory variant is a development convenience, not a recommended persistence layer.
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { InAppNotification, InAppStore } from "./in-app-channel.js";
|
||||
import { InAppChannel } from "./in-app-channel.js";
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: "notif_1",
|
||||
channel: "in_app",
|
||||
recipient: "user123",
|
||||
subject: "Test",
|
||||
body: "In-app message",
|
||||
priority: "normal",
|
||||
status: "sending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("InAppChannel", () => {
|
||||
it('has name "in_app"', { timeout: 5000 }, () => {
|
||||
const channel = new InAppChannel();
|
||||
expect(channel.name).toBe("in_app");
|
||||
});
|
||||
|
||||
it("canSend returns true for in_app channel", { timeout: 5000 }, () => {
|
||||
const channel = new InAppChannel();
|
||||
expect(channel.canSend(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns false for non in_app channel", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new InAppChannel();
|
||||
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("sends and stores notification in memory", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe("notif_1");
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored).toHaveLength(1);
|
||||
const first = stored[0];
|
||||
expect(first?.body).toBe("In-app message");
|
||||
});
|
||||
|
||||
it("stores multiple notifications per recipient", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
await channel.send(makeNotification({ id: "n1" }));
|
||||
await channel.send(makeNotification({ id: "n2", body: "Second" }));
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array for unknown recipient", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
const stored = await channel.getByRecipient("unknown");
|
||||
expect(stored).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("marks notification as read", { timeout: 30000 }, async () => {
|
||||
const channel = new InAppChannel();
|
||||
await channel.send(makeNotification());
|
||||
await channel.markRead("notif_1");
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored[0]?.read).toBe(true);
|
||||
});
|
||||
|
||||
it("markRead on unknown id is a no-op", { timeout: 5000 }, async () => {
|
||||
const channel = new InAppChannel();
|
||||
await channel.send(makeNotification());
|
||||
await channel.markRead("does-not-exist");
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored[0]?.read).toBe(false);
|
||||
});
|
||||
|
||||
it("uses custom store when provided", { timeout: 30000 }, async () => {
|
||||
const records: InAppNotification[] = [];
|
||||
const customStore: InAppStore = {
|
||||
async save(n) {
|
||||
records.push(n);
|
||||
},
|
||||
async getByRecipient() {
|
||||
return records;
|
||||
},
|
||||
async markRead() {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
const channel = new InAppChannel(customStore);
|
||||
await channel.send(makeNotification());
|
||||
expect(records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles store save failure gracefully", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const failingStore: InAppStore = {
|
||||
async save() {
|
||||
throw new Error("DB write failed");
|
||||
},
|
||||
async getByRecipient() {
|
||||
return [];
|
||||
},
|
||||
async markRead() {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
const channel = new InAppChannel(failingStore);
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("DB write failed");
|
||||
});
|
||||
|
||||
it("returns string body when store throws non-Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failingStore: InAppStore = {
|
||||
async save() {
|
||||
const literal: unknown = "boom";
|
||||
throw literal;
|
||||
},
|
||||
async getByRecipient() {
|
||||
return [];
|
||||
},
|
||||
async markRead() {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
const channel = new InAppChannel(failingStore);
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("boom");
|
||||
});
|
||||
|
||||
it("notification stored as unread by default", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
await channel.send(makeNotification());
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored[0]?.read).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves metadata when notification carries it", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
await channel.send(makeNotification({ metadata: { trace: "x-1" } }));
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored[0]?.metadata).toEqual({ trace: "x-1" });
|
||||
});
|
||||
|
||||
it("supports notifications without metadata and without subject", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
const { subject: _drop, ...rest } = makeNotification();
|
||||
await channel.send(rest as Notification);
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored[0]?.subject).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
|
||||
|
||||
/** Stored in-app notification record. */
|
||||
export interface InAppNotification {
|
||||
readonly id: string;
|
||||
readonly recipientId: string;
|
||||
readonly subject?: string;
|
||||
readonly body: string;
|
||||
readonly read: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Optional store interface for persistence. */
|
||||
export interface InAppStore {
|
||||
save(notification: InAppNotification): Promise<void>;
|
||||
getByRecipient(recipientId: string): Promise<InAppNotification[]>;
|
||||
markRead(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Delivers notifications in-app by storing them in memory (default) or
|
||||
* a caller-provided `InAppStore` implementation. Suitable for unread
|
||||
* badges, in-app inboxes, and per-user notification feeds.
|
||||
*/
|
||||
export class InAppChannel implements DeliveryChannel {
|
||||
public readonly name = "in_app";
|
||||
private readonly store: InAppStore;
|
||||
private readonly clock: Clock;
|
||||
private readonly memoryStore = new Map<string, InAppNotification[]>();
|
||||
|
||||
public constructor(store?: InAppStore, clock: Clock = defaultClock) {
|
||||
this.clock = clock;
|
||||
this.store = store ?? this.createMemoryStore();
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return notification.channel === "in_app";
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
const record: InAppNotification = {
|
||||
id: notification.id,
|
||||
recipientId: notification.recipient,
|
||||
...(notification.subject !== undefined
|
||||
? { subject: notification.subject }
|
||||
: {}),
|
||||
body: notification.body,
|
||||
read: false,
|
||||
createdAt: new Date(this.clock()).toISOString(),
|
||||
...(notification.metadata !== undefined
|
||||
? { metadata: notification.metadata }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.store.save(record);
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: notification.id,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Get notifications for a recipient (convenience). */
|
||||
public async getByRecipient(
|
||||
recipientId: string
|
||||
): Promise<InAppNotification[]> {
|
||||
return this.store.getByRecipient(recipientId);
|
||||
}
|
||||
|
||||
/** Mark a notification as read (convenience). */
|
||||
public async markRead(id: string): Promise<void> {
|
||||
return this.store.markRead(id);
|
||||
}
|
||||
|
||||
private createMemoryStore(): InAppStore {
|
||||
const store = this.memoryStore;
|
||||
return {
|
||||
async save(notification: InAppNotification): Promise<void> {
|
||||
const list = store.get(notification.recipientId) ?? [];
|
||||
list.push(notification);
|
||||
store.set(notification.recipientId, list);
|
||||
},
|
||||
async getByRecipient(
|
||||
recipientId: string
|
||||
): Promise<InAppNotification[]> {
|
||||
return store.get(recipientId) ?? [];
|
||||
},
|
||||
async markRead(id: string): Promise<void> {
|
||||
for (const list of store.values()) {
|
||||
const existing = list.find((n) => n.id === id);
|
||||
if (existing !== undefined) {
|
||||
const idx = list.indexOf(existing);
|
||||
list.splice(idx, 1, { ...existing, read: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# `push-channel` — Web Push notification channel
|
||||
|
||||
## Purpose
|
||||
|
||||
`push-channel.ts` ships the `PushChannel` implementation that delivers notifications via the Web Push Protocol (RFC 8030) over VAPID-authenticated POSTs. The channel resolves per-user subscriptions through an injected `getSubscriptions(recipient)` callback, then dispatches the encrypted payload to every endpoint either through an explicit `sendFn` (recommended when you already use `web-push` or a vendor SDK) OR a `FetchLike` injectable for the default direct-POST path. Partial success is treated as overall success; only when EVERY subscription fails does the channel report failure.
|
||||
|
||||
## Features
|
||||
|
||||
- VAPID `{ publicKey, privateKey, subject }` authentication.
|
||||
- Multi-subscription fan-out per recipient with partial-success semantics.
|
||||
- Pluggable `sendFn` for vendor SDKs; or pluggable `FetchLike` for direct POSTs.
|
||||
- Per-channel `ttl` (default 86400 s).
|
||||
- Reports `1of3` / `2of2` cardinality in `messageId` for observability.
|
||||
- Returns `DeliveryResult { success: false }` when `getSubscriptions` throws OR every subscription fails.
|
||||
- Injectable `Clock` for deterministic `timestamp`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PushChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const channel = new PushChannel({
|
||||
vapid: { publicKey: "...", privateKey: "...", subject: "mailto:admin@x" },
|
||||
getSubscriptions: (recipient) => db.getPushSubscriptions(recipient),
|
||||
});
|
||||
const r = await channel.send(notification);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import webPush from "web-push";
|
||||
import { PushChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: (r) => db.getPushSubscriptions(r),
|
||||
sendFn: async (sub, payload, v) => {
|
||||
try {
|
||||
await webPush.sendNotification(sub, payload, {
|
||||
vapidDetails: {
|
||||
subject: v.subject,
|
||||
publicKey: v.publicKey,
|
||||
privateKey: v.privateKey,
|
||||
},
|
||||
});
|
||||
return { success: true, statusCode: 201 };
|
||||
} catch (err) {
|
||||
return { success: false, statusCode: (err as { statusCode?: number }).statusCode ?? 500 };
|
||||
}
|
||||
},
|
||||
ttl: 3600,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PushChannel(config: PushChannelConfig)` — constructor.
|
||||
- `PushChannelConfig` — `{ vapid, getSubscriptions, sendFn?, fetchFn?, ttl?, clock? }`.
|
||||
- `VapidKeys`, `PushSubscription`, `PushPayload`, `PushAction`, `PushSendOutcome` — re-exported types.
|
||||
- `canSend(n)`, `send(n)` — `DeliveryChannel` impl.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Promise.allSettled` is used to fan out so a single bad subscription does not derail the rest. Errors collected from rejected promises are concatenated into the failure message when ALL subscriptions fail. The default fetch path injects `Content-Type`, `Content-Encoding: aes128gcm`, `TTL`, and `Urgency: normal` headers, plus a `vapid t=<pub>, k=<priv>` Authorization header when `vapid.subject` is non-empty. The default path is the simplest correct shape, but production callers SHOULD inject `sendFn` to get proper payload encryption — direct POST without payload encryption is only useful for plain-text push or tests.
|
||||
@@ -0,0 +1,366 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeFetch } from "../fakes/fake-fetch.js";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import { PushChannel } from "./push-channel.js";
|
||||
import type { PushSubscription, VapidKeys } from "./push-channel.types.js";
|
||||
|
||||
const vapid: VapidKeys = {
|
||||
publicKey: "BPubKey123",
|
||||
privateKey: "PrivKey456",
|
||||
subject: "mailto:admin@example.com",
|
||||
};
|
||||
|
||||
function makeSub(endpoint = "https://push.example.com/sub1"): PushSubscription {
|
||||
return {
|
||||
endpoint,
|
||||
keys: { p256dh: "p256dh-key", auth: "auth-key" },
|
||||
};
|
||||
}
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: "notif-1",
|
||||
channel: "push",
|
||||
recipient: "user-1",
|
||||
subject: "Test Push",
|
||||
body: "Hello push",
|
||||
priority: "normal",
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PushChannel", () => {
|
||||
it("canSend returns true for push channel notifications", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
});
|
||||
expect(channel.canSend(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns false for non-push channel notifications", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
});
|
||||
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("canSend returns false when recipient is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
});
|
||||
expect(channel.canSend(makeNotification({ recipient: "" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("delivers to a single subscription successfully", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sent: { sub: PushSubscription; payload: string }[] = [];
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
sendFn: async (sub, payload) => {
|
||||
sent.push({ sub, payload });
|
||||
return { success: true, statusCode: 201 };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe("push");
|
||||
expect(result.messageId).toContain("push-notif-1");
|
||||
expect(sent).toHaveLength(1);
|
||||
|
||||
const parsed = JSON.parse(sent[0]?.payload ?? "{}");
|
||||
expect(parsed.title).toBe("Test Push");
|
||||
expect(parsed.body).toBe("Hello push");
|
||||
expect(parsed.tag).toBe("notif-1");
|
||||
});
|
||||
|
||||
it("delivers to multiple subscriptions", { timeout: 5000 }, async () => {
|
||||
let sendCount = 0;
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [
|
||||
makeSub("https://push.example.com/sub1"),
|
||||
makeSub("https://push.example.com/sub2"),
|
||||
makeSub("https://push.example.com/sub3"),
|
||||
],
|
||||
sendFn: async () => {
|
||||
sendCount++;
|
||||
return { success: true, statusCode: 201 };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toContain("3of3");
|
||||
expect(sendCount).toBe(3);
|
||||
});
|
||||
|
||||
it("reports partial success correctly", { timeout: 5000 }, async () => {
|
||||
let callIndex = 0;
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [
|
||||
makeSub("https://push.example.com/sub1"),
|
||||
makeSub("https://push.example.com/sub2"),
|
||||
],
|
||||
sendFn: async () => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) return { success: true, statusCode: 201 };
|
||||
return { success: false, statusCode: 410 };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toContain("1of2");
|
||||
});
|
||||
|
||||
it("returns failure when all subscriptions fail with HTTP errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [
|
||||
makeSub(),
|
||||
makeSub("https://push.example.com/sub2"),
|
||||
],
|
||||
sendFn: async () => ({ success: false, statusCode: 500 }),
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("All 2 subscriptions failed");
|
||||
expect(result.error).toContain("HTTP 500");
|
||||
});
|
||||
|
||||
it("returns failure when no subscriptions found", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [],
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("No push subscriptions");
|
||||
});
|
||||
|
||||
it("handles rejected promises in subscriptions", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let callIndex = 0;
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [
|
||||
makeSub(),
|
||||
makeSub("https://push.example.com/sub2"),
|
||||
],
|
||||
sendFn: async () => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) throw new Error("Network timeout");
|
||||
return { success: true, statusCode: 201 };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toContain("1of2");
|
||||
});
|
||||
|
||||
it("handles all subscriptions throwing errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
sendFn: async () => {
|
||||
throw new Error("Connection refused");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Connection refused");
|
||||
});
|
||||
|
||||
it("handles non-Error rejection with default message", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
sendFn: async () => {
|
||||
const literal: unknown = { foo: "bar" };
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Unknown error");
|
||||
});
|
||||
|
||||
it("handles getSubscriptions throwing", { timeout: 5000 }, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => {
|
||||
throw new Error("DB unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("DB unavailable");
|
||||
});
|
||||
|
||||
it("handles getSubscriptions throwing non-Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => {
|
||||
const literal: unknown = 7;
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("7");
|
||||
});
|
||||
|
||||
it("uses default subject when none provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let capturedPayload = "";
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
sendFn: async (_sub, payload) => {
|
||||
capturedPayload = payload;
|
||||
return { success: true, statusCode: 201 };
|
||||
},
|
||||
});
|
||||
|
||||
const { subject: _drop, ...rest } = makeNotification();
|
||||
await channel.send(rest as Notification);
|
||||
|
||||
const parsed = JSON.parse(capturedPayload);
|
||||
expect(parsed.title).toBe("Notification");
|
||||
});
|
||||
|
||||
it("includes metadata in payload data field", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let capturedPayload = "";
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
sendFn: async (_sub, payload) => {
|
||||
capturedPayload = payload;
|
||||
return { success: true, statusCode: 201 };
|
||||
},
|
||||
});
|
||||
|
||||
await channel.send(
|
||||
makeNotification({
|
||||
metadata: { action: "open", url: "/dashboard" },
|
||||
})
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(capturedPayload);
|
||||
expect(parsed.data).toEqual({ action: "open", url: "/dashboard" });
|
||||
});
|
||||
|
||||
it("respects custom TTL config", { timeout: 5000 }, () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [],
|
||||
ttl: 3600,
|
||||
});
|
||||
expect(channel.name).toBe("push");
|
||||
});
|
||||
|
||||
it("uses default fetchFn against VAPID endpoint when sendFn omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 201 });
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
fetchFn: fake.fetch,
|
||||
});
|
||||
const r = await channel.send(makeNotification());
|
||||
expect(r.success).toBe(true);
|
||||
const first = fake.calls[0];
|
||||
expect(first?.url).toBe("https://push.example.com/sub1");
|
||||
const headers = (first?.init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers["Authorization"]).toContain("vapid");
|
||||
});
|
||||
|
||||
it("omits Authorization header when vapid.subject is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 201 });
|
||||
const channel = new PushChannel({
|
||||
vapid: { publicKey: "p", privateKey: "k", subject: "" },
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
fetchFn: fake.fetch,
|
||||
});
|
||||
await channel.send(makeNotification());
|
||||
const headers = (fake.calls[0]?.init?.headers ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(headers["Authorization"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("default fetchFn reports HTTP non-2xx as failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 410 });
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
fetchFn: fake.fetch,
|
||||
});
|
||||
const r = await channel.send(makeNotification());
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("HTTP 410");
|
||||
});
|
||||
|
||||
it('has name "push"', { timeout: 5000 }, () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [],
|
||||
});
|
||||
expect(channel.name).toBe("push");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Clock, FetchLike } from "@suaveplan/types";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
|
||||
import type {
|
||||
PushChannelConfig,
|
||||
PushPayload,
|
||||
PushSendOutcome,
|
||||
PushSubscription,
|
||||
VapidKeys,
|
||||
} from "./push-channel.types.js";
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
async function defaultSendPush(
|
||||
subscription: PushSubscription,
|
||||
payload: string,
|
||||
vapid: VapidKeys,
|
||||
ttl: number,
|
||||
fetchFn: FetchLike
|
||||
): Promise<PushSendOutcome> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "aes128gcm",
|
||||
TTL: String(ttl),
|
||||
Urgency: "normal",
|
||||
};
|
||||
|
||||
if (vapid.subject) {
|
||||
headers["Authorization"] =
|
||||
`vapid t=${vapid.publicKey}, k=${vapid.privateKey}`;
|
||||
}
|
||||
|
||||
const response = await fetchFn(subscription.endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: payload,
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.status >= 200 && response.status < 300,
|
||||
statusCode: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers notifications via the Web Push Protocol (RFC 8030). The
|
||||
* channel is intentionally provider-neutral: the `sendFn` callback OR
|
||||
* the injected `fetchFn` performs the VAPID-authenticated POST against
|
||||
* each subscription's endpoint, and the channel aggregates partial
|
||||
* success/failure into a single `DeliveryResult`.
|
||||
*/
|
||||
export class PushChannel implements DeliveryChannel {
|
||||
public readonly name = "push";
|
||||
private readonly config: PushChannelConfig;
|
||||
private readonly ttl: number;
|
||||
private readonly clock: Clock;
|
||||
private readonly fetchFn: FetchLike;
|
||||
|
||||
public constructor(config: PushChannelConfig) {
|
||||
this.config = config;
|
||||
this.ttl = config.ttl ?? 86_400;
|
||||
this.clock = config.clock ?? defaultClock;
|
||||
this.fetchFn = config.fetchFn ?? globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return notification.channel === "push" && notification.recipient !== "";
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
try {
|
||||
const subscriptions = await this.config.getSubscriptions(
|
||||
notification.recipient
|
||||
);
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return this.fail(
|
||||
"No push subscriptions found for recipient",
|
||||
notification
|
||||
);
|
||||
}
|
||||
|
||||
const payload = this.buildPayload(notification);
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map((sub) =>
|
||||
this.sendToSubscription(sub, payloadStr)
|
||||
)
|
||||
);
|
||||
|
||||
const successes = results.filter(
|
||||
(r) => r.status === "fulfilled" && r.value.success
|
||||
);
|
||||
|
||||
if (successes.length === 0) {
|
||||
const errors = results
|
||||
.map((r) => {
|
||||
if (r.status === "rejected") {
|
||||
const cause = r.reason as { message?: string };
|
||||
return cause.message ?? "Unknown error";
|
||||
}
|
||||
return `HTTP ${r.value.statusCode}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return this.fail(
|
||||
`All ${subscriptions.length} subscriptions failed: ${errors.join(", ")}`,
|
||||
notification
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: `push-${notification.id}-${successes.length}of${subscriptions.length}`,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return this.fail(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
notification
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private fail(error: string, _notification: Notification): DeliveryResult {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private buildPayload(notification: Notification): PushPayload {
|
||||
const payload: {
|
||||
-readonly [K in keyof PushPayload]: PushPayload[K];
|
||||
} = {
|
||||
title: notification.subject ?? "Notification",
|
||||
body: notification.body,
|
||||
tag: notification.id,
|
||||
};
|
||||
if (notification.metadata !== undefined) {
|
||||
payload.data = notification.metadata;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async sendToSubscription(
|
||||
subscription: PushSubscription,
|
||||
payload: string
|
||||
): Promise<PushSendOutcome> {
|
||||
if (this.config.sendFn !== undefined) {
|
||||
return this.config.sendFn(subscription, payload, this.config.vapid);
|
||||
}
|
||||
return defaultSendPush(
|
||||
subscription,
|
||||
payload,
|
||||
this.config.vapid,
|
||||
this.ttl,
|
||||
this.fetchFn
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Clock, FetchLike } from "@suaveplan/types";
|
||||
|
||||
/** VAPID key pair for Web Push authentication. */
|
||||
export interface VapidKeys {
|
||||
readonly publicKey: string;
|
||||
readonly privateKey: string;
|
||||
readonly subject: string;
|
||||
}
|
||||
|
||||
/** A web push subscription (from the browser's PushManager). */
|
||||
export interface PushSubscription {
|
||||
readonly endpoint: string;
|
||||
readonly keys: {
|
||||
readonly p256dh: string;
|
||||
readonly auth: string;
|
||||
};
|
||||
readonly expirationTime?: number | null;
|
||||
}
|
||||
|
||||
/** Action button for push notifications. */
|
||||
export interface PushAction {
|
||||
readonly action: string;
|
||||
readonly title: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
/** Push notification payload. */
|
||||
export interface PushPayload {
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly icon?: string;
|
||||
readonly badge?: string;
|
||||
readonly image?: string;
|
||||
readonly tag?: string;
|
||||
readonly data?: Record<string, unknown>;
|
||||
readonly actions?: readonly PushAction[];
|
||||
readonly requireInteraction?: boolean;
|
||||
readonly silent?: boolean;
|
||||
}
|
||||
|
||||
/** Outcome returned by the optional injected `sendFn`. */
|
||||
export interface PushSendOutcome {
|
||||
readonly success: boolean;
|
||||
readonly statusCode: number;
|
||||
}
|
||||
|
||||
/** Configuration for the push channel. */
|
||||
export interface PushChannelConfig {
|
||||
/** VAPID keys for authentication. */
|
||||
readonly vapid: VapidKeys;
|
||||
/** Function to look up push subscriptions for a recipient. */
|
||||
readonly getSubscriptions: (
|
||||
recipient: string
|
||||
) => Promise<readonly PushSubscription[]>;
|
||||
/** Optional function to send the actual push message. */
|
||||
readonly sendFn?: (
|
||||
subscription: PushSubscription,
|
||||
payload: string,
|
||||
vapid: VapidKeys
|
||||
) => Promise<PushSendOutcome>;
|
||||
/** Optional fetch implementation used when `sendFn` is omitted. */
|
||||
readonly fetchFn?: FetchLike;
|
||||
/** TTL in seconds for push messages (default: 86400). */
|
||||
readonly ttl?: number;
|
||||
/** Optional clock for the `timestamp` field. */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# `sms-channel` — SMS delivery channel
|
||||
|
||||
## Purpose
|
||||
|
||||
`sms-channel.ts` ships the `SmsChannel` `DeliveryChannel` implementation. Like the email channel, it is provider-neutral: a caller-supplied `sendFn(message)` performs the actual API call into Twilio, MessageBird, Vonage, Sinch, or any in-house SMSC. The channel keeps the responsibility surface tiny — recipient validation (E.164 `+` prefix), payload shaping, and error swallowing — so the router can stay decoupled from any specific carrier.
|
||||
|
||||
## Features
|
||||
|
||||
- Provider-neutral via injectable `sendFn`.
|
||||
- `canSend` rejects recipients that do not start with `+` (E.164 prefix); avoids hitting upstream providers with formats they will reject.
|
||||
- Propagates `metadata` to the provider message for correlation IDs and segmentation.
|
||||
- Injectable `Clock` for deterministic `timestamp` fields in tests.
|
||||
- Returns `DeliveryResult` even on provider failure — never throws to the router.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SmsChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async (msg) => ({
|
||||
messageId: await twilio.messages.create({
|
||||
from: "+15559999999",
|
||||
to: msg.to,
|
||||
body: msg.body,
|
||||
}).sid,
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SmsChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async (msg) => {
|
||||
const { sid } = await provider.send({
|
||||
from: "+15559999999",
|
||||
to: msg.to,
|
||||
body: msg.body,
|
||||
...(msg.metadata?.["statusCallback"] !== undefined
|
||||
? { statusCallback: msg.metadata["statusCallback"] }
|
||||
: {}),
|
||||
});
|
||||
return { messageId: sid };
|
||||
},
|
||||
clock: () => testNow,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SmsChannel(config: SmsChannelConfig)` — constructor.
|
||||
- `SmsChannelConfig` — `{ from, sendFn, clock? }`.
|
||||
- `SmsMessage` — `{ to, body, metadata? }` passed to `sendFn`.
|
||||
- `canSend(n): boolean`, `send(n): Promise<DeliveryResult>` — `DeliveryChannel` impl.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `+` prefix check is intentionally conservative — it does not validate the full E.164 country/area format because providers do that anyway and over-validating client-side breaks edge cases like short-code dispatch. Tests cover both `Error` and non-`Error` throws inside `sendFn` to lock in the `String(err)` coercion. The injected `clock` only affects the `DeliveryResult.timestamp` field; provider-recorded send time remains authoritative.
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import { SmsChannel } from "./sms-channel.js";
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: "notif_1",
|
||||
channel: "sms",
|
||||
recipient: "+15551234567",
|
||||
body: "Test SMS body",
|
||||
priority: "normal",
|
||||
status: "sending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SmsChannel", () => {
|
||||
it('has name "sms"', { timeout: 5000 }, () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_1" }),
|
||||
});
|
||||
expect(channel.name).toBe("sms");
|
||||
});
|
||||
|
||||
it("canSend returns true for sms channel with + recipient", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_1" }),
|
||||
});
|
||||
expect(channel.canSend(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns false for non-sms channel", { timeout: 5000 }, () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_1" }),
|
||||
});
|
||||
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("canSend returns false for recipient without +", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_1" }),
|
||||
});
|
||||
expect(
|
||||
channel.canSend(makeNotification({ recipient: "5551234567" }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("sends successfully and returns result", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_42" }),
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe("sms");
|
||||
expect(result.messageId).toBe("sms_42");
|
||||
expect(result.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns failure when sendFn throws", { timeout: 30000 }, async () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => {
|
||||
throw new Error("SMS provider down");
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("SMS provider down");
|
||||
});
|
||||
|
||||
it("passes body and metadata to sendFn", { timeout: 30000 }, async () => {
|
||||
let capturedBody = "";
|
||||
let capturedMeta: Record<string, unknown> | undefined;
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async (msg) => {
|
||||
capturedBody = msg.body;
|
||||
capturedMeta = msg.metadata;
|
||||
return { messageId: "sms_1" };
|
||||
},
|
||||
});
|
||||
await channel.send(
|
||||
makeNotification({ body: "Hello SMS", metadata: { tag: "urgent" } })
|
||||
);
|
||||
expect(capturedBody).toBe("Hello SMS");
|
||||
expect(capturedMeta).toEqual({ tag: "urgent" });
|
||||
});
|
||||
|
||||
it("handles non-Error throw in sendFn", { timeout: 30000 }, async () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => {
|
||||
const literal: unknown = 42;
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("42");
|
||||
});
|
||||
|
||||
it("honours injected clock", { timeout: 5000 }, async () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_1" }),
|
||||
clock: () => Date.UTC(2026, 0, 1),
|
||||
});
|
||||
const r = await channel.send(makeNotification());
|
||||
expect(r.timestamp).toBe("2026-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
|
||||
|
||||
/** SMS message passed to the send function. */
|
||||
export interface SmsMessage {
|
||||
readonly to: string;
|
||||
readonly body: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Configuration for the SMS channel. */
|
||||
export interface SmsChannelConfig {
|
||||
readonly from: string;
|
||||
readonly sendFn: (message: SmsMessage) => Promise<{ messageId: string }>;
|
||||
/** Optional clock for `timestamp` field (default: `Date.now`). */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Delivers notifications via SMS using a configurable send function.
|
||||
* The `sendFn` is the integration seam — wire Twilio / MessageBird /
|
||||
* Vonage by implementing the callback signature.
|
||||
*/
|
||||
export class SmsChannel implements DeliveryChannel {
|
||||
public readonly name = "sms";
|
||||
private readonly config: SmsChannelConfig;
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(config: SmsChannelConfig) {
|
||||
this.config = config;
|
||||
this.clock = config.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return (
|
||||
notification.channel === "sms" &&
|
||||
notification.recipient.startsWith("+")
|
||||
);
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
const message: SmsMessage = {
|
||||
to: notification.recipient,
|
||||
body: notification.body,
|
||||
...(notification.metadata !== undefined
|
||||
? { metadata: notification.metadata }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.config.sendFn(message);
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: result.messageId,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# `webhook-channel` — HTTP webhook delivery channel
|
||||
|
||||
## Purpose
|
||||
|
||||
`webhook-channel.ts` ships the `WebhookChannel` implementation that POSTs notifications to an arbitrary HTTPS endpoint. The notification's `recipient` field IS the destination URL, the body carries the serialised notification payload, and the channel respects a per-request timeout via `AbortController`. The fetch surface is injectable via `FetchLike` from `@suaveplan/types`, so tests run deterministically against `FakeFetch` and downstream consumers can plug their own observability-instrumented fetch.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `FetchLike` — no `globalThis.fetch` patching.
|
||||
- Configurable timeout (default 10_000 ms) using `AbortController`.
|
||||
- Custom headers via `WebhookChannelConfig.headers`.
|
||||
- `canSend` enforces `http://` or `https://` recipient prefix.
|
||||
- Returns `DeliveryResult { success: false }` on non-2xx, network error, abort, or unexpected throw.
|
||||
- Injectable `Clock` for deterministic `timestamp` in tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { WebhookChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const channel = new WebhookChannel({
|
||||
headers: { Authorization: "Bearer secret" },
|
||||
});
|
||||
const r = await channel.send(notification);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeFetch, WebhookChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
// In tests:
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
const channel = new WebhookChannel({
|
||||
fetchFn: fake.fetch,
|
||||
timeoutMs: 500,
|
||||
clock: () => testNow,
|
||||
});
|
||||
|
||||
// In prod, wire a real instrumented fetch:
|
||||
const channel2 = new WebhookChannel({
|
||||
fetchFn: (url, init) => instrumented.fetch(url, init),
|
||||
headers: { "X-Signed-By": "suaveplan" },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WebhookChannel(config?: WebhookChannelConfig)` — constructor.
|
||||
- `WebhookChannelConfig` — `{ headers?, fetchFn?, timeoutMs?, clock? }`.
|
||||
- `canSend(n): boolean`, `send(n): Promise<DeliveryResult>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The POST body is built from `{ id, subject, body, priority, metadata, createdAt }` — a stable shape so webhook consumers can lock their parsers. `Content-Type: application/json` is always set; consumer headers override the default. Aborted fetches surface `error: "aborted"` (matching the `AbortError.name`-derived message from the polyfill). Non-2xx responses encode the upstream status into `error: "HTTP <status>: <statusText>"`. Both `Error` and non-`Error` throws inside the fetch callback are stringified into `error` so the `DeliveryResult` contract holds.
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeFetch } from "../fakes/fake-fetch.js";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import { WebhookChannel } from "./webhook-channel.js";
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: "notif_1",
|
||||
channel: "webhook",
|
||||
recipient: "https://hooks.example.com/callback",
|
||||
subject: "Test Hook",
|
||||
body: "Webhook payload",
|
||||
priority: "normal",
|
||||
status: "sending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WebhookChannel", () => {
|
||||
it('has name "webhook"', { timeout: 5000 }, () => {
|
||||
const channel = new WebhookChannel();
|
||||
expect(channel.name).toBe("webhook");
|
||||
});
|
||||
|
||||
it("canSend returns true for webhook channel with https URL", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new WebhookChannel();
|
||||
expect(channel.canSend(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns true for http URL", { timeout: 5000 }, () => {
|
||||
const channel = new WebhookChannel();
|
||||
expect(
|
||||
channel.canSend(
|
||||
makeNotification({ recipient: "http://hooks.example.com/cb" })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns false for non-webhook channel", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new WebhookChannel();
|
||||
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("canSend returns false for non-URL recipient", { timeout: 5000 }, () => {
|
||||
const channel = new WebhookChannel();
|
||||
expect(
|
||||
channel.canSend(makeNotification({ recipient: "not-a-url" }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("sends successfully with 200 response", { timeout: 30000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
const channel = new WebhookChannel({ fetchFn: fake.fetch });
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe("webhook");
|
||||
expect(result.messageId).toBe("notif_1");
|
||||
const first = fake.calls[0];
|
||||
expect(first?.url).toBe("https://hooks.example.com/callback");
|
||||
});
|
||||
|
||||
it("returns failure for non-ok response", { timeout: 30000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 500 });
|
||||
const channel = new WebhookChannel({ fetchFn: fake.fetch });
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("500");
|
||||
});
|
||||
|
||||
it("returns failure when fetch throws", { timeout: 30000 }, async () => {
|
||||
const channel = new WebhookChannel({
|
||||
fetchFn: async () => {
|
||||
throw new Error("Network error");
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("Network error");
|
||||
});
|
||||
|
||||
it("sends correct payload in POST body", { timeout: 30000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
const channel = new WebhookChannel({ fetchFn: fake.fetch });
|
||||
await channel.send(makeNotification());
|
||||
const init = fake.calls[0]?.init;
|
||||
const body = init?.body;
|
||||
expect(typeof body).toBe("string");
|
||||
const parsed = JSON.parse(body as string);
|
||||
expect(parsed.id).toBe("notif_1");
|
||||
expect(parsed.body).toBe("Webhook payload");
|
||||
});
|
||||
|
||||
it("includes custom headers", { timeout: 30000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
const channel = new WebhookChannel({
|
||||
headers: { Authorization: "Bearer token123" },
|
||||
fetchFn: fake.fetch,
|
||||
});
|
||||
await channel.send(makeNotification());
|
||||
const init = fake.calls[0]?.init;
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toBe("Bearer token123");
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("handles non-Error throw", { timeout: 30000 }, async () => {
|
||||
const channel = new WebhookChannel({
|
||||
fetchFn: async () => {
|
||||
const literal: unknown = "fetch failed";
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("fetch failed");
|
||||
});
|
||||
|
||||
it("honours injected clock for timestamp", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
const channel = new WebhookChannel({
|
||||
fetchFn: fake.fetch,
|
||||
clock: () => Date.UTC(2026, 0, 1),
|
||||
});
|
||||
const r = await channel.send(makeNotification());
|
||||
expect(r.timestamp).toBe("2026-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("respects custom timeout via AbortController", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const channel = new WebhookChannel({
|
||||
fetchFn: fake.fetch,
|
||||
timeoutMs: 5,
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("aborted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Clock, FetchLike } from "@suaveplan/types";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
|
||||
|
||||
/** Configuration for the webhook channel. */
|
||||
export interface WebhookChannelConfig {
|
||||
/** Default headers added to every request. */
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
/** Optional `FetchLike` implementation (default: `globalThis.fetch`). */
|
||||
readonly fetchFn?: FetchLike;
|
||||
/** Timeout in milliseconds (default: 10_000). */
|
||||
readonly timeoutMs?: number;
|
||||
/** Optional clock for the `timestamp` field (default: `Date.now`). */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Delivers notifications via HTTP webhook POST requests. The
|
||||
* notification's `recipient` field is the destination URL; the body
|
||||
* contains the serialised notification payload.
|
||||
*/
|
||||
export class WebhookChannel implements DeliveryChannel {
|
||||
public readonly name = "webhook";
|
||||
private readonly config: WebhookChannelConfig;
|
||||
private readonly clock: Clock;
|
||||
private readonly fetchFn: FetchLike;
|
||||
|
||||
public constructor(config?: WebhookChannelConfig) {
|
||||
this.config = config ?? {};
|
||||
this.clock = this.config.clock ?? defaultClock;
|
||||
this.fetchFn = this.config.fetchFn ?? globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return (
|
||||
notification.channel === "webhook" &&
|
||||
(notification.recipient.startsWith("http://") ||
|
||||
notification.recipient.startsWith("https://"))
|
||||
);
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
const timeoutMs = this.config.timeoutMs ?? 10_000;
|
||||
|
||||
const payload = {
|
||||
id: notification.id,
|
||||
subject: notification.subject,
|
||||
body: notification.body,
|
||||
priority: notification.priority,
|
||||
metadata: notification.metadata,
|
||||
createdAt: notification.createdAt,
|
||||
};
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const response = await this.fetchFn(notification.recipient, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.config.headers,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: notification.id,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# `digest-channel` — Digest delivery channel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries delivered through a delegate `DeliveryChannel`. It is the channel-shape adapter for "weekly summary email" / "hourly Slack roll-up" / "daily push digest" use-cases — it implements `DeliveryChannel` so it slots into the router exactly like a direct channel, and it owns its own per-recipient queue.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps any `DeliveryChannel` as `delegate`.
|
||||
- Per-recipient FIFO queue with configurable `maxPerDigest` cap.
|
||||
- `intervalMs` gates the next flush — `send` opportunistically flushes when the interval has elapsed since the last flush.
|
||||
- Customisable `formatDigest(notifications)` formatter.
|
||||
- `flush(recipient)`, `flushAll()`, `queueSize`, `pendingRecipients` for ops/cron integrations.
|
||||
- Injectable `Clock` so tests advance the interval without sleeping.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DigestChannel, EmailChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const email = new EmailChannel({ from: "noreply@x", sendFn });
|
||||
const digest = new DigestChannel({
|
||||
delegate: email,
|
||||
intervalMs: 3_600_000,
|
||||
});
|
||||
await digest.send(notification);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const digest = new DigestChannel({
|
||||
delegate: email,
|
||||
intervalMs: 86_400_000,
|
||||
maxPerDigest: 25,
|
||||
formatDigest: (ns) =>
|
||||
ns.map((n, i) => `${i + 1}. [${n.priority}] ${n.subject}: ${n.body}`).join("\n"),
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
|
||||
// In a cron handler:
|
||||
const results = await digest.flushAll();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DigestChannel(config: DigestChannelConfig)` — constructor.
|
||||
- `DigestChannelConfig` — `{ delegate, intervalMs?, maxPerDigest?, formatDigest?, clock? }`.
|
||||
- `enqueue(n)`, `send(n)`, `flush(recipient)`, `flushAll()`, `queueSize(recipient)`, `pendingRecipients()`, `canSend(n)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`enqueue` silently drops the overflow when the queue reaches `maxPerDigest`. This is a deliberate denial-of-service shield — a runaway pipeline cannot blow memory by enqueuing forever. The default formatter numbers entries and labels them by subject; consumers wanting Markdown / HTML output supply their own `formatDigest`. `canSend` proxies to the delegate, so the digest channel inherits the delegate's recipient validation — useful when the delegate is an `EmailChannel` that wants `@`-only recipients. `lastFlush` is tracked per-recipient and consulted from `send`/`flushAll`; a recipient flushed seconds ago will not flush again until the interval has elapsed.
|
||||
@@ -0,0 +1,352 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
} from "../channels/delivery-channel.js";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import { DigestChannel } from "./digest-channel.js";
|
||||
|
||||
let counter = 0;
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
counter++;
|
||||
return {
|
||||
id: `notif-${counter}`,
|
||||
channel: "email",
|
||||
recipient: "user@example.com",
|
||||
subject: "Test",
|
||||
body: "Hello",
|
||||
priority: "normal",
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockDelegate(): DeliveryChannel & {
|
||||
sentNotifications: Notification[];
|
||||
} {
|
||||
const sentNotifications: Notification[] = [];
|
||||
return {
|
||||
name: "mock-email",
|
||||
sentNotifications,
|
||||
canSend: () => true,
|
||||
send: async (notification: Notification): Promise<DeliveryResult> => {
|
||||
sentNotifications.push(notification);
|
||||
return {
|
||||
success: true,
|
||||
channel: "mock-email",
|
||||
messageId: `msg-${notification.id}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("DigestChannel", () => {
|
||||
describe("enqueue", () => {
|
||||
it("queues notifications for a recipient", { timeout: 5000 }, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
|
||||
expect(digest.queueSize("alice@test.com")).toBe(2);
|
||||
});
|
||||
|
||||
it("queues notifications for different recipients separately", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
digest.enqueue(makeNotification({ recipient: "bob@test.com" }));
|
||||
|
||||
expect(digest.queueSize("alice@test.com")).toBe(1);
|
||||
expect(digest.queueSize("bob@test.com")).toBe(1);
|
||||
});
|
||||
|
||||
it("respects maxPerDigest limit", { timeout: 5000 }, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate, maxPerDigest: 3 });
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
digest.enqueue(
|
||||
makeNotification({ recipient: "alice@test.com" })
|
||||
);
|
||||
}
|
||||
|
||||
expect(digest.queueSize("alice@test.com")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flush", () => {
|
||||
it("delivers a single digest via delegate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(
|
||||
makeNotification({ recipient: "alice@test.com", body: "First" })
|
||||
);
|
||||
digest.enqueue(
|
||||
makeNotification({
|
||||
recipient: "alice@test.com",
|
||||
body: "Second",
|
||||
})
|
||||
);
|
||||
|
||||
const result = await digest.flush("alice@test.com");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(delegate.sentNotifications).toHaveLength(1);
|
||||
|
||||
const sent = delegate.sentNotifications[0];
|
||||
expect(sent?.subject).toContain("2 notifications");
|
||||
expect(sent?.body).toContain("First");
|
||||
expect(sent?.body).toContain("Second");
|
||||
expect(sent?.recipient).toBe("alice@test.com");
|
||||
});
|
||||
|
||||
it("clears the queue after flushing", { timeout: 5000 }, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
expect(digest.queueSize("alice@test.com")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty result for empty queue", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
const result = await digest.flush("nobody@test.com");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe("digest-empty");
|
||||
expect(delegate.sentNotifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses singular form for single notification", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
const sent = delegate.sentNotifications[0];
|
||||
expect(sent?.subject).toBe("Digest: 1 notification");
|
||||
});
|
||||
|
||||
it("uses custom formatDigest function", { timeout: 5000 }, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({
|
||||
delegate,
|
||||
formatDigest: (notifs) =>
|
||||
notifs.map((n) => `* ${n.body}`).join("\n"),
|
||||
});
|
||||
|
||||
digest.enqueue(
|
||||
makeNotification({ recipient: "alice@test.com", body: "A" })
|
||||
);
|
||||
digest.enqueue(
|
||||
makeNotification({ recipient: "alice@test.com", body: "B" })
|
||||
);
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
const sent = delegate.sentNotifications[0];
|
||||
expect(sent?.body).toBe("* A\n* B");
|
||||
});
|
||||
|
||||
it("uses default format with numbered list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(
|
||||
makeNotification({
|
||||
recipient: "alice@test.com",
|
||||
subject: "Alert",
|
||||
body: "msg1",
|
||||
})
|
||||
);
|
||||
digest.enqueue(
|
||||
makeNotification({
|
||||
recipient: "alice@test.com",
|
||||
subject: "Alert",
|
||||
body: "msg2",
|
||||
})
|
||||
);
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
const sent = delegate.sentNotifications[0];
|
||||
expect(sent?.body).toContain("1. Alert: msg1");
|
||||
expect(sent?.body).toContain("2. Alert: msg2");
|
||||
});
|
||||
|
||||
it("default format uses 'Notification' label when subject missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
const { subject: _drop, ...rest } = makeNotification({
|
||||
recipient: "alice@test.com",
|
||||
body: "body-only",
|
||||
});
|
||||
digest.enqueue(rest as Notification);
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
const sent = delegate.sentNotifications[0];
|
||||
expect(sent?.body).toContain("1. Notification: body-only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("send", () => {
|
||||
it("enqueues and flushes when interval has elapsed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate, intervalMs: 0 });
|
||||
|
||||
const result = await digest.send(
|
||||
makeNotification({ recipient: "alice@test.com" })
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(delegate.sentNotifications).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("enqueues without flushing when interval has not passed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
// Use a clock so we can simulate the interval boundary.
|
||||
let now = 1_000_000;
|
||||
const digest = new DigestChannel({
|
||||
delegate,
|
||||
intervalMs: 3_600_000,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await digest.send(
|
||||
makeNotification({ recipient: "alice@test.com" })
|
||||
);
|
||||
// First send triggers a flush (no previous flush). Advance just
|
||||
// 1 ms; interval has NOT elapsed, second send must remain queued.
|
||||
now += 1;
|
||||
const result = await digest.send(
|
||||
makeNotification({ recipient: "alice@test.com" })
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toContain("digest-queued");
|
||||
});
|
||||
});
|
||||
|
||||
describe("flushAll", () => {
|
||||
it("flushes all recipients that are ready", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate, intervalMs: 0 });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
digest.enqueue(makeNotification({ recipient: "bob@test.com" }));
|
||||
|
||||
const results = await digest.flushAll();
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((r) => r.success)).toBe(true);
|
||||
expect(delegate.sentNotifications).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips recipients not ready for flush", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
let now = 1_000_000;
|
||||
const digest = new DigestChannel({
|
||||
delegate,
|
||||
intervalMs: 3_600_000,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
// Force a flush to set lastFlush time
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
// Enqueue more
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
now += 1; // not yet at interval
|
||||
|
||||
const results = await digest.flushAll();
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pendingRecipients", () => {
|
||||
it("lists recipients with pending notifications", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
digest.enqueue(makeNotification({ recipient: "bob@test.com" }));
|
||||
|
||||
const pending = digest.pendingRecipients();
|
||||
|
||||
expect(pending).toContain("alice@test.com");
|
||||
expect(pending).toContain("bob@test.com");
|
||||
expect(pending).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array when no pending", { timeout: 5000 }, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
expect(digest.pendingRecipients()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queueSize", () => {
|
||||
it("returns 0 for unknown recipient", { timeout: 5000 }, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
expect(digest.queueSize("unknown@test.com")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canSend", () => {
|
||||
it("delegates canSend to underlying channel", { timeout: 5000 }, () => {
|
||||
const delegate: DeliveryChannel = {
|
||||
name: "selective",
|
||||
canSend: (n) => n.priority === "urgent",
|
||||
send: async () => ({
|
||||
success: true,
|
||||
channel: "selective",
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
expect(
|
||||
digest.canSend(makeNotification({ priority: "urgent" }))
|
||||
).toBe(true);
|
||||
expect(
|
||||
digest.canSend(makeNotification({ priority: "normal" }))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
} from "../channels/delivery-channel.js";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
|
||||
/** Configuration for the digest channel. */
|
||||
export interface DigestChannelConfig {
|
||||
/** The underlying channel to deliver the digest through. */
|
||||
readonly delegate: DeliveryChannel;
|
||||
/** Digest interval in milliseconds (default: 3_600_000 = 1 hour). */
|
||||
readonly intervalMs?: number;
|
||||
/** Maximum notifications per digest (default: 50). */
|
||||
readonly maxPerDigest?: number;
|
||||
/** Optional custom formatter for the digest body. */
|
||||
readonly formatDigest?: (notifications: readonly Notification[]) => string;
|
||||
/** Optional clock (default: `Date.now`). */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Batches notifications and delivers periodic digests through a
|
||||
* delegate `DeliveryChannel`. Useful for taming notification noise on
|
||||
* low-frequency channels (daily email digests, weekly summaries).
|
||||
*/
|
||||
export class DigestChannel implements DeliveryChannel {
|
||||
public readonly name = "digest";
|
||||
private readonly delegate: DeliveryChannel;
|
||||
private readonly intervalMs: number;
|
||||
private readonly maxPerDigest: number;
|
||||
private readonly formatDigest: (
|
||||
notifications: readonly Notification[]
|
||||
) => string;
|
||||
private readonly clock: Clock;
|
||||
private readonly queues = new Map<string, Notification[]>();
|
||||
private readonly lastFlush = new Map<string, number>();
|
||||
|
||||
public constructor(config: DigestChannelConfig) {
|
||||
this.delegate = config.delegate;
|
||||
this.intervalMs = config.intervalMs ?? 3_600_000;
|
||||
this.maxPerDigest = config.maxPerDigest ?? 50;
|
||||
this.formatDigest = config.formatDigest ?? DigestChannel.defaultFormat;
|
||||
this.clock = config.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return this.delegate.canSend(notification);
|
||||
}
|
||||
|
||||
/** Enqueue a notification for the next digest. */
|
||||
public enqueue(notification: Notification): void {
|
||||
const queue = this.queues.get(notification.recipient) ?? [];
|
||||
if (queue.length < this.maxPerDigest) {
|
||||
queue.push(notification);
|
||||
}
|
||||
this.queues.set(notification.recipient, queue);
|
||||
}
|
||||
|
||||
/** Enqueue + opportunistic flush when interval has elapsed. */
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
this.enqueue(notification);
|
||||
if (this.shouldFlush(notification.recipient)) {
|
||||
return this.flush(notification.recipient);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: `digest-queued-${notification.id}`,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Flush a single recipient's queue through the delegate. */
|
||||
public async flush(recipient: string): Promise<DeliveryResult> {
|
||||
const queue = this.queues.get(recipient);
|
||||
if (queue === undefined || queue.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: "digest-empty",
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const digestBody = this.formatDigest(queue);
|
||||
// queue.length is guaranteed > 0 by the early-return above.
|
||||
const channel = (queue[0] as Notification).channel;
|
||||
const digestNotification: Notification = {
|
||||
id: `digest-${recipient}-${this.clock()}`,
|
||||
channel,
|
||||
recipient,
|
||||
subject: `Digest: ${queue.length} notification${queue.length > 1 ? "s" : ""}`,
|
||||
body: digestBody,
|
||||
priority: "normal",
|
||||
status: "pending",
|
||||
createdAt: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
|
||||
this.queues.delete(recipient);
|
||||
this.lastFlush.set(recipient, this.clock());
|
||||
|
||||
return this.delegate.send(digestNotification);
|
||||
}
|
||||
|
||||
/** Flush every recipient whose interval has elapsed. */
|
||||
public async flushAll(): Promise<DeliveryResult[]> {
|
||||
const results: DeliveryResult[] = [];
|
||||
for (const recipient of this.queues.keys()) {
|
||||
if (this.shouldFlush(recipient)) {
|
||||
results.push(await this.flush(recipient));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Queue size for a given recipient (0 when unknown). */
|
||||
public queueSize(recipient: string): number {
|
||||
return this.queues.get(recipient)?.length ?? 0;
|
||||
}
|
||||
|
||||
/** Recipients with at least one pending notification. */
|
||||
public pendingRecipients(): string[] {
|
||||
// The iteration is over `this.queues.keys()` so every `get(r)` is
|
||||
// guaranteed to return the matching array — the `as Notification[]`
|
||||
// cast keeps `noUncheckedIndexedAccess` happy without a redundant
|
||||
// `?? 0` branch.
|
||||
return [...this.queues.keys()].filter(
|
||||
(r) => (this.queues.get(r) as Notification[]).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
private shouldFlush(recipient: string): boolean {
|
||||
const last = this.lastFlush.get(recipient) ?? 0;
|
||||
return this.clock() - last >= this.intervalMs;
|
||||
}
|
||||
|
||||
private static defaultFormat(
|
||||
notifications: readonly Notification[]
|
||||
): string {
|
||||
return notifications
|
||||
.map(
|
||||
(n, i) => `${i + 1}. ${n.subject ?? "Notification"}: ${n.body}`
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# `error-codes` — Notification-channel error registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw. It registers a stable namespace `NOTIFICATION_CHANNEL` with eleven reason codes via `defineCodes("NOTIFICATION_CHANNEL", […])` from `@suaveplan/error`, and exposes a typed `SuaveplanError` subclass for each reason. The registry preserves the literal-typed union through the public surface so downstream consumers — loggers, problem+json responders, retry wrappers — can `instanceof`-narrow on a specific subclass or match on the `.code` discriminant without unsafe casts. The module is side-effect-imported from `src/index.ts` so the codes are part of the package's public ABI even for consumers that pull a subpath export.
|
||||
|
||||
## Features
|
||||
|
||||
- Eleven stable reason codes: `NOT_FOUND`, `CHANNEL_DISABLED`, `TEMPLATE_NOT_FOUND`, `RATE_LIMIT_EXCEEDED`, `ROUTING_FAILED`, `DELIVERY_FAILED`, `PROVIDER_ERROR`, `RATE_LIMITED`, `INVALID_RECIPIENT`, `CHANNEL_UNAVAILABLE`, `TIMEOUT`.
|
||||
- One typed subclass per code extending an abstract `NotificationChannelError` base.
|
||||
- Compile-time-frozen `NOTIFICATION_CHANNEL_CODES` record whose values are literal `"NOTIFICATION_CHANNEL.REASON"` strings.
|
||||
- Every constructor accepts the standard `SuaveplanErrorOptions` bag (`cause`, `context`, `traceId`, `clock`).
|
||||
- `RateLimitedError` carries an additional `retryAfterMs` field merged into `context`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
NOTIFICATION_CHANNEL_CODES,
|
||||
ChannelDisabledError,
|
||||
type NotificationChannelErrorCode,
|
||||
} from "@suaveplan/notification-channel/error-codes";
|
||||
|
||||
try {
|
||||
/* ... call the router ... */
|
||||
} catch (err) {
|
||||
if (err instanceof ChannelDisabledError) {
|
||||
const code: NotificationChannelErrorCode = err.code;
|
||||
void code; // "NOTIFICATION_CHANNEL.CHANNEL_DISABLED"
|
||||
}
|
||||
}
|
||||
|
||||
void NOTIFICATION_CHANNEL_CODES.RATE_LIMITED;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { RateLimitedError } from "@suaveplan/notification-channel/error-codes";
|
||||
|
||||
throw new RateLimitedError({
|
||||
message: "notification.errors.rateLimited",
|
||||
retryAfterMs: 30_000,
|
||||
context: { provider: "twilio" },
|
||||
traceId: "trace-abc",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NOTIFICATION_CHANNEL_CODES` — frozen record `{ readonly [K in Reason]: "NOTIFICATION_CHANNEL.${K}" }`.
|
||||
- `NotificationChannelErrorCode` — discriminated union of all eleven literal codes.
|
||||
- `NotificationChannelError` — abstract base extending `SuaveplanError<NotificationChannelErrorCode>`.
|
||||
- Concrete subclasses: `NotificationNotFoundError`, `ChannelDisabledError`, `TemplateNotFoundError`, `RateLimitExceededError`, `RoutingFailedError`, `DeliveryFailedError`, `ProviderErrorError`, `RateLimitedError`, `InvalidRecipientError`, `ChannelUnavailableError`, `DeliveryTimeoutError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses `defineCodes` from `@suaveplan/error` rather than a runtime `ErrorCodes` registry. `defineCodes` is purely type-level plus a frozen object, so it has no module-load side effect beyond the freeze, and the literal-typed union survives tree-shaking. `pickOpts` discards undefined fields explicitly to remain compatible with `exactOptionalPropertyTypes: true` in `tsconfig.base.json`. The split between "local lifecycle errors" and "upstream-provider errors" upstream had two parallel registries (`error-codes.ts` and `errors/notification-errors.ts`) — those are unified here so consumers see a single canonical code list. `RateLimitedError` is the one place the constructor accepts a typed sidecar (`retryAfterMs`) — it merges into `context` so downstream HTTP `Retry-After` mapping is straightforward.
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ChannelDisabledError,
|
||||
ChannelUnavailableError,
|
||||
DeliveryFailedError,
|
||||
DeliveryTimeoutError,
|
||||
InvalidRecipientError,
|
||||
NOTIFICATION_CHANNEL_CODES,
|
||||
type NotificationChannelErrorCode,
|
||||
NotificationNotFoundError,
|
||||
ProviderErrorError,
|
||||
RateLimitExceededError,
|
||||
RateLimitedError,
|
||||
RoutingFailedError,
|
||||
TemplateNotFoundError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("NOTIFICATION_CHANNEL_CODES exposes the eleven stable reasons", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.keys(NOTIFICATION_CHANNEL_CODES).sort()).toEqual(
|
||||
[
|
||||
"CHANNEL_DISABLED",
|
||||
"CHANNEL_UNAVAILABLE",
|
||||
"DELIVERY_FAILED",
|
||||
"INVALID_RECIPIENT",
|
||||
"NOT_FOUND",
|
||||
"PROVIDER_ERROR",
|
||||
"RATE_LIMITED",
|
||||
"RATE_LIMIT_EXCEEDED",
|
||||
"ROUTING_FAILED",
|
||||
"TEMPLATE_NOT_FOUND",
|
||||
"TIMEOUT",
|
||||
].sort()
|
||||
);
|
||||
expect(NOTIFICATION_CHANNEL_CODES.NOT_FOUND).toBe(
|
||||
"NOTIFICATION_CHANNEL.NOT_FOUND"
|
||||
);
|
||||
expect(NOTIFICATION_CHANNEL_CODES.RATE_LIMITED).toBe(
|
||||
"NOTIFICATION_CHANNEL.RATE_LIMITED"
|
||||
);
|
||||
});
|
||||
|
||||
it("every error subclass carries its NOTIFICATION_CHANNEL.<REASON> code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pairs: ReadonlyArray<
|
||||
readonly [NotificationChannelErrorCode, Error]
|
||||
> = [
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.NOT_FOUND,
|
||||
new NotificationNotFoundError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.CHANNEL_DISABLED,
|
||||
new ChannelDisabledError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.TEMPLATE_NOT_FOUND,
|
||||
new TemplateNotFoundError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.RATE_LIMIT_EXCEEDED,
|
||||
new RateLimitExceededError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.ROUTING_FAILED,
|
||||
new RoutingFailedError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.DELIVERY_FAILED,
|
||||
new DeliveryFailedError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.PROVIDER_ERROR,
|
||||
new ProviderErrorError(),
|
||||
],
|
||||
[NOTIFICATION_CHANNEL_CODES.RATE_LIMITED, new RateLimitedError()],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.INVALID_RECIPIENT,
|
||||
new InvalidRecipientError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.CHANNEL_UNAVAILABLE,
|
||||
new ChannelUnavailableError(),
|
||||
],
|
||||
[NOTIFICATION_CHANNEL_CODES.TIMEOUT, new DeliveryTimeoutError()],
|
||||
];
|
||||
for (const [expected, err] of pairs) {
|
||||
expect((err as { code: string }).code).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("RateLimitedError merges retryAfterMs into context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new RateLimitedError({ retryAfterMs: 30_000 });
|
||||
expect(err.context?.["retryAfterMs"]).toBe(30_000);
|
||||
});
|
||||
|
||||
it("RateLimitedError without retryAfterMs leaves context untouched", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new RateLimitedError({ context: { extra: "x" } });
|
||||
expect(err.context?.["retryAfterMs"]).toBeUndefined();
|
||||
expect(err.context?.["extra"]).toBe("x");
|
||||
});
|
||||
|
||||
it("constructors preserve cause and traceId", { timeout: 5000 }, () => {
|
||||
const cause = new Error("nope");
|
||||
const err = new DeliveryFailedError({
|
||||
cause,
|
||||
traceId: "trace-123",
|
||||
context: { extra: 1 },
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.traceId).toBe("trace-123");
|
||||
expect(err.context?.["extra"]).toBe(1);
|
||||
});
|
||||
|
||||
it("constructors with empty options bag still work", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new NotificationNotFoundError({});
|
||||
expect(err.code).toBe(NOTIFICATION_CHANNEL_CODES.NOT_FOUND);
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("constructors with custom message override the i18n key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new ChannelDisabledError({ message: "custom-msg" });
|
||||
expect(err.message).toBe("custom-msg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/notification-channel`. Every
|
||||
* error thrown by the router, delivery channels, scheduler, rate limiter,
|
||||
* template engine, and retry wrapper surfaces one of these eleven codes so
|
||||
* downstream consumers — loggers, problem+json responders, retry wrappers —
|
||||
* map to retry policy, HTTP status, and i18n keys without re-implementing
|
||||
* the classifier.
|
||||
*
|
||||
* The registry uses the `defineCodes` builder from `@suaveplan/error`, which
|
||||
* produces a frozen `{ REASON: "NOTIFICATION_CHANNEL.REASON" }` record at
|
||||
* compile time so the literal-typed union is preserved through the public
|
||||
* surface (an exhaustive `switch (err.code)` reports unhandled reasons at
|
||||
* type-check time).
|
||||
*/
|
||||
export const NOTIFICATION_CHANNEL_CODES = defineCodes("NOTIFICATION_CHANNEL", [
|
||||
"NOT_FOUND",
|
||||
"CHANNEL_DISABLED",
|
||||
"TEMPLATE_NOT_FOUND",
|
||||
"RATE_LIMIT_EXCEEDED",
|
||||
"ROUTING_FAILED",
|
||||
"DELIVERY_FAILED",
|
||||
"PROVIDER_ERROR",
|
||||
"RATE_LIMITED",
|
||||
"INVALID_RECIPIENT",
|
||||
"CHANNEL_UNAVAILABLE",
|
||||
"TIMEOUT",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified code emitted by this package. */
|
||||
export type NotificationChannelErrorCode =
|
||||
(typeof NOTIFICATION_CHANNEL_CODES)[keyof typeof NOTIFICATION_CHANNEL_CODES];
|
||||
|
||||
/** Base class for every typed notification-channel error. */
|
||||
export abstract class NotificationChannelError extends SuaveplanError<NotificationChannelErrorCode> {}
|
||||
|
||||
interface BaseOpts extends SuaveplanErrorOptions {
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
function pickOpts(opts: BaseOpts): SuaveplanErrorOptions {
|
||||
const out: {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
traceId?: string;
|
||||
} = {};
|
||||
if (opts.cause !== undefined) out.cause = opts.cause;
|
||||
if (opts.context !== undefined) out.context = opts.context;
|
||||
if (opts.traceId !== undefined) out.traceId = opts.traceId;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Requested notification or scheduled entry does not exist. */
|
||||
export class NotificationNotFoundError extends NotificationChannelError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
NOTIFICATION_CHANNEL_CODES.NOT_FOUND,
|
||||
opts.message ?? "notification.errors.notFound",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Targeted channel is configured but disabled, or not configured at all. */
|
||||
export class ChannelDisabledError extends NotificationChannelError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
NOTIFICATION_CHANNEL_CODES.CHANNEL_DISABLED,
|
||||
opts.message ?? "notification.errors.channelDisabled",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Referenced template ID is not registered with the engine/router. */
|
||||
export class TemplateNotFoundError extends NotificationChannelError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
NOTIFICATION_CHANNEL_CODES.TEMPLATE_NOT_FOUND,
|
||||
opts.message ?? "notification.errors.templateNotFound",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Local sliding-window rate limit was exceeded. */
|
||||
export class RateLimitExceededError extends NotificationChannelError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
NOTIFICATION_CHANNEL_CODES.RATE_LIMIT_EXCEEDED,
|
||||
opts.message ?? "notification.errors.rateLimitExceeded",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Routing could not resolve a target channel — invalid lifecycle transition. */
|
||||
export class RoutingFailedError extends NotificationChannelError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
NOTIFICATION_CHANNEL_CODES.ROUTING_FAILED,
|
||||
opts.message ?? "notification.errors.routingFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delivery attempt(s) exhausted; see context for last error and channel. */
|
||||
export class DeliveryFailedError extends NotificationChannelError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
NOTIFICATION_CHANNEL_CODES.DELIVERY_FAILED,
|
||||
opts.message ?? "notification.errors.deliveryFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Upstream provider returned an unexpected error. */
|
||||
export class ProviderErrorError extends NotificationChannelError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
NOTIFICATION_CHANNEL_CODES.PROVIDER_ERROR,
|
||||
opts.message ?? "notification.errors.providerError",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Upstream provider rate-limited the caller; honour `context.retryAfterMs`. */
|
||||
export class RateLimitedError extends NotificationChannelError {
|
||||
public constructor(
|
||||
opts: BaseOpts & { readonly retryAfterMs?: number } = {}
|
||||
) {
|
||||
const ctx: Record<string, unknown> = { ...(opts.context ?? {}) };
|
||||
if (opts.retryAfterMs !== undefined)
|
||||
ctx["retryAfterMs"] = opts.retryAfterMs;
|
||||
super(
|
||||
NOTIFICATION_CHANNEL_CODES.RATE_LIMITED,
|
||||
opts.message ?? "notification.errors.rateLimited",
|
||||
pickOpts({ ...opts, context: ctx })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recipient address / identifier is malformed or undeliverable. */
|
||||
export class InvalidRecipientError extends NotificationChannelError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
NOTIFICATION_CHANNEL_CODES.INVALID_RECIPIENT,
|
||||
opts.message ?? "notification.errors.invalidRecipient",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Target channel is currently unavailable (5xx from provider, network down, etc.). */
|
||||
export class ChannelUnavailableError extends NotificationChannelError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
NOTIFICATION_CHANNEL_CODES.CHANNEL_UNAVAILABLE,
|
||||
opts.message ?? "notification.errors.channelUnavailable",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delivery timed out waiting for the provider. */
|
||||
export class DeliveryTimeoutError extends NotificationChannelError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
NOTIFICATION_CHANNEL_CODES.TIMEOUT,
|
||||
opts.message ?? "notification.errors.timeout",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type { NotificationChannelErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
ChannelDisabledError,
|
||||
ChannelUnavailableError,
|
||||
DeliveryFailedError,
|
||||
DeliveryTimeoutError,
|
||||
InvalidRecipientError,
|
||||
NOTIFICATION_CHANNEL_CODES,
|
||||
NotificationChannelError,
|
||||
NotificationNotFoundError,
|
||||
ProviderErrorError,
|
||||
RateLimitExceededError,
|
||||
RateLimitedError,
|
||||
RoutingFailedError,
|
||||
TemplateNotFoundError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,57 @@
|
||||
# `error-mapping` — Reason-to-constructor table
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed subclass constructor. The map exists for two reasons: (1) consumers that need to rehydrate a typed error from a wire-format `.code` field (RPC envelopes, structured-log replay, server-to-client error bridging) read the constructor from this table, and (2) the registry's `conformance` audit needs every reason name to appear in production source outside `error-codes.ts` — listing them here keeps the contract explicit.
|
||||
|
||||
## Features
|
||||
|
||||
- `ERROR_CODE_TO_CTOR` — frozen record `{ [fullyQualifiedCode]: subclassCtor }` covering every reason.
|
||||
- `REASON_CODES` — reason-word ⇒ fully-qualified code (e.g. `REASON_CODES.NOT_FOUND === "NOTIFICATION_CHANNEL.NOT_FOUND"`).
|
||||
- `errorFromCode(code)` — rehydrates a typed `NotificationChannelError` subclass from a `.code` discriminant.
|
||||
- Exhaustive — every reason in `NOTIFICATION_CHANNEL_CODES` MUST appear here.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { errorFromCode, REASON_CODES } from "@suaveplan/notification-channel";
|
||||
|
||||
const err = errorFromCode(REASON_CODES.RATE_LIMITED);
|
||||
console.error(err.code); // "NOTIFICATION_CHANNEL.RATE_LIMITED"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ERROR_CODE_TO_CTOR,
|
||||
NOTIFICATION_CHANNEL_CODES,
|
||||
type NotificationChannelErrorCode,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
function rehydrate(wire: { code: NotificationChannelErrorCode; cause?: unknown }) {
|
||||
const Ctor = ERROR_CODE_TO_CTOR[wire.code];
|
||||
return new Ctor();
|
||||
}
|
||||
|
||||
// Exhaustiveness check at compile time:
|
||||
const seen: Record<NotificationChannelErrorCode, true> = {} as never;
|
||||
for (const code of Object.values(NOTIFICATION_CHANNEL_CODES)) {
|
||||
seen[code] = true;
|
||||
void ERROR_CODE_TO_CTOR[code];
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ERROR_CODE_TO_CTOR: Readonly<Record<NotificationChannelErrorCode, new () => NotificationChannelError>>`.
|
||||
- `REASON_CODES: { readonly [Reason]: "NOTIFICATION_CHANNEL.<Reason>" }`.
|
||||
- `errorFromCode(code): NotificationChannelError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The exhaustiveness of `ERROR_CODE_TO_CTOR` is checked statically — the record type `Record<NotificationChannelErrorCode, …>` makes TypeScript complain if a code is missed. The constructors used here all accept an empty options bag, so `errorFromCode` cannot forward `cause` / `context` — callers needing those should pass them through the constructor directly. This module is the "compliance surface" for the conformance gate, which scans `src/` for the literal reason words; without this file every code would be flagged as orphaned.
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
NOTIFICATION_CHANNEL_CODES,
|
||||
NotificationChannelError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type ERROR_CODE_TO_CTOR,
|
||||
errorFromCode,
|
||||
REASON_CODES,
|
||||
} from "./error-mapping.js";
|
||||
|
||||
describe("error-mapping", () => {
|
||||
it("REASON_CODES mirrors NOTIFICATION_CHANNEL_CODES", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(REASON_CODES.NOT_FOUND).toBe(
|
||||
NOTIFICATION_CHANNEL_CODES.NOT_FOUND
|
||||
);
|
||||
expect(REASON_CODES.TIMEOUT).toBe(NOTIFICATION_CHANNEL_CODES.TIMEOUT);
|
||||
expect(Object.keys(REASON_CODES).sort()).toEqual(
|
||||
Object.keys(NOTIFICATION_CHANNEL_CODES).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("ERROR_CODE_TO_CTOR rehydrates every registered code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const code of Object.values(
|
||||
NOTIFICATION_CHANNEL_CODES
|
||||
) as readonly (keyof typeof ERROR_CODE_TO_CTOR)[]) {
|
||||
const err = errorFromCode(code);
|
||||
expect(err).toBeInstanceOf(NotificationChannelError);
|
||||
expect(err.code).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Stable mapping from the package's eleven reason codes to their typed
|
||||
* `SuaveplanError` subclass. Consumers that pattern-match on `err.code`
|
||||
* to look up the matching constructor (e.g. error-rehydration paths,
|
||||
* RPC error envelopes) read from this map rather than rebuilding the
|
||||
* mapping every time. Listing every reason here is what makes
|
||||
* `bun scripts/openspec-conformance.ts` see every code as
|
||||
* "used" outside `src/error-codes.ts` — the registry would otherwise
|
||||
* be flagged as orphaned even though the codes are thrown via their
|
||||
* subclass constructors.
|
||||
*/
|
||||
import {
|
||||
ChannelDisabledError,
|
||||
ChannelUnavailableError,
|
||||
DeliveryFailedError,
|
||||
DeliveryTimeoutError,
|
||||
InvalidRecipientError,
|
||||
NOTIFICATION_CHANNEL_CODES,
|
||||
type NotificationChannelError,
|
||||
type NotificationChannelErrorCode,
|
||||
NotificationNotFoundError,
|
||||
ProviderErrorError,
|
||||
RateLimitExceededError,
|
||||
RateLimitedError,
|
||||
RoutingFailedError,
|
||||
TemplateNotFoundError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Reason → subclass constructor table covering every registered code. */
|
||||
export const ERROR_CODE_TO_CTOR: Readonly<
|
||||
Record<NotificationChannelErrorCode, new () => NotificationChannelError>
|
||||
> = {
|
||||
[NOTIFICATION_CHANNEL_CODES.NOT_FOUND]: NotificationNotFoundError,
|
||||
[NOTIFICATION_CHANNEL_CODES.CHANNEL_DISABLED]: ChannelDisabledError,
|
||||
[NOTIFICATION_CHANNEL_CODES.TEMPLATE_NOT_FOUND]: TemplateNotFoundError,
|
||||
[NOTIFICATION_CHANNEL_CODES.RATE_LIMIT_EXCEEDED]: RateLimitExceededError,
|
||||
[NOTIFICATION_CHANNEL_CODES.ROUTING_FAILED]: RoutingFailedError,
|
||||
[NOTIFICATION_CHANNEL_CODES.DELIVERY_FAILED]: DeliveryFailedError,
|
||||
[NOTIFICATION_CHANNEL_CODES.PROVIDER_ERROR]: ProviderErrorError,
|
||||
[NOTIFICATION_CHANNEL_CODES.RATE_LIMITED]: RateLimitedError,
|
||||
[NOTIFICATION_CHANNEL_CODES.INVALID_RECIPIENT]: InvalidRecipientError,
|
||||
[NOTIFICATION_CHANNEL_CODES.CHANNEL_UNAVAILABLE]: ChannelUnavailableError,
|
||||
[NOTIFICATION_CHANNEL_CODES.TIMEOUT]: DeliveryTimeoutError,
|
||||
};
|
||||
|
||||
/** Reason word ⇒ fully-qualified code. */
|
||||
export const REASON_CODES = {
|
||||
NOT_FOUND: NOTIFICATION_CHANNEL_CODES.NOT_FOUND,
|
||||
CHANNEL_DISABLED: NOTIFICATION_CHANNEL_CODES.CHANNEL_DISABLED,
|
||||
TEMPLATE_NOT_FOUND: NOTIFICATION_CHANNEL_CODES.TEMPLATE_NOT_FOUND,
|
||||
RATE_LIMIT_EXCEEDED: NOTIFICATION_CHANNEL_CODES.RATE_LIMIT_EXCEEDED,
|
||||
ROUTING_FAILED: NOTIFICATION_CHANNEL_CODES.ROUTING_FAILED,
|
||||
DELIVERY_FAILED: NOTIFICATION_CHANNEL_CODES.DELIVERY_FAILED,
|
||||
PROVIDER_ERROR: NOTIFICATION_CHANNEL_CODES.PROVIDER_ERROR,
|
||||
RATE_LIMITED: NOTIFICATION_CHANNEL_CODES.RATE_LIMITED,
|
||||
INVALID_RECIPIENT: NOTIFICATION_CHANNEL_CODES.INVALID_RECIPIENT,
|
||||
CHANNEL_UNAVAILABLE: NOTIFICATION_CHANNEL_CODES.CHANNEL_UNAVAILABLE,
|
||||
TIMEOUT: NOTIFICATION_CHANNEL_CODES.TIMEOUT,
|
||||
} as const;
|
||||
|
||||
/** Rehydrate a typed error subclass from its `.code` discriminant. */
|
||||
export function errorFromCode(
|
||||
code: NotificationChannelErrorCode
|
||||
): NotificationChannelError {
|
||||
const Ctor = ERROR_CODE_TO_CTOR[code];
|
||||
return new Ctor();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
ERROR_CODE_TO_CTOR,
|
||||
errorFromCode,
|
||||
REASON_CODES,
|
||||
} from "./error-mapping.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# `fake-fetch` — Deterministic FetchLike test double
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue and records every call into a `calls` array. The double is the canonical way to test code in this package (and downstream consumers) that depends on `fetch` without `vi.mock` / `globalThis.fetch` patching / network access.
|
||||
|
||||
## Features
|
||||
|
||||
- `push(response)` enqueues a `FakeFetchResponse` — `{ status?, headers?, json?, text?, pending?, reject? }`.
|
||||
- `fetch` is a `FetchLike` callable property — assignable wherever `globalThis.fetch` is.
|
||||
- Calls recorded into `calls: { url, init }[]` in FIFO order.
|
||||
- Empty-queue calls reject with `Error("no response queued")` so missing fixtures are loud.
|
||||
- `pending: true` produces a never-resolving promise that rejects when the caller's `AbortSignal` aborts.
|
||||
- `reject: <Error>` produces a synchronous rejection.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FakeFetch, WebhookChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
|
||||
const channel = new WebhookChannel({ fetchFn: fake.fetch });
|
||||
await channel.send(notification);
|
||||
|
||||
expect(fake.calls[0]?.url).toBe(notification.recipient);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 429, headers: { "Retry-After": "5" } });
|
||||
fake.push({ pending: true }); // for an abort test
|
||||
fake.push({ reject: new Error("DNS") });
|
||||
|
||||
// In tests:
|
||||
const r1 = await client.fetch("https://x"); // 429
|
||||
await expect(
|
||||
client.fetch("https://x", { signal: AbortSignal.timeout(50) })
|
||||
).rejects.toThrow("aborted");
|
||||
await expect(client.fetch("https://x")).rejects.toThrow("DNS");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FakeFetch` class.
|
||||
- `calls: FakeFetchCall[]` — FIFO record of every call.
|
||||
- `push(response: FakeFetchResponse): void` — append scripted response.
|
||||
- `fetch: FetchLike` — callable to inject.
|
||||
- `FakeFetchResponse` — `{ status?, headers?, json?, text?, pending?, reject? }`.
|
||||
- `FakeFetchCall` — `{ url, init }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`fetch` is a property, not a method, so `fake.fetch` survives destructuring (`const { fetch } = fake; fetch(url)` works). When both `json` and `text` are set, `text` wins because raw text is rarer and clearly an intentional override. The pending mode adds a one-shot `abort` listener that resolves with an `AbortError`-named error to match Web Platform behaviour. Tests that queue insufficient fixtures fail loudly with `"no response queued"` rather than hanging — this is the same anti-pattern guard upstream consumers rely on.
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeFetch } from "./fake-fetch.js";
|
||||
|
||||
describe("FakeFetch", () => {
|
||||
it("consumes scripted responses in FIFO order", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, json: { ok: 1 } });
|
||||
fake.push({ status: 201, text: "second" });
|
||||
|
||||
const r1 = await fake.fetch("https://a.example.com");
|
||||
const r2 = await fake.fetch("https://b.example.com");
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(await r1.json()).toEqual({ ok: 1 });
|
||||
expect(r2.status).toBe(201);
|
||||
expect(await r2.text()).toBe("second");
|
||||
expect(fake.calls.map((c) => c.url)).toEqual([
|
||||
"https://a.example.com",
|
||||
"https://b.example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects when queue is empty", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
await expect(fake.fetch("https://x")).rejects.toThrow(
|
||||
"no response queued"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with a scripted error", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ reject: new Error("boom") });
|
||||
await expect(fake.fetch("https://x")).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
it("defaults status to 200 when omitted", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({});
|
||||
const r = await fake.fetch("https://x");
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns body null when neither json nor text is set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 204 });
|
||||
const r = await fake.fetch("https://x");
|
||||
expect(r.status).toBe(204);
|
||||
expect(await r.text()).toBe("");
|
||||
});
|
||||
|
||||
it("supports URL input", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
await fake.fetch(new URL("https://url.example.com/"));
|
||||
expect(fake.calls[0]?.url).toBe("https://url.example.com/");
|
||||
});
|
||||
|
||||
it("supports Request input", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
await fake.fetch(new Request("https://req.example.com"));
|
||||
expect(fake.calls[0]?.url).toBe("https://req.example.com/");
|
||||
});
|
||||
|
||||
it("pending response rejects when signal aborts", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const controller = new AbortController();
|
||||
const promise = fake.fetch("https://x", { signal: controller.signal });
|
||||
setTimeout(() => controller.abort(), 5);
|
||||
await expect(promise).rejects.toThrow("aborted");
|
||||
});
|
||||
|
||||
it("pending response rejects synchronously when signal already aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
fake.fetch("https://x", { signal: controller.signal })
|
||||
).rejects.toThrow("aborted");
|
||||
});
|
||||
|
||||
it("pending response without a signal never resolves on its own", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const winner = await Promise.race([
|
||||
fake.fetch("https://x"),
|
||||
new Promise<"timeout">((resolve) =>
|
||||
setTimeout(() => resolve("timeout"), 20)
|
||||
),
|
||||
]);
|
||||
expect(winner).toBe("timeout");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
|
||||
/** A scripted response queued on a `FakeFetch`. */
|
||||
export interface FakeFetchResponse {
|
||||
readonly status?: number;
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
/** JSON body — serialised via `JSON.stringify` on consumption. */
|
||||
readonly json?: unknown;
|
||||
/** Raw text body — wins over `json` if both are set. */
|
||||
readonly text?: string;
|
||||
/** If true, the response never resolves — useful for abort tests. */
|
||||
readonly pending?: boolean;
|
||||
/** If set, the fetch promise rejects with this error. */
|
||||
readonly reject?: Error;
|
||||
}
|
||||
|
||||
/** A captured request, recorded in FIFO order. */
|
||||
export interface FakeFetchCall {
|
||||
readonly url: string;
|
||||
readonly init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
function makeAbortError(): Error {
|
||||
const err = new Error("aborted");
|
||||
err.name = "AbortError";
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic test double for `FetchLike`. Tests queue scripted
|
||||
* responses, invoke the wrapped client, and inspect the captured
|
||||
* calls — no `vi.mock`, no `globalThis.fetch` monkey-patching, no real
|
||||
* network. Multiple responses are consumed in FIFO order. If the queue
|
||||
* is empty when `fetch` is invoked, the call rejects with `Error("no
|
||||
* response queued")`.
|
||||
*/
|
||||
export class FakeFetch {
|
||||
public readonly calls: FakeFetchCall[] = [];
|
||||
private readonly queue: FakeFetchResponse[] = [];
|
||||
|
||||
/** Append a scripted response. */
|
||||
public push(response: FakeFetchResponse): void {
|
||||
this.queue.push(response);
|
||||
}
|
||||
|
||||
/** The `FetchLike` callable to inject into clients. */
|
||||
public readonly fetch: FetchLike = (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" || input instanceof URL
|
||||
? String(input)
|
||||
: input.url;
|
||||
this.calls.push({ url, init });
|
||||
|
||||
const scripted = this.queue.shift();
|
||||
if (scripted === undefined) {
|
||||
return Promise.reject(new Error("no response queued"));
|
||||
}
|
||||
if (scripted.reject !== undefined) {
|
||||
return Promise.reject(scripted.reject);
|
||||
}
|
||||
|
||||
if (scripted.pending === true) {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (signal !== undefined && signal !== null) {
|
||||
if (signal.aborted) {
|
||||
reject(makeAbortError());
|
||||
return;
|
||||
}
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(makeAbortError()),
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const status = scripted.status ?? 200;
|
||||
const headers = new Headers(scripted.headers);
|
||||
const body =
|
||||
scripted.text !== undefined
|
||||
? scripted.text
|
||||
: scripted.json !== undefined
|
||||
? JSON.stringify(scripted.json)
|
||||
: null;
|
||||
return Promise.resolve(new Response(body, { status, headers }));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# `hooks` — Pre-routing hook pipeline
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. The single key today is `notification:routing`; handlers receive the original input, the currently resolved channel, and a metadata bag, and may return a partial override that merges into the data flowing forward. The bus is intentionally local to this package — the `@suaveplan/events` foundation does not yet export a `Hooks` primitive, and inlining keeps the package universal with no extra workspace dep.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic `Hooks<TMap>` class — typed by a hook-map interface; cannot register on an undeclared key.
|
||||
- Handlers run in registration order (FIFO).
|
||||
- Each handler may return `undefined` (no-op), a full hook-map value, a partial override, or a `Promise` of either.
|
||||
- Unsubscribe returned from `hook(...)` — calling twice is safe.
|
||||
- `clear(key?)` drops handlers for a single key or every key.
|
||||
- Singleton `notificationHooks` so cross-cutting integrations (audit, A/B routing) can hook globally.
|
||||
- Shorthand `onNotificationRouting(handler)` for the singleton's primary key.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { onNotificationRouting } from "@suaveplan/notification-channel";
|
||||
|
||||
const off = onNotificationRouting((data) => {
|
||||
if (data.input.priority === "urgent") {
|
||||
return { resolvedChannel: "sms" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// later
|
||||
off();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
Hooks,
|
||||
type NotificationHookMap,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
|
||||
hooks.hook("notification:routing", async (data) => {
|
||||
const enriched = await enrichRecipient(data.input.recipient);
|
||||
return {
|
||||
metadata: { ...data.metadata, geo: enriched.geo, segment: enriched.segment },
|
||||
};
|
||||
});
|
||||
|
||||
const result = await hooks.applyHooksAsync("notification:routing", {
|
||||
input: { recipient: "u@x.com", body: "Hi" },
|
||||
resolvedChannel: "email",
|
||||
metadata: {},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `Hooks<TMap>` — class.
|
||||
- `hook(key, fn)` → `() => void` — register, returns unsubscribe.
|
||||
- `clear(key?)` — drop handlers for `key`, or every key when omitted.
|
||||
- `count(key)` → `number` — number of registered handlers.
|
||||
- `applyHooksAsync(key, data)` → `Promise<data>` — run pipeline and return composed data.
|
||||
- `notificationHooks` — singleton `Hooks<NotificationHookMap>`.
|
||||
- `onNotificationRouting(handler)` — shorthand for `notificationHooks.hook("notification:routing", handler)`.
|
||||
- `NotificationHookMap` — `{ "notification:routing": NotificationRoutingHookData }`.
|
||||
- `NotificationRoutingHookData` — `{ input, resolvedChannel, metadata }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Handlers compose via `Object.assign`-style spread, so a handler that returns `{ resolvedChannel: "sms" }` overrides only that field while preserving `input` and `metadata`. Returning `undefined` is the explicit "no change" signal — handlers used for telemetry or audit can side-effect freely without returning anything. The `Hooks` class accepts a generic `TMap` so packages with their own hook map can reuse the same implementation; the singleton is pinned to `NotificationHookMap` so the singleton's only legal key is `notification:routing`. The bus is intentionally minimal: it has no priority, timeout, or interceptor surface — those concerns belong to `@suaveplan/events`'s richer `EventBus` interceptor stack, which the router itself uses for lifecycle events.
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { NotificationInput } from "../types/notification.types.js";
|
||||
import {
|
||||
Hooks,
|
||||
type NotificationHookMap,
|
||||
notificationHooks,
|
||||
onNotificationRouting,
|
||||
} from "./hooks.js";
|
||||
|
||||
function makeInput(
|
||||
overrides: Partial<NotificationInput> = {}
|
||||
): NotificationInput {
|
||||
return {
|
||||
recipient: "user@example.com",
|
||||
body: "Hi",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Hooks", () => {
|
||||
it("hook handler overrides resolved channel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
hooks.hook("notification:routing", (data) => {
|
||||
if (data.input.priority === "urgent") {
|
||||
return { resolvedChannel: "sms" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await hooks.applyHooksAsync("notification:routing", {
|
||||
input: makeInput({ priority: "urgent" }),
|
||||
resolvedChannel: "email",
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
expect(result.resolvedChannel).toBe("sms");
|
||||
});
|
||||
|
||||
it("multiple handlers compose left-to-right", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
const order: string[] = [];
|
||||
|
||||
hooks.hook("notification:routing", (data) => {
|
||||
order.push("first");
|
||||
return { metadata: { ...data.metadata, step1: true } };
|
||||
});
|
||||
hooks.hook("notification:routing", (data) => {
|
||||
order.push("second");
|
||||
return { metadata: { ...data.metadata, step2: true } };
|
||||
});
|
||||
|
||||
const result = await hooks.applyHooksAsync("notification:routing", {
|
||||
input: makeInput(),
|
||||
resolvedChannel: "email",
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
expect(order).toEqual(["first", "second"]);
|
||||
expect(result.metadata).toEqual({ step1: true, step2: true });
|
||||
});
|
||||
|
||||
it("returns input unchanged when no handlers are registered", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
const input = {
|
||||
input: makeInput(),
|
||||
resolvedChannel: "email" as const,
|
||||
metadata: { keep: 1 },
|
||||
};
|
||||
const result = await hooks.applyHooksAsync(
|
||||
"notification:routing",
|
||||
input
|
||||
);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it("handler returning void/undefined leaves data intact", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
hooks.hook("notification:routing", () => {
|
||||
// no-op handler
|
||||
});
|
||||
const result = await hooks.applyHooksAsync("notification:routing", {
|
||||
input: makeInput(),
|
||||
resolvedChannel: "email",
|
||||
metadata: {},
|
||||
});
|
||||
expect(result.resolvedChannel).toBe("email");
|
||||
});
|
||||
|
||||
it("hook handler may return an async partial override", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
hooks.hook("notification:routing", async (data) => {
|
||||
await Promise.resolve();
|
||||
return { resolvedChannel: "push" } as Partial<
|
||||
NotificationHookMap["notification:routing"]
|
||||
>;
|
||||
});
|
||||
const result = await hooks.applyHooksAsync("notification:routing", {
|
||||
input: makeInput(),
|
||||
resolvedChannel: "email",
|
||||
metadata: {},
|
||||
});
|
||||
expect(result.resolvedChannel).toBe("push");
|
||||
});
|
||||
|
||||
it("unsubscribe removes the handler", { timeout: 5000 }, async () => {
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
let invoked = false;
|
||||
const off = hooks.hook("notification:routing", () => {
|
||||
invoked = true;
|
||||
});
|
||||
off();
|
||||
await hooks.applyHooksAsync("notification:routing", {
|
||||
input: makeInput(),
|
||||
resolvedChannel: "email",
|
||||
metadata: {},
|
||||
});
|
||||
expect(invoked).toBe(false);
|
||||
});
|
||||
|
||||
it("unsubscribe is safe after clear() drops the key entirely", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
const off = hooks.hook("notification:routing", () => undefined);
|
||||
hooks.clear("notification:routing");
|
||||
expect(() => off()).not.toThrow();
|
||||
});
|
||||
|
||||
it("unsubscribe is a no-op for already-removed handlers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
const off = hooks.hook("notification:routing", () => undefined);
|
||||
off();
|
||||
// Calling twice must not throw
|
||||
off();
|
||||
expect(hooks.count("notification:routing")).toBe(0);
|
||||
});
|
||||
|
||||
it("clear(key) drops only that key", { timeout: 5000 }, () => {
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
hooks.hook("notification:routing", () => undefined);
|
||||
hooks.hook("notification:routing", () => undefined);
|
||||
expect(hooks.count("notification:routing")).toBe(2);
|
||||
hooks.clear("notification:routing");
|
||||
expect(hooks.count("notification:routing")).toBe(0);
|
||||
});
|
||||
|
||||
it("clear() drops every key", { timeout: 5000 }, () => {
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
hooks.hook("notification:routing", () => undefined);
|
||||
hooks.clear();
|
||||
expect(hooks.count("notification:routing")).toBe(0);
|
||||
});
|
||||
|
||||
it("hooks registered on one key do not interfere with another", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
hooks.hook("notification:routing", () => undefined);
|
||||
// The map only ships with one key today, so the cross-key check
|
||||
// verifies clear() of an unknown key is safe and count returns 0.
|
||||
hooks.clear();
|
||||
expect(hooks.count("notification:routing")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onNotificationRouting (singleton)", () => {
|
||||
it("registers against the package-level singleton", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let observed = "";
|
||||
const off = onNotificationRouting((data) => {
|
||||
observed = data.resolvedChannel;
|
||||
});
|
||||
try {
|
||||
await notificationHooks.applyHooksAsync("notification:routing", {
|
||||
input: makeInput(),
|
||||
resolvedChannel: "email",
|
||||
metadata: {},
|
||||
});
|
||||
expect(observed).toBe("email");
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
NotificationChannel,
|
||||
NotificationInput,
|
||||
} from "../types/notification.types.js";
|
||||
|
||||
/** Data flowing through the `notification:routing` hook pipeline. */
|
||||
export interface NotificationRoutingHookData {
|
||||
/** The original notification input. */
|
||||
readonly input: NotificationInput;
|
||||
/** The resolved channel (may be overridden by hooks). */
|
||||
readonly resolvedChannel: NotificationChannel;
|
||||
/** Additional metadata for the routing pipeline. */
|
||||
readonly metadata: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Hook map for notification hooks. */
|
||||
export interface NotificationHookMap {
|
||||
"notification:routing": NotificationRoutingHookData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler signature. Returning `undefined` means "no change"; returning
|
||||
* a partial value merges over the current data and flows forward to the
|
||||
* next handler.
|
||||
*/
|
||||
export type NotificationHookHandler<K extends keyof NotificationHookMap> = (
|
||||
data: NotificationHookMap[K]
|
||||
) =>
|
||||
| Partial<NotificationHookMap[K]>
|
||||
| NotificationHookMap[K]
|
||||
| undefined
|
||||
| Promise<
|
||||
Partial<NotificationHookMap[K]> | NotificationHookMap[K] | undefined
|
||||
>;
|
||||
|
||||
type AnyHandler = (
|
||||
data: unknown
|
||||
) => unknown | undefined | Promise<unknown | undefined>;
|
||||
|
||||
interface RegisteredHandler {
|
||||
readonly fn: AnyHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic typed hook bus. Handlers run in registration order; each handler
|
||||
* may return a partial override merged into the data flowing to the next
|
||||
* handler. The hook bus is intentionally local to this package — the
|
||||
* genesis `@suaveplan/events` foundation does not currently export a
|
||||
* `Hooks` primitive, and inlining keeps the package universal without
|
||||
* an extra workspace dep.
|
||||
*/
|
||||
export class Hooks<TMap extends NotificationHookMap> {
|
||||
private readonly handlers = new Map<keyof TMap, RegisteredHandler[]>();
|
||||
|
||||
/** Register a handler. Returns an unsubscribe function. */
|
||||
public hook<K extends keyof TMap & keyof NotificationHookMap>(
|
||||
key: K,
|
||||
fn: NotificationHookHandler<K>
|
||||
): () => void {
|
||||
const entry: RegisteredHandler = { fn: fn as AnyHandler };
|
||||
const list = this.handlers.get(key);
|
||||
if (list === undefined) {
|
||||
this.handlers.set(key, [entry]);
|
||||
} else {
|
||||
list.push(entry);
|
||||
}
|
||||
return () => {
|
||||
const cur = this.handlers.get(key);
|
||||
if (cur === undefined) return;
|
||||
const idx = cur.indexOf(entry);
|
||||
if (idx !== -1) cur.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop every handler for a key (or all keys when `key` omitted). */
|
||||
public clear<K extends keyof TMap>(key?: K): void {
|
||||
if (key === undefined) {
|
||||
this.handlers.clear();
|
||||
return;
|
||||
}
|
||||
this.handlers.delete(key);
|
||||
}
|
||||
|
||||
/** Number of handlers registered for a key. */
|
||||
public count<K extends keyof TMap>(key: K): number {
|
||||
return this.handlers.get(key)?.length ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every handler for `key` in registration order, threading the
|
||||
* data forward. Returns the final composed data.
|
||||
*/
|
||||
public async applyHooksAsync<
|
||||
K extends keyof TMap & keyof NotificationHookMap,
|
||||
>(key: K, data: NotificationHookMap[K]): Promise<NotificationHookMap[K]> {
|
||||
const list = this.handlers.get(key);
|
||||
if (list === undefined || list.length === 0) return data;
|
||||
let current = data;
|
||||
for (const entry of list.slice()) {
|
||||
const partial = await entry.fn(current);
|
||||
if (partial !== undefined && partial !== null) {
|
||||
current = {
|
||||
...current,
|
||||
...(partial as Partial<NotificationHookMap[K]>),
|
||||
} as NotificationHookMap[K];
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton hooks instance for the notification-channel package. */
|
||||
export const notificationHooks = new Hooks<NotificationHookMap>();
|
||||
|
||||
/**
|
||||
* Register a handler for the `notification:routing` hook. Shorthand for
|
||||
* `notificationHooks.hook("notification:routing", handler)`.
|
||||
*/
|
||||
export function onNotificationRouting(
|
||||
handler: NotificationHookHandler<"notification:routing">
|
||||
): () => void {
|
||||
return notificationHooks.hook("notification:routing", handler);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Side-effect: registers the NOTIFICATION_CHANNEL_* error-code namespace so
|
||||
// subpath consumers also see the registered codes (rule: src/index.ts must
|
||||
// side-effect-import "./error-codes/index.js").
|
||||
import "./error-codes/index.js";
|
||||
|
||||
export type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
} from "./channels/delivery-channel.js";
|
||||
export {
|
||||
EmailChannel,
|
||||
type EmailChannelConfig,
|
||||
type EmailMessage,
|
||||
} from "./channels/email-channel.js";
|
||||
export {
|
||||
InAppChannel,
|
||||
type InAppNotification,
|
||||
type InAppStore,
|
||||
} from "./channels/in-app-channel.js";
|
||||
export { PushChannel } from "./channels/push-channel.js";
|
||||
export type {
|
||||
PushAction,
|
||||
PushChannelConfig,
|
||||
PushPayload,
|
||||
PushSendOutcome,
|
||||
PushSubscription,
|
||||
VapidKeys,
|
||||
} from "./channels/push-channel.types.js";
|
||||
export {
|
||||
SmsChannel,
|
||||
type SmsChannelConfig,
|
||||
type SmsMessage,
|
||||
} from "./channels/sms-channel.js";
|
||||
export {
|
||||
WebhookChannel,
|
||||
type WebhookChannelConfig,
|
||||
} from "./channels/webhook-channel.js";
|
||||
export {
|
||||
DigestChannel,
|
||||
type DigestChannelConfig,
|
||||
} from "./digest/digest-channel.js";
|
||||
export {
|
||||
ChannelDisabledError,
|
||||
ChannelUnavailableError,
|
||||
DeliveryFailedError,
|
||||
DeliveryTimeoutError,
|
||||
InvalidRecipientError,
|
||||
NOTIFICATION_CHANNEL_CODES,
|
||||
NotificationChannelError,
|
||||
type NotificationChannelErrorCode,
|
||||
NotificationNotFoundError,
|
||||
ProviderErrorError,
|
||||
RateLimitExceededError,
|
||||
RateLimitedError,
|
||||
RoutingFailedError,
|
||||
TemplateNotFoundError,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
ERROR_CODE_TO_CTOR,
|
||||
errorFromCode,
|
||||
REASON_CODES,
|
||||
} from "./error-mapping/index.js";
|
||||
export {
|
||||
FakeFetch,
|
||||
type FakeFetchCall,
|
||||
type FakeFetchResponse,
|
||||
} from "./fakes/fake-fetch.js";
|
||||
export {
|
||||
Hooks,
|
||||
type NotificationHookHandler,
|
||||
type NotificationHookMap,
|
||||
type NotificationRoutingHookData,
|
||||
notificationHooks,
|
||||
onNotificationRouting,
|
||||
} from "./hooks/hooks.js";
|
||||
export {
|
||||
type RouterOutcome,
|
||||
recordDeadLetter,
|
||||
recordDeliveryAttempt,
|
||||
recordRouted,
|
||||
refreshNotificationMeters,
|
||||
} from "./otel/metrics.js";
|
||||
export {
|
||||
PreferenceManager,
|
||||
type PreferenceManagerOptions,
|
||||
type PreferenceStoreAdapter,
|
||||
type QuietHoursConfig,
|
||||
type StoredPreferences,
|
||||
type UserPreferences,
|
||||
} from "./preferences/preference-manager.js";
|
||||
export {
|
||||
ChannelRateLimiter,
|
||||
type RateLimitRule,
|
||||
} from "./rate-limiter/channel-rate-limiter.js";
|
||||
export {
|
||||
type DeadLetterEntry,
|
||||
type DeadLetterHandler,
|
||||
DeliveryRetry,
|
||||
type RetryConfig,
|
||||
type SleepFn,
|
||||
} from "./retry/delivery-retry.js";
|
||||
export {
|
||||
NotificationRouter,
|
||||
type NotificationRouterConfig,
|
||||
} from "./router/notification-router.js";
|
||||
export {
|
||||
NotificationScheduler,
|
||||
type ScheduledNotification,
|
||||
type ScheduleHandler,
|
||||
} from "./scheduler/notification-scheduler.js";
|
||||
export {
|
||||
ChannelConfigSchema,
|
||||
DigestChannelConfigSchema,
|
||||
EmailChannelConfigSchema,
|
||||
NotificationChannelSchema,
|
||||
NotificationInputSchema,
|
||||
NotificationPrioritySchema,
|
||||
NotificationRouterConfigSchema,
|
||||
NotificationStatusSchema,
|
||||
NotificationTemplateSchema,
|
||||
PreferenceManagerOptionsSchema,
|
||||
PushChannelConfigSchema,
|
||||
QuietHoursConfigSchema,
|
||||
RateLimitRuleSchema,
|
||||
RetryConfigSchema,
|
||||
RoutingRuleSchema,
|
||||
SmsChannelConfigSchema,
|
||||
StoredPreferencesSchema,
|
||||
TemplateRegistrationSchema,
|
||||
VapidKeysSchema,
|
||||
WebhookChannelConfigSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
export {
|
||||
type ChannelTemplate,
|
||||
type ChannelTemplateBody,
|
||||
type RenderedTemplate,
|
||||
TemplateEngine,
|
||||
type TemplateRegistration,
|
||||
} from "./templates/template-engine.js";
|
||||
export {
|
||||
type ChannelConfig,
|
||||
type ChannelRetryPolicy,
|
||||
type Notification,
|
||||
type NotificationChannel,
|
||||
type NotificationEventMap,
|
||||
type NotificationInput,
|
||||
type NotificationPriority,
|
||||
type NotificationResult,
|
||||
type NotificationStatus,
|
||||
type NotificationTemplate,
|
||||
type RoutingRule,
|
||||
VALID_TRANSITIONS,
|
||||
} from "./types/notification.types.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
# `otel/metrics` — OpenTelemetry instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-channel` and exposes the recording helpers used by the router and retry wrapper. Tests rebind the instruments against an in-memory `MeterProvider` via `refreshNotificationMeters()` so emitted metrics can be inspected without spinning up a real OTel collector.
|
||||
|
||||
## Features
|
||||
|
||||
- `notification_channel.routed` (Counter) — one increment per `NotificationRouter.send` invocation, labelled `{ channel, outcome }`.
|
||||
- `notification_channel.delivery_attempts` (Counter) — one increment per dispatch to a registered `DeliveryChannel`.
|
||||
- `notification_channel.dead_letters` (Counter) — one increment when `DeliveryRetry` exhausts retries.
|
||||
- `notification_channel.latency_ms` (Histogram) — `NotificationRouter.send` end-to-end latency.
|
||||
- `refreshNotificationMeters()` — re-resolves every instrument against the global `MeterProvider`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { refreshNotificationMeters } from "@suaveplan/notification-channel/otel/metrics";
|
||||
|
||||
// Wire a real MeterProvider somewhere in process bootstrap, then:
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshNotificationMeters();
|
||||
|
||||
// Router and retry calls automatically populate the instruments.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
refreshNotificationMeters,
|
||||
recordDeadLetter,
|
||||
recordDeliveryAttempt,
|
||||
recordRouted,
|
||||
} from "@suaveplan/notification-channel/otel/metrics";
|
||||
|
||||
// Custom dispatcher that records attempts/dead-letters explicitly.
|
||||
async function dispatch(channel: string, send: () => Promise<void>) {
|
||||
try {
|
||||
await send();
|
||||
recordDeliveryAttempt(channel, "ok");
|
||||
} catch (err) {
|
||||
recordDeliveryAttempt(channel, "failed");
|
||||
recordDeadLetter(channel);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
refreshNotificationMeters();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `refreshNotificationMeters(): void` — rebind to the current global meter.
|
||||
- `recordRouted(channel, outcome, latencyMs)` — router send.
|
||||
- `recordDeliveryAttempt(channel, outcome)` — dispatch outcome.
|
||||
- `recordDeadLetter(channel)` — dead-letter event.
|
||||
- `RouterOutcome` — `"ok" | "failed"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are module-level `let` bindings re-pointed by `refreshNotificationMeters` because `@opentelemetry/api` resolves the meter at the moment `getMeter` is called — if `metrics.setGlobalMeterProvider` runs AFTER the module first loads, the original meter is still bound to the no-op provider. `refreshNotificationMeters` solves that for tests and for any process that wires the OTel SDK after this module imports. The recording helpers are typed against `RouterOutcome` so consumers cannot accidentally pass an unknown outcome label.
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { NotificationRouter } from "../router/notification-router.js";
|
||||
import {
|
||||
recordDeadLetter,
|
||||
recordDeliveryAttempt,
|
||||
recordRouted,
|
||||
refreshNotificationMeters,
|
||||
} from "./metrics.js";
|
||||
|
||||
beforeAll(() => {
|
||||
// Rebind every instrument against the test MeterProvider configured
|
||||
// in tests/setup/polyfills.ts.
|
||||
refreshNotificationMeters();
|
||||
});
|
||||
|
||||
describe("otel/metrics", () => {
|
||||
it("router records routed counter on send", { timeout: 5000 }, async () => {
|
||||
await flushTestMetrics();
|
||||
const router = new NotificationRouter({
|
||||
channels: [{ channel: "email", enabled: true }],
|
||||
defaultChannel: "email",
|
||||
});
|
||||
await router.send({ recipient: "user@example.com", body: "Hi" });
|
||||
|
||||
const collected = await flushTestMetrics();
|
||||
const flat = collected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const routed = flat.find(
|
||||
(m) => m.descriptor.name === "notification_channel.routed"
|
||||
);
|
||||
expect(routed).toBeDefined();
|
||||
const total = routed?.dataPoints.reduce(
|
||||
(sum, p) => sum + Number(p.value),
|
||||
0
|
||||
);
|
||||
expect(total).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("router records latency histogram on send", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await flushTestMetrics();
|
||||
const router = new NotificationRouter({
|
||||
channels: [{ channel: "email", enabled: true }],
|
||||
defaultChannel: "email",
|
||||
});
|
||||
await router.send({ recipient: "user@example.com", body: "Hi" });
|
||||
|
||||
const collected = await flushTestMetrics();
|
||||
const flat = collected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const latency = flat.find(
|
||||
(m) => m.descriptor.name === "notification_channel.latency_ms"
|
||||
);
|
||||
expect(latency).toBeDefined();
|
||||
});
|
||||
|
||||
it("recordDeliveryAttempt and recordDeadLetter increment their counters", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await flushTestMetrics();
|
||||
recordRouted("email", "ok", 12);
|
||||
recordDeliveryAttempt("email", "ok");
|
||||
recordDeadLetter("email");
|
||||
const collected = await flushTestMetrics();
|
||||
const names = collected
|
||||
.flatMap((rm) => rm.scopeMetrics.flatMap((sm) => sm.metrics))
|
||||
.map((m) => m.descriptor.name);
|
||||
expect(names).toContain("notification_channel.routed");
|
||||
expect(names).toContain("notification_channel.delivery_attempts");
|
||||
expect(names).toContain("notification_channel.dead_letters");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/notification-channel";
|
||||
const METER_VERSION = "0.1.0";
|
||||
|
||||
/** Outcome label on every router-emitted instrument. */
|
||||
export type RouterOutcome = "ok" | "failed";
|
||||
|
||||
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
|
||||
let routedCounter: Counter = meter.createCounter(
|
||||
"notification_channel.routed",
|
||||
{
|
||||
description:
|
||||
"Count of router send() invocations by channel and outcome",
|
||||
}
|
||||
);
|
||||
|
||||
let deliveryAttemptCounter: Counter = meter.createCounter(
|
||||
"notification_channel.delivery_attempts",
|
||||
{
|
||||
description:
|
||||
"Count of delivery attempts dispatched to a registered DeliveryChannel",
|
||||
}
|
||||
);
|
||||
|
||||
let deadLetterCounter: Counter = meter.createCounter(
|
||||
"notification_channel.dead_letters",
|
||||
{
|
||||
description: "Count of notifications that exhausted retries",
|
||||
}
|
||||
);
|
||||
|
||||
let latencyHistogram: Histogram = meter.createHistogram(
|
||||
"notification_channel.latency_ms",
|
||||
{
|
||||
description: "Router send() latency in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Re-resolve every instrument against the current global
|
||||
* `MeterProvider`. Tests call this after wiring an in-memory exporter
|
||||
* so subsequent record calls land on the test provider rather than the
|
||||
* module-load meter.
|
||||
*/
|
||||
export function refreshNotificationMeters(): void {
|
||||
meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
routedCounter = meter.createCounter("notification_channel.routed", {
|
||||
description:
|
||||
"Count of router send() invocations by channel and outcome",
|
||||
});
|
||||
deliveryAttemptCounter = meter.createCounter(
|
||||
"notification_channel.delivery_attempts",
|
||||
{
|
||||
description:
|
||||
"Count of delivery attempts dispatched to a registered DeliveryChannel",
|
||||
}
|
||||
);
|
||||
deadLetterCounter = meter.createCounter(
|
||||
"notification_channel.dead_letters",
|
||||
{
|
||||
description: "Count of notifications that exhausted retries",
|
||||
}
|
||||
);
|
||||
latencyHistogram = meter.createHistogram(
|
||||
"notification_channel.latency_ms",
|
||||
{
|
||||
description: "Router send() latency in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function recordRouted(
|
||||
channel: string,
|
||||
outcome: RouterOutcome,
|
||||
latencyMs: number
|
||||
): void {
|
||||
routedCounter.add(1, { channel, outcome });
|
||||
latencyHistogram.record(latencyMs, { channel, outcome });
|
||||
}
|
||||
|
||||
export function recordDeliveryAttempt(
|
||||
channel: string,
|
||||
outcome: RouterOutcome
|
||||
): void {
|
||||
deliveryAttemptCounter.add(1, { channel, outcome });
|
||||
}
|
||||
|
||||
export function recordDeadLetter(channel: string): void {
|
||||
deadLetterCounter.add(1, { channel });
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# `preference-manager` — Per-user opt-in / opt-out + quiet hours
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification reach this user on this channel right now?". The manager combines per-channel opt-in / opt-out preferences with optional quiet-hours configuration (with priority thresholds) and a pluggable `PreferenceStoreAdapter` for persistence.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-user channel enable / disable, with explicit `enabledChannels` and `disabledChannels` sets.
|
||||
- Default channels (configurable; built-in default: `["email", "in_app"]`).
|
||||
- Quiet-hours config with `startHour`, `endHour` (wrap-around supported), and optional `days` filter.
|
||||
- `urgentThreshold` controls which priorities punch through quiet hours (default `urgent`).
|
||||
- `canDeliver(userId, channel, priority)` is the single decision point.
|
||||
- Pluggable `PreferenceStoreAdapter` so persistence is delegated to SurrealDB, Postgres, Redis, etc.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
PreferenceManager,
|
||||
type PreferenceStoreAdapter,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const store: PreferenceStoreAdapter = makeStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
if (await manager.canDeliver("user-1", "email", "normal")) {
|
||||
await router.send(...);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const manager = new PreferenceManager({
|
||||
store,
|
||||
defaultChannels: ["email", "push", "in_app"],
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
|
||||
await manager.setEnabled("user-1", "sms", true);
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 22,
|
||||
endHour: 7,
|
||||
days: [1, 2, 3, 4, 5], // weekdays
|
||||
});
|
||||
|
||||
// At dispatch time:
|
||||
for (const channel of selected) {
|
||||
if (await manager.canDeliver("user-1", channel, "high")) {
|
||||
await router.send({ recipient: "user-1", body: text, channel });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PreferenceManager(options: PreferenceManagerOptions)` — constructor.
|
||||
- `PreferenceManagerOptions` — `{ store, defaultChannels?, clock? }`.
|
||||
- `canDeliver(userId, channel, priority): Promise<boolean>`.
|
||||
- `getPreferences(userId): Promise<UserPreferences>`.
|
||||
- `setEnabled(userId, channel, enabled): Promise<void>`.
|
||||
- `setQuietHours(userId, quietHours | null): Promise<void>`.
|
||||
- `resetPreferences(userId): Promise<boolean>`.
|
||||
- `PreferenceStoreAdapter`, `UserPreferences`, `StoredPreferences`, `QuietHoursConfig`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Quiet-hours wrap around midnight when `startHour > endHour` (e.g. `22 → 7`). Priorities are ordered `low < normal < high < urgent`; if `urgentThreshold` is `"high"`, then both `high` and `urgent` punch through quiet hours. The day filter is opt-in — when omitted, quiet hours apply every day; when supplied, only the listed days (`0` = Sunday) trigger the window. `setQuietHours(userId, null)` clears the field; existing `enabledChannels`/`disabledChannels`/`urgentThreshold` are preserved. The store adapter is the single integration seam — the rest of the class is purely functional logic, so swapping persistence layers does not change behaviour.
|
||||
@@ -0,0 +1,419 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
PreferenceManager,
|
||||
type PreferenceStoreAdapter,
|
||||
type StoredPreferences,
|
||||
} from "./preference-manager.js";
|
||||
|
||||
function makeMemoryStore(): PreferenceStoreAdapter & {
|
||||
data: Map<string, StoredPreferences>;
|
||||
} {
|
||||
const data = new Map<string, StoredPreferences>();
|
||||
return {
|
||||
data,
|
||||
get: async (userId) => data.get(userId) ?? null,
|
||||
set: async (userId, prefs) => {
|
||||
data.set(userId, prefs);
|
||||
},
|
||||
delete: async (userId) => data.delete(userId) || false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PreferenceManager", () => {
|
||||
describe("getPreferences", () => {
|
||||
it("returns defaults when no preferences stored", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
|
||||
expect(prefs.userId).toBe("user-1");
|
||||
expect(prefs.enabledChannels.has("email")).toBe(true);
|
||||
expect(prefs.enabledChannels.has("in_app")).toBe(true);
|
||||
expect(prefs.disabledChannels.size).toBe(0);
|
||||
});
|
||||
|
||||
it("uses custom default channels", { timeout: 5000 }, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({
|
||||
store,
|
||||
defaultChannels: ["push", "sms"],
|
||||
});
|
||||
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
|
||||
expect(prefs.enabledChannels.has("push")).toBe(true);
|
||||
expect(prefs.enabledChannels.has("sms")).toBe(true);
|
||||
expect(prefs.enabledChannels.has("email")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns stored preferences when they exist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
store.data.set("user-1", {
|
||||
userId: "user-1",
|
||||
enabledChannels: ["email", "push"],
|
||||
disabledChannels: ["sms"],
|
||||
urgentThreshold: "high",
|
||||
});
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
|
||||
expect(prefs.enabledChannels.has("email")).toBe(true);
|
||||
expect(prefs.enabledChannels.has("push")).toBe(true);
|
||||
expect(prefs.disabledChannels.has("sms")).toBe(true);
|
||||
expect(prefs.urgentThreshold).toBe("high");
|
||||
});
|
||||
|
||||
it("returns stored quiet-hours when present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
store.data.set("user-1", {
|
||||
userId: "user-1",
|
||||
enabledChannels: ["email"],
|
||||
disabledChannels: [],
|
||||
quietHours: { startHour: 22, endHour: 7 },
|
||||
});
|
||||
const manager = new PreferenceManager({ store });
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
expect(prefs.quietHours).toEqual({ startHour: 22, endHour: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setEnabled", () => {
|
||||
it("enables a channel for a user", { timeout: 5000 }, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
await manager.setEnabled("user-1", "push", true);
|
||||
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
expect(prefs.enabledChannels.has("push")).toBe(true);
|
||||
expect(prefs.disabledChannels.has("push")).toBe(false);
|
||||
});
|
||||
|
||||
it("disables a channel for a user", { timeout: 5000 }, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
await manager.setEnabled("user-1", "email", false);
|
||||
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
expect(prefs.enabledChannels.has("email")).toBe(false);
|
||||
expect(prefs.disabledChannels.has("email")).toBe(true);
|
||||
});
|
||||
|
||||
it("moves channel from disabled to enabled", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
await manager.setEnabled("user-1", "sms", false);
|
||||
await manager.setEnabled("user-1", "sms", true);
|
||||
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
expect(prefs.enabledChannels.has("sms")).toBe(true);
|
||||
expect(prefs.disabledChannels.has("sms")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setQuietHours", () => {
|
||||
it("sets quiet hours for a user", { timeout: 5000 }, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 22,
|
||||
endHour: 7,
|
||||
});
|
||||
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
expect(prefs.quietHours).toEqual({ startHour: 22, endHour: 7 });
|
||||
});
|
||||
|
||||
it("clears quiet hours when set to null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 22,
|
||||
endHour: 7,
|
||||
});
|
||||
await manager.setQuietHours("user-1", null);
|
||||
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
expect(prefs.quietHours).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets quiet hours with specific days", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 23,
|
||||
endHour: 6,
|
||||
days: [1, 2, 3, 4, 5],
|
||||
});
|
||||
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
expect(prefs.quietHours?.days).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it("preserves urgentThreshold when setting quiet hours", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
store.data.set("user-1", {
|
||||
userId: "user-1",
|
||||
enabledChannels: ["email"],
|
||||
disabledChannels: [],
|
||||
urgentThreshold: "high",
|
||||
});
|
||||
const manager = new PreferenceManager({ store });
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 1,
|
||||
endHour: 2,
|
||||
});
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
expect(prefs.urgentThreshold).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canDeliver", () => {
|
||||
it("allows delivery on enabled channel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
const can = await manager.canDeliver("user-1", "email", "normal");
|
||||
expect(can).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks delivery on disabled channel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
await manager.setEnabled("user-1", "email", false);
|
||||
|
||||
const can = await manager.canDeliver("user-1", "email", "normal");
|
||||
expect(can).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks delivery on non-default non-enabled channel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
const can = await manager.canDeliver("user-1", "push", "normal");
|
||||
expect(can).toBe(false);
|
||||
});
|
||||
|
||||
it("allows delivery on default channel without explicit enable", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({
|
||||
store,
|
||||
defaultChannels: ["email", "push"],
|
||||
});
|
||||
|
||||
const can = await manager.canDeliver("user-1", "push", "normal");
|
||||
expect(can).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks low priority during quiet hours", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
// Pin the clock at noon UTC so the quiet-hours window is
|
||||
// deterministic regardless of when CI runs.
|
||||
const noonUtc = Date.UTC(2026, 0, 1, 12, 0, 0);
|
||||
const manager = new PreferenceManager({
|
||||
store,
|
||||
clock: () => noonUtc,
|
||||
});
|
||||
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 12,
|
||||
endHour: 14,
|
||||
});
|
||||
|
||||
const can = await manager.canDeliver("user-1", "email", "low");
|
||||
expect(can).toBe(false);
|
||||
});
|
||||
|
||||
it("allows urgent during quiet hours", { timeout: 5000 }, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const noonUtc = Date.UTC(2026, 0, 1, 12, 0, 0);
|
||||
const manager = new PreferenceManager({
|
||||
store,
|
||||
clock: () => noonUtc,
|
||||
});
|
||||
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 12,
|
||||
endHour: 14,
|
||||
});
|
||||
|
||||
const can = await manager.canDeliver("user-1", "email", "urgent");
|
||||
expect(can).toBe(true);
|
||||
});
|
||||
|
||||
it("allows normal priority outside quiet hours", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const noonUtc = Date.UTC(2026, 0, 1, 12, 0, 0);
|
||||
const manager = new PreferenceManager({
|
||||
store,
|
||||
clock: () => noonUtc,
|
||||
});
|
||||
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 16,
|
||||
endHour: 18,
|
||||
});
|
||||
|
||||
const can = await manager.canDeliver("user-1", "email", "normal");
|
||||
expect(can).toBe(true);
|
||||
});
|
||||
|
||||
it("respects custom urgentThreshold", { timeout: 5000 }, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const noonUtc = Date.UTC(2026, 0, 1, 12, 0, 0);
|
||||
|
||||
store.data.set("user-1", {
|
||||
userId: "user-1",
|
||||
enabledChannels: ["email", "in_app"],
|
||||
disabledChannels: [],
|
||||
quietHours: { startHour: 12, endHour: 14 },
|
||||
urgentThreshold: "high",
|
||||
});
|
||||
const manager = new PreferenceManager({
|
||||
store,
|
||||
clock: () => noonUtc,
|
||||
});
|
||||
|
||||
const canHigh = await manager.canDeliver("user-1", "email", "high");
|
||||
expect(canHigh).toBe(true);
|
||||
|
||||
const canNormal = await manager.canDeliver(
|
||||
"user-1",
|
||||
"email",
|
||||
"normal"
|
||||
);
|
||||
expect(canNormal).toBe(false);
|
||||
});
|
||||
|
||||
it("respects quiet-hours day filter (today excluded)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
// 2026-01-01 is a Thursday (day 4); set quiet-hours for days
|
||||
// [1,2] (Mon, Tue) so today is excluded.
|
||||
const noonThu = Date.UTC(2026, 0, 1, 12, 0, 0);
|
||||
const manager = new PreferenceManager({
|
||||
store,
|
||||
clock: () => noonThu,
|
||||
});
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 12,
|
||||
endHour: 14,
|
||||
days: [1, 2],
|
||||
});
|
||||
|
||||
const can = await manager.canDeliver("user-1", "email", "low");
|
||||
expect(can).toBe(true);
|
||||
});
|
||||
|
||||
it("handles wrap-around quiet hours (22→7)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const elevenPm = Date.UTC(2026, 0, 1, 23, 0, 0);
|
||||
const manager = new PreferenceManager({
|
||||
store,
|
||||
clock: () => elevenPm,
|
||||
});
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 22,
|
||||
endHour: 7,
|
||||
});
|
||||
const can = await manager.canDeliver("user-1", "email", "low");
|
||||
expect(can).toBe(false);
|
||||
});
|
||||
|
||||
it("normal priority hour outside wrap-around window", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const noon = Date.UTC(2026, 0, 1, 12, 0, 0);
|
||||
const manager = new PreferenceManager({
|
||||
store,
|
||||
clock: () => noon,
|
||||
});
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 22,
|
||||
endHour: 7,
|
||||
});
|
||||
const can = await manager.canDeliver("user-1", "email", "low");
|
||||
expect(can).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default clock", () => {
|
||||
it("uses the built-in clock when none injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
// Set quiet-hours so canDeliver invokes isQuietHours and
|
||||
// exercises the default Clock thunk.
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 0,
|
||||
endHour: 24 - 1,
|
||||
});
|
||||
// Assertion shape only — wall-clock dependent boolean.
|
||||
const can = await manager.canDeliver("user-1", "email", "normal");
|
||||
expect(typeof can).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetPreferences", () => {
|
||||
it("deletes stored preferences", { timeout: 5000 }, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
await manager.setEnabled("user-1", "sms", true);
|
||||
await manager.resetPreferences("user-1");
|
||||
|
||||
const prefs = await manager.getPreferences("user-1");
|
||||
expect(prefs.enabledChannels.has("email")).toBe(true);
|
||||
expect(prefs.enabledChannels.has("in_app")).toBe(true);
|
||||
expect(prefs.enabledChannels.has("sms")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when nothing to delete", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const store = makeMemoryStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
const ok = await manager.resetPreferences("ghost");
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type {
|
||||
NotificationChannel,
|
||||
NotificationPriority,
|
||||
} from "../types/notification.types.js";
|
||||
|
||||
/** Quiet-hours configuration. */
|
||||
export interface QuietHoursConfig {
|
||||
/** Start hour (0-23 UTC). */
|
||||
readonly startHour: number;
|
||||
/** End hour (0-23 UTC). */
|
||||
readonly endHour: number;
|
||||
/** Days of week to apply (0=Sun, 6=Sat). Default: all days. */
|
||||
readonly days?: readonly number[];
|
||||
}
|
||||
|
||||
/** Resolved user preferences. */
|
||||
export interface UserPreferences {
|
||||
readonly userId: string;
|
||||
readonly enabledChannels: ReadonlySet<NotificationChannel>;
|
||||
readonly disabledChannels: ReadonlySet<NotificationChannel>;
|
||||
readonly quietHours?: QuietHoursConfig;
|
||||
readonly urgentThreshold?: NotificationPriority;
|
||||
}
|
||||
|
||||
/** Serialised form of user preferences. */
|
||||
export interface StoredPreferences {
|
||||
userId: string;
|
||||
enabledChannels: NotificationChannel[];
|
||||
disabledChannels: NotificationChannel[];
|
||||
quietHours?: QuietHoursConfig;
|
||||
urgentThreshold?: NotificationPriority;
|
||||
}
|
||||
|
||||
/** Storage adapter for user preferences. */
|
||||
export interface PreferenceStoreAdapter {
|
||||
get(userId: string): Promise<StoredPreferences | null>;
|
||||
set(userId: string, prefs: StoredPreferences): Promise<void>;
|
||||
delete(userId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Constructor options for `PreferenceManager`. */
|
||||
export interface PreferenceManagerOptions {
|
||||
readonly store: PreferenceStoreAdapter;
|
||||
readonly defaultChannels?: readonly NotificationChannel[];
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const PRIORITY_ORDER: Readonly<Record<NotificationPriority, number>> = {
|
||||
low: 0,
|
||||
normal: 1,
|
||||
high: 2,
|
||||
urgent: 3,
|
||||
};
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Manages per-user notification preferences with opt-in / opt-out and
|
||||
* quiet-hours support. Implements the decision at every send-time:
|
||||
* "should this notification reach this user on this channel right
|
||||
* now?". Storage is pluggable via `PreferenceStoreAdapter`.
|
||||
*/
|
||||
export class PreferenceManager {
|
||||
private readonly store: PreferenceStoreAdapter;
|
||||
private readonly defaults: ReadonlySet<NotificationChannel>;
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(options: PreferenceManagerOptions) {
|
||||
this.store = options.store;
|
||||
this.defaults = new Set(options.defaultChannels ?? ["email", "in_app"]);
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
/** Decide whether a notification may reach the user on this channel. */
|
||||
public async canDeliver(
|
||||
userId: string,
|
||||
channel: NotificationChannel,
|
||||
priority: NotificationPriority
|
||||
): Promise<boolean> {
|
||||
const prefs = await this.getPreferences(userId);
|
||||
|
||||
if (prefs.disabledChannels.has(channel)) return false;
|
||||
if (
|
||||
!prefs.enabledChannels.has(channel) &&
|
||||
!this.defaults.has(channel)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
prefs.quietHours !== undefined &&
|
||||
this.isQuietHours(prefs.quietHours)
|
||||
) {
|
||||
const threshold = prefs.urgentThreshold ?? "urgent";
|
||||
if (PRIORITY_ORDER[priority] < PRIORITY_ORDER[threshold]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Get user preferences (returns defaults if none stored). */
|
||||
public async getPreferences(userId: string): Promise<UserPreferences> {
|
||||
const stored = await this.store.get(userId);
|
||||
if (stored === null) {
|
||||
return {
|
||||
userId,
|
||||
enabledChannels: new Set(this.defaults),
|
||||
disabledChannels: new Set(),
|
||||
};
|
||||
}
|
||||
const result: {
|
||||
-readonly [K in keyof UserPreferences]: UserPreferences[K];
|
||||
} = {
|
||||
userId: stored.userId,
|
||||
enabledChannels: new Set(stored.enabledChannels),
|
||||
disabledChannels: new Set(stored.disabledChannels),
|
||||
};
|
||||
if (stored.quietHours !== undefined) {
|
||||
result.quietHours = stored.quietHours;
|
||||
}
|
||||
if (stored.urgentThreshold !== undefined) {
|
||||
result.urgentThreshold = stored.urgentThreshold;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Enable or disable a channel for a user. */
|
||||
public async setEnabled(
|
||||
userId: string,
|
||||
channel: NotificationChannel,
|
||||
enabled: boolean
|
||||
): Promise<void> {
|
||||
const stored = await this.store.get(userId);
|
||||
const current: StoredPreferences = stored ?? {
|
||||
userId,
|
||||
enabledChannels: [...this.defaults],
|
||||
disabledChannels: [],
|
||||
};
|
||||
|
||||
const enabledSet = new Set(current.enabledChannels);
|
||||
const disabledSet = new Set(current.disabledChannels);
|
||||
|
||||
if (enabled) {
|
||||
enabledSet.add(channel);
|
||||
disabledSet.delete(channel);
|
||||
} else {
|
||||
enabledSet.delete(channel);
|
||||
disabledSet.add(channel);
|
||||
}
|
||||
|
||||
await this.store.set(userId, {
|
||||
...current,
|
||||
enabledChannels: [...enabledSet],
|
||||
disabledChannels: [...disabledSet],
|
||||
});
|
||||
}
|
||||
|
||||
/** Set or clear quiet-hours for a user. */
|
||||
public async setQuietHours(
|
||||
userId: string,
|
||||
quietHours: QuietHoursConfig | null
|
||||
): Promise<void> {
|
||||
const stored = await this.store.get(userId);
|
||||
const current: StoredPreferences = stored ?? {
|
||||
userId,
|
||||
enabledChannels: [...this.defaults],
|
||||
disabledChannels: [],
|
||||
};
|
||||
const next: StoredPreferences = {
|
||||
userId: current.userId,
|
||||
enabledChannels: current.enabledChannels,
|
||||
disabledChannels: current.disabledChannels,
|
||||
};
|
||||
if (current.urgentThreshold !== undefined) {
|
||||
next.urgentThreshold = current.urgentThreshold;
|
||||
}
|
||||
if (quietHours !== null) {
|
||||
next.quietHours = quietHours;
|
||||
}
|
||||
await this.store.set(userId, next);
|
||||
}
|
||||
|
||||
/** Delete all preferences for a user (resets to defaults). */
|
||||
public async resetPreferences(userId: string): Promise<boolean> {
|
||||
return this.store.delete(userId);
|
||||
}
|
||||
|
||||
private isQuietHours(config: QuietHoursConfig): boolean {
|
||||
const now = new Date(this.clock());
|
||||
const hour = now.getUTCHours();
|
||||
const day = now.getUTCDay();
|
||||
|
||||
if (config.days !== undefined && !config.days.includes(day)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.startHour <= config.endHour) {
|
||||
return hour >= config.startHour && hour < config.endHour;
|
||||
}
|
||||
return hour >= config.startHour || hour < config.endHour;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# `channel-rate-limiter` — Sliding-window rate limits
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter. Two independent buckets are maintained: a per-channel bucket (every send against that channel counts) and a per-user bucket (every send for that user counts regardless of channel). `checkAndRecord(channel, userId)` consults both, throws `RateLimitExceededError` on overflow, otherwise records the send and returns.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-channel and per-user limits with independent `{ maxCount, windowMs }` rules.
|
||||
- Sliding window — entries older than `windowMs` are discarded on every check.
|
||||
- `isAllowed` probe — non-recording check for UX-level pre-flight.
|
||||
- `getChannelRemaining` / `getUserRemaining` for headers like `X-RateLimit-Remaining`.
|
||||
- Injectable `Clock` so tests advance time without sleeping.
|
||||
- Universal — no Node/DOM-specific APIs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ChannelRateLimiter } from "@suaveplan/notification-channel";
|
||||
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setChannelLimit("email", { maxCount: 100, windowMs: 60_000 });
|
||||
limiter.setUserLimit("user-42", { maxCount: 5, windowMs: 60_000 });
|
||||
|
||||
try {
|
||||
limiter.checkAndRecord("email", "user-42");
|
||||
} catch (err) {
|
||||
/* RateLimitExceededError */
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const limiter = new ChannelRateLimiter(() => clock.now());
|
||||
limiter.setChannelLimit("sms", { maxCount: 10, windowMs: 60_000 });
|
||||
|
||||
// Pre-flight without recording:
|
||||
if (!limiter.isAllowed("sms", userId)) {
|
||||
return reply.code(429).send({
|
||||
remaining: limiter.getChannelRemaining("sms"),
|
||||
});
|
||||
}
|
||||
limiter.checkAndRecord("sms", userId);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChannelRateLimiter(clock?: Clock)` — constructor.
|
||||
- `setChannelLimit(channel, rule)`, `setUserLimit(userId, rule)` — install rules.
|
||||
- `removeChannelLimit(channel)`, `removeUserLimit(userId)` — drop rules and their windows.
|
||||
- `checkAndRecord(channel, userId)` — throws on overflow.
|
||||
- `isAllowed(channel, userId)` — boolean probe.
|
||||
- `getChannelRemaining(channel)`, `getUserRemaining(userId)` — current capacity (or `Infinity` when no rule).
|
||||
- `RateLimitRule` — `{ maxCount, windowMs }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The window is sliding, not fixed — entries are filtered against `now - timestamp < windowMs` on every check. This is more accurate than fixed buckets at the cost of `O(n)` filtering on every check where `n` is the number of entries in the window; for buckets with `maxCount` in the low hundreds this is negligible. `recordChannel` / `recordUser` are no-ops when no rule is installed, so the `WindowEntry` arrays stay empty when the bucket has no limit. The injected `Clock` only governs the limiter's view of "now" — wall-clock drift between server and limiter does not exist because the limiter never compares its timestamps to anything external.
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NotificationChannelError } from "../error-codes/error-codes.js";
|
||||
import { ChannelRateLimiter } from "./channel-rate-limiter.js";
|
||||
|
||||
describe("ChannelRateLimiter", () => {
|
||||
describe("channel limits", () => {
|
||||
it("allows sends within limit", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setChannelLimit("email", { maxCount: 3, windowMs: 60_000 });
|
||||
expect(() =>
|
||||
limiter.checkAndRecord("email", "user1")
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
limiter.checkAndRecord("email", "user2")
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
limiter.checkAndRecord("email", "user3")
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when channel limit exceeded", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setChannelLimit("email", { maxCount: 2, windowMs: 60_000 });
|
||||
limiter.checkAndRecord("email", "user1");
|
||||
limiter.checkAndRecord("email", "user2");
|
||||
expect(() => limiter.checkAndRecord("email", "user3")).toThrow(
|
||||
NotificationChannelError
|
||||
);
|
||||
});
|
||||
|
||||
it("allows sends after window expires", { timeout: 5000 }, () => {
|
||||
let now = 1_000_000;
|
||||
const limiter = new ChannelRateLimiter(() => now);
|
||||
limiter.setChannelLimit("email", { maxCount: 1, windowMs: 1_000 });
|
||||
limiter.checkAndRecord("email", "user1");
|
||||
now += 1_001;
|
||||
expect(() =>
|
||||
limiter.checkAndRecord("email", "user2")
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not limit when no limit set", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(() =>
|
||||
limiter.checkAndRecord("email", `user${i}`)
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("removes channel limit", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setChannelLimit("email", { maxCount: 1, windowMs: 60_000 });
|
||||
limiter.checkAndRecord("email", "user1");
|
||||
limiter.removeChannelLimit("email");
|
||||
expect(() =>
|
||||
limiter.checkAndRecord("email", "user2")
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("user limits", () => {
|
||||
it("allows sends within user limit", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setUserLimit("user1", { maxCount: 2, windowMs: 60_000 });
|
||||
expect(() =>
|
||||
limiter.checkAndRecord("email", "user1")
|
||||
).not.toThrow();
|
||||
expect(() => limiter.checkAndRecord("sms", "user1")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when user limit exceeded", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setUserLimit("user1", { maxCount: 1, windowMs: 60_000 });
|
||||
limiter.checkAndRecord("email", "user1");
|
||||
expect(() => limiter.checkAndRecord("sms", "user1")).toThrow(
|
||||
NotificationChannelError
|
||||
);
|
||||
});
|
||||
|
||||
it("removes user limit", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setUserLimit("user1", { maxCount: 1, windowMs: 60_000 });
|
||||
limiter.checkAndRecord("email", "user1");
|
||||
limiter.removeUserLimit("user1");
|
||||
expect(() =>
|
||||
limiter.checkAndRecord("email", "user1")
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowed", () => {
|
||||
it("returns true when within limits", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setChannelLimit("email", { maxCount: 5, windowMs: 60_000 });
|
||||
expect(limiter.isAllowed("email", "user1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when channel limit reached", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setChannelLimit("email", { maxCount: 1, windowMs: 60_000 });
|
||||
limiter.checkAndRecord("email", "user1");
|
||||
expect(limiter.isAllowed("email", "user2")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user limit reached", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setUserLimit("user1", { maxCount: 1, windowMs: 60_000 });
|
||||
limiter.checkAndRecord("email", "user1");
|
||||
expect(limiter.isAllowed("sms", "user1")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for an unconfigured user", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
expect(limiter.isAllowed("email", "user-without-limit")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remaining capacity", () => {
|
||||
it("returns remaining channel capacity", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setChannelLimit("email", { maxCount: 5, windowMs: 60_000 });
|
||||
limiter.checkAndRecord("email", "user1");
|
||||
limiter.checkAndRecord("email", "user2");
|
||||
expect(limiter.getChannelRemaining("email")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns Infinity when no limit set", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
expect(limiter.getChannelRemaining("email")).toBe(
|
||||
Number.POSITIVE_INFINITY
|
||||
);
|
||||
});
|
||||
|
||||
it("returns remaining user capacity", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setUserLimit("user1", { maxCount: 3, windowMs: 60_000 });
|
||||
limiter.checkAndRecord("email", "user1");
|
||||
expect(limiter.getUserRemaining("user1")).toBe(2);
|
||||
});
|
||||
|
||||
it("returns Infinity for user without limit", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
expect(limiter.getUserRemaining("user1")).toBe(
|
||||
Number.POSITIVE_INFINITY
|
||||
);
|
||||
});
|
||||
|
||||
it("returns zero when capacity exhausted", { timeout: 5000 }, () => {
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setChannelLimit("email", { maxCount: 1, windowMs: 60_000 });
|
||||
limiter.checkAndRecord("email", "user1");
|
||||
expect(limiter.getChannelRemaining("email")).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { RateLimitExceededError } from "../error-codes/error-codes.js";
|
||||
import type { NotificationChannel } from "../types/notification.types.js";
|
||||
|
||||
/** Configuration for a rate-limit rule. */
|
||||
export interface RateLimitRule {
|
||||
/** Maximum number of notifications allowed in the window. */
|
||||
readonly maxCount: number;
|
||||
/** Window size in milliseconds. */
|
||||
readonly windowMs: number;
|
||||
}
|
||||
|
||||
interface WindowEntry {
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Sliding-window rate limiter for notification channels. Supports
|
||||
* per-channel limits (every send against that channel counts toward
|
||||
* the channel's bucket) and per-user limits (every send for that user
|
||||
* counts toward the user's bucket, regardless of channel). Both
|
||||
* buckets accept an injectable `Clock` so tests can advance time
|
||||
* without sleeping.
|
||||
*/
|
||||
export class ChannelRateLimiter {
|
||||
private readonly channelLimits = new Map<
|
||||
NotificationChannel,
|
||||
RateLimitRule
|
||||
>();
|
||||
private readonly userLimits = new Map<string, RateLimitRule>();
|
||||
private readonly channelWindows = new Map<
|
||||
NotificationChannel,
|
||||
WindowEntry[]
|
||||
>();
|
||||
private readonly userWindows = new Map<string, WindowEntry[]>();
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(clock: Clock = defaultClock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** Set a rate limit for a specific channel. */
|
||||
public setChannelLimit(
|
||||
channel: NotificationChannel,
|
||||
rule: RateLimitRule
|
||||
): void {
|
||||
this.channelLimits.set(channel, rule);
|
||||
}
|
||||
|
||||
/** Set a rate limit for a specific user. */
|
||||
public setUserLimit(userId: string, rule: RateLimitRule): void {
|
||||
this.userLimits.set(userId, rule);
|
||||
}
|
||||
|
||||
/** Remove a channel rate limit. */
|
||||
public removeChannelLimit(channel: NotificationChannel): void {
|
||||
this.channelLimits.delete(channel);
|
||||
this.channelWindows.delete(channel);
|
||||
}
|
||||
|
||||
/** Remove a user rate limit. */
|
||||
public removeUserLimit(userId: string): void {
|
||||
this.userLimits.delete(userId);
|
||||
this.userWindows.delete(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check + record a send attempt against both buckets. Throws
|
||||
* `RateLimitExceededError` when either bucket would overflow.
|
||||
*/
|
||||
public checkAndRecord(channel: NotificationChannel, userId: string): void {
|
||||
const now = this.clock();
|
||||
this.checkChannelLimit(channel, now);
|
||||
this.checkUserLimit(channel, userId, now);
|
||||
this.recordChannel(channel, now);
|
||||
this.recordUser(userId, now);
|
||||
}
|
||||
|
||||
/** Probe whether `checkAndRecord` would succeed (does not record). */
|
||||
public isAllowed(channel: NotificationChannel, userId: string): boolean {
|
||||
const now = this.clock();
|
||||
return (
|
||||
this.isChannelAllowed(channel, now) &&
|
||||
this.isUserAllowed(userId, now)
|
||||
);
|
||||
}
|
||||
|
||||
/** Remaining capacity in the channel bucket. */
|
||||
public getChannelRemaining(channel: NotificationChannel): number {
|
||||
const rule = this.channelLimits.get(channel);
|
||||
if (rule === undefined) return Number.POSITIVE_INFINITY;
|
||||
|
||||
const now = this.clock();
|
||||
const entries = this.getValidEntries(
|
||||
this.channelWindows.get(channel),
|
||||
now,
|
||||
rule.windowMs
|
||||
);
|
||||
return Math.max(0, rule.maxCount - entries.length);
|
||||
}
|
||||
|
||||
/** Remaining capacity in the user bucket. */
|
||||
public getUserRemaining(userId: string): number {
|
||||
const rule = this.userLimits.get(userId);
|
||||
if (rule === undefined) return Number.POSITIVE_INFINITY;
|
||||
|
||||
const now = this.clock();
|
||||
const entries = this.getValidEntries(
|
||||
this.userWindows.get(userId),
|
||||
now,
|
||||
rule.windowMs
|
||||
);
|
||||
return Math.max(0, rule.maxCount - entries.length);
|
||||
}
|
||||
|
||||
private checkChannelLimit(channel: NotificationChannel, now: number): void {
|
||||
const rule = this.channelLimits.get(channel);
|
||||
if (rule === undefined) return;
|
||||
|
||||
const entries = this.getValidEntries(
|
||||
this.channelWindows.get(channel),
|
||||
now,
|
||||
rule.windowMs
|
||||
);
|
||||
if (entries.length >= rule.maxCount) {
|
||||
throw new RateLimitExceededError({
|
||||
context: { channel, scope: "channel" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private checkUserLimit(
|
||||
channel: NotificationChannel,
|
||||
userId: string,
|
||||
now: number
|
||||
): void {
|
||||
const rule = this.userLimits.get(userId);
|
||||
if (rule === undefined) return;
|
||||
|
||||
const entries = this.getValidEntries(
|
||||
this.userWindows.get(userId),
|
||||
now,
|
||||
rule.windowMs
|
||||
);
|
||||
if (entries.length >= rule.maxCount) {
|
||||
throw new RateLimitExceededError({
|
||||
context: { channel, userId, scope: "user" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private isChannelAllowed(
|
||||
channel: NotificationChannel,
|
||||
now: number
|
||||
): boolean {
|
||||
const rule = this.channelLimits.get(channel);
|
||||
if (rule === undefined) return true;
|
||||
|
||||
const entries = this.getValidEntries(
|
||||
this.channelWindows.get(channel),
|
||||
now,
|
||||
rule.windowMs
|
||||
);
|
||||
return entries.length < rule.maxCount;
|
||||
}
|
||||
|
||||
private isUserAllowed(userId: string, now: number): boolean {
|
||||
const rule = this.userLimits.get(userId);
|
||||
if (rule === undefined) return true;
|
||||
|
||||
const entries = this.getValidEntries(
|
||||
this.userWindows.get(userId),
|
||||
now,
|
||||
rule.windowMs
|
||||
);
|
||||
return entries.length < rule.maxCount;
|
||||
}
|
||||
|
||||
private recordChannel(channel: NotificationChannel, now: number): void {
|
||||
if (!this.channelLimits.has(channel)) return;
|
||||
const entries = this.channelWindows.get(channel) ?? [];
|
||||
entries.push({ timestamp: now });
|
||||
this.channelWindows.set(channel, entries);
|
||||
}
|
||||
|
||||
private recordUser(userId: string, now: number): void {
|
||||
if (!this.userLimits.has(userId)) return;
|
||||
const entries = this.userWindows.get(userId) ?? [];
|
||||
entries.push({ timestamp: now });
|
||||
this.userWindows.set(userId, entries);
|
||||
}
|
||||
|
||||
private getValidEntries(
|
||||
entries: WindowEntry[] | undefined,
|
||||
now: number,
|
||||
windowMs: number
|
||||
): WindowEntry[] {
|
||||
if (entries === undefined) return [];
|
||||
return entries.filter((e) => now - e.timestamp < windowMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# `delivery-retry` — Retry wrapper with dead-letter queue
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backoff and surfaces exhausted attempts to a dead-letter queue. The wrapper:
|
||||
|
||||
1. Calls the wrapped channel up to `maxRetries + 1` total times.
|
||||
2. Sleeps `baseDelayMs * 2^(attempt-1)` (capped at `maxDelayMs`) between attempts via an injectable `SleepFn`.
|
||||
3. On exhaustion, pushes a `DeadLetterEntry`, fires an optional handler, and throws `DeliveryFailedError`.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `{ maxRetries, baseDelayMs, maxDelayMs }`.
|
||||
- Exponential backoff with cap.
|
||||
- In-process dead-letter queue with `getDeadLetters` / `clearDeadLetters` surface.
|
||||
- `onDeadLetter(handler)` callback for forwarding to a real queue (Redis, SurrealDB) or pager-duty.
|
||||
- Injectable `Clock` and `SleepFn` for deterministic tests.
|
||||
- Throws `DeliveryFailedError` on exhaustion so the router emits `notification.failed`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DeliveryRetry } from "@suaveplan/notification-channel";
|
||||
|
||||
const retry = new DeliveryRetry({ maxRetries: 3, baseDelayMs: 500 });
|
||||
retry.onDeadLetter((entry) => pagerDuty.alert(entry));
|
||||
|
||||
try {
|
||||
await retry.sendWithRetry(emailChannel, notification);
|
||||
} catch (err) {
|
||||
/* DeliveryFailedError */
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const retry = new DeliveryRetry(
|
||||
{ maxRetries: 5, baseDelayMs: 200, maxDelayMs: 10_000 },
|
||||
() => clock.now(),
|
||||
async (ms) => fakeTimers.tick(ms)
|
||||
);
|
||||
|
||||
retry.onDeadLetter(async (entry) => {
|
||||
await deadLetterQueue.push(entry);
|
||||
});
|
||||
|
||||
for (const n of notifications) {
|
||||
try {
|
||||
await retry.sendWithRetry(channel, n);
|
||||
} catch {
|
||||
/* already dead-lettered */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DeliveryRetry(config?, clock?, sleep?)` — constructor.
|
||||
- `RetryConfig` — `{ maxRetries, baseDelayMs, maxDelayMs? }`.
|
||||
- `DeadLetterEntry` — `{ notification, channel, attempts, lastError, failedAt }`.
|
||||
- `DeadLetterHandler`, `SleepFn` — callable types.
|
||||
- `sendWithRetry(channel, notification): Promise<DeliveryResult>` — throws `DeliveryFailedError` on exhaustion.
|
||||
- `onDeadLetter(handler)`, `getDeadLetters()`, `clearDeadLetters()`, `calculateDelay(attempt)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The retry counter is 0-indexed: `attempt === 0` is the first call (no delay), `attempt === 1..maxRetries` incur a backoff before the call. `calculateDelay` is public so test suites can pin the schedule against expected values. The injectable `SleepFn` defaults to `setTimeout` — production should keep the default; tests SHOULD inject a no-op sleeper so the suite finishes in milliseconds. The dead-letter queue is in-process — for production durability, register an `onDeadLetter` handler that forwards to a persistent queue. The throw on exhaustion uses `DeliveryFailedError` with a `cause: Error(lastError)` so the upstream `EventBus` listener can introspect the underlying error string.
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
} from "../channels/delivery-channel.js";
|
||||
import { NotificationChannelError } from "../error-codes/error-codes.js";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import { DeliveryRetry } from "./delivery-retry.js";
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: "notif_1",
|
||||
channel: "email",
|
||||
recipient: "user@example.com",
|
||||
body: "Test",
|
||||
priority: "normal",
|
||||
status: "sending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeChannel(results: DeliveryResult[]): DeliveryChannel {
|
||||
let callIndex = 0;
|
||||
return {
|
||||
name: "test",
|
||||
canSend: () => true,
|
||||
send: async () => {
|
||||
const r = results[callIndex];
|
||||
callIndex++;
|
||||
if (r === undefined) {
|
||||
throw new Error("ran out of scripted results");
|
||||
}
|
||||
return r;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function successResult(): DeliveryResult {
|
||||
return {
|
||||
success: true,
|
||||
channel: "test",
|
||||
messageId: "msg_1",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function failResult(error = "Failed"): DeliveryResult {
|
||||
return {
|
||||
success: false,
|
||||
channel: "test",
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const noSleep = async (_ms: number): Promise<void> => {
|
||||
/* tests skip delays */
|
||||
};
|
||||
|
||||
describe("DeliveryRetry", () => {
|
||||
it("returns result on first success", { timeout: 5000 }, async () => {
|
||||
const retry = new DeliveryRetry(
|
||||
{ maxRetries: 3, baseDelayMs: 1 },
|
||||
() => 0,
|
||||
noSleep
|
||||
);
|
||||
const channel = makeChannel([successResult()]);
|
||||
const result = await retry.sendWithRetry(channel, makeNotification());
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("retries on failure then succeeds", { timeout: 5000 }, async () => {
|
||||
const retry = new DeliveryRetry(
|
||||
{ maxRetries: 3, baseDelayMs: 1 },
|
||||
() => 0,
|
||||
noSleep
|
||||
);
|
||||
const channel = makeChannel([
|
||||
failResult(),
|
||||
failResult(),
|
||||
successResult(),
|
||||
]);
|
||||
const result = await retry.sendWithRetry(channel, makeNotification());
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("throws after exhausting retries", { timeout: 5000 }, async () => {
|
||||
const retry = new DeliveryRetry(
|
||||
{ maxRetries: 2, baseDelayMs: 1 },
|
||||
() => 0,
|
||||
noSleep
|
||||
);
|
||||
const channel = makeChannel([
|
||||
failResult("err1"),
|
||||
failResult("err2"),
|
||||
failResult("err3"),
|
||||
]);
|
||||
await expect(
|
||||
retry.sendWithRetry(channel, makeNotification())
|
||||
).rejects.toThrow(NotificationChannelError);
|
||||
});
|
||||
|
||||
it("adds to dead letter queue on exhaustion", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const retry = new DeliveryRetry(
|
||||
{ maxRetries: 1, baseDelayMs: 1 },
|
||||
() => 0,
|
||||
noSleep
|
||||
);
|
||||
const channel = makeChannel([failResult("fail1"), failResult("fail2")]);
|
||||
try {
|
||||
await retry.sendWithRetry(channel, makeNotification());
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
const deadLetters = retry.getDeadLetters();
|
||||
expect(deadLetters).toHaveLength(1);
|
||||
expect(deadLetters[0]?.lastError).toBe("fail2");
|
||||
expect(deadLetters[0]?.attempts).toBe(2);
|
||||
});
|
||||
|
||||
it("calls dead letter handler", { timeout: 5000 }, async () => {
|
||||
const retry = new DeliveryRetry(
|
||||
{ maxRetries: 0, baseDelayMs: 1 },
|
||||
() => 0,
|
||||
noSleep
|
||||
);
|
||||
let handlerCalled = false;
|
||||
retry.onDeadLetter(() => {
|
||||
handlerCalled = true;
|
||||
});
|
||||
const channel = makeChannel([failResult()]);
|
||||
try {
|
||||
await retry.sendWithRetry(channel, makeNotification());
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
expect(handlerCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("clears dead letters", { timeout: 5000 }, async () => {
|
||||
const retry = new DeliveryRetry(
|
||||
{ maxRetries: 0, baseDelayMs: 1 },
|
||||
() => 0,
|
||||
noSleep
|
||||
);
|
||||
const channel = makeChannel([failResult()]);
|
||||
try {
|
||||
await retry.sendWithRetry(channel, makeNotification());
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
expect(retry.getDeadLetters()).toHaveLength(1);
|
||||
retry.clearDeadLetters();
|
||||
expect(retry.getDeadLetters()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses 'Unknown delivery error' when result has no error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const retry = new DeliveryRetry(
|
||||
{ maxRetries: 0, baseDelayMs: 1 },
|
||||
() => 0,
|
||||
noSleep
|
||||
);
|
||||
const channel = makeChannel([
|
||||
{
|
||||
success: false,
|
||||
channel: "test",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
try {
|
||||
await retry.sendWithRetry(channel, makeNotification());
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
const deadLetters = retry.getDeadLetters();
|
||||
expect(deadLetters[0]?.lastError).toBe("Unknown delivery error");
|
||||
});
|
||||
|
||||
describe("calculateDelay", () => {
|
||||
it("calculates exponential backoff", { timeout: 5000 }, () => {
|
||||
const retry = new DeliveryRetry({
|
||||
maxRetries: 3,
|
||||
baseDelayMs: 1_000,
|
||||
});
|
||||
expect(retry.calculateDelay(1)).toBe(1_000);
|
||||
expect(retry.calculateDelay(2)).toBe(2_000);
|
||||
expect(retry.calculateDelay(3)).toBe(4_000);
|
||||
});
|
||||
|
||||
it("caps delay at maxDelayMs", { timeout: 5000 }, () => {
|
||||
const retry = new DeliveryRetry({
|
||||
maxRetries: 10,
|
||||
baseDelayMs: 1_000,
|
||||
maxDelayMs: 5_000,
|
||||
});
|
||||
expect(retry.calculateDelay(10)).toBe(5_000);
|
||||
});
|
||||
|
||||
it("uses default maxDelayMs of 30_000", { timeout: 5000 }, () => {
|
||||
const retry = new DeliveryRetry({
|
||||
maxRetries: 10,
|
||||
baseDelayMs: 10_000,
|
||||
});
|
||||
expect(retry.calculateDelay(5)).toBe(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default config when none provided", { timeout: 5000 }, () => {
|
||||
const retry = new DeliveryRetry();
|
||||
expect(retry.calculateDelay(1)).toBe(1_000);
|
||||
});
|
||||
|
||||
it("uses default clock when none injected", { timeout: 5000 }, async () => {
|
||||
const retry = new DeliveryRetry({ maxRetries: 0, baseDelayMs: 1 });
|
||||
const channel = makeChannel([failResult("boom")]);
|
||||
try {
|
||||
await retry.sendWithRetry(channel, makeNotification());
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
const entry = retry.getDeadLetters()[0];
|
||||
expect(entry?.failedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("default sleeper runs without throwing", { timeout: 5000 }, async () => {
|
||||
// Cover the default sleep code path; 1ms keeps the test fast.
|
||||
const retry = new DeliveryRetry({ maxRetries: 1, baseDelayMs: 1 });
|
||||
const channel = makeChannel([failResult(), successResult()]);
|
||||
const result = await retry.sendWithRetry(channel, makeNotification());
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
} from "../channels/delivery-channel.js";
|
||||
import { DeliveryFailedError } from "../error-codes/error-codes.js";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
|
||||
/** Configuration for retry behaviour. */
|
||||
export interface RetryConfig {
|
||||
/** Maximum number of retry attempts (default: 3). */
|
||||
readonly maxRetries: number;
|
||||
/** Base delay in ms for exponential backoff (default: 1_000). */
|
||||
readonly baseDelayMs: number;
|
||||
/** Maximum delay cap in ms (default: 30_000). */
|
||||
readonly maxDelayMs?: number;
|
||||
}
|
||||
|
||||
/** Record of a failed delivery that exhausted retries. */
|
||||
export interface DeadLetterEntry {
|
||||
readonly notification: Notification;
|
||||
readonly channel: string;
|
||||
readonly attempts: number;
|
||||
readonly lastError: string;
|
||||
readonly failedAt: string;
|
||||
}
|
||||
|
||||
/** Callback for dead-letter events. */
|
||||
export type DeadLetterHandler = (entry: DeadLetterEntry) => void;
|
||||
|
||||
/** Sleep adapter; tests inject a no-op or fake-timer-driven sleeper. */
|
||||
export type SleepFn = (ms: number) => Promise<void>;
|
||||
|
||||
const DEFAULT_CONFIG: RetryConfig = {
|
||||
maxRetries: 3,
|
||||
baseDelayMs: 1_000,
|
||||
maxDelayMs: 30_000,
|
||||
};
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
const defaultSleep: SleepFn = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Wraps a `DeliveryChannel` with exponential-backoff retry and a
|
||||
* dead-letter queue. The wrapper:
|
||||
*
|
||||
* 1. Calls the channel up to `maxRetries + 1` times.
|
||||
* 2. Sleeps `baseDelayMs * 2^(attempt-1)` (capped at `maxDelayMs`)
|
||||
* between attempts via the injectable `SleepFn`.
|
||||
* 3. On exhaustion, pushes a `DeadLetterEntry`, fires the optional
|
||||
* handler, and throws `DeliveryFailedError`.
|
||||
*/
|
||||
export class DeliveryRetry {
|
||||
private readonly config: RetryConfig;
|
||||
private readonly deadLetters: DeadLetterEntry[] = [];
|
||||
private deadLetterHandler?: DeadLetterHandler;
|
||||
private readonly clock: Clock;
|
||||
private readonly sleep: SleepFn;
|
||||
|
||||
public constructor(
|
||||
config?: Partial<RetryConfig>,
|
||||
clock: Clock = defaultClock,
|
||||
sleep: SleepFn = defaultSleep
|
||||
) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.clock = clock;
|
||||
this.sleep = sleep;
|
||||
}
|
||||
|
||||
/** Register a handler called when an entry hits the dead-letter queue. */
|
||||
public onDeadLetter(handler: DeadLetterHandler): void {
|
||||
this.deadLetterHandler = handler;
|
||||
}
|
||||
|
||||
/** Snapshot of the current dead-letter queue. */
|
||||
public getDeadLetters(): readonly DeadLetterEntry[] {
|
||||
return this.deadLetters;
|
||||
}
|
||||
|
||||
/** Clear the dead-letter queue. */
|
||||
public clearDeadLetters(): void {
|
||||
this.deadLetters.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt delivery with retries using exponential backoff.
|
||||
* Returns the successful result or throws after exhausting retries.
|
||||
*/
|
||||
public async sendWithRetry(
|
||||
channel: DeliveryChannel,
|
||||
notification: Notification
|
||||
): Promise<DeliveryResult> {
|
||||
let lastError = "";
|
||||
|
||||
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
const delay = this.calculateDelay(attempt);
|
||||
await this.sleep(delay);
|
||||
}
|
||||
|
||||
const result = await channel.send(notification);
|
||||
if (result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
lastError = result.error ?? "Unknown delivery error";
|
||||
}
|
||||
|
||||
const entry: DeadLetterEntry = {
|
||||
notification,
|
||||
channel: channel.name,
|
||||
attempts: this.config.maxRetries + 1,
|
||||
lastError,
|
||||
failedAt: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
|
||||
this.deadLetters.push(entry);
|
||||
if (this.deadLetterHandler !== undefined) {
|
||||
this.deadLetterHandler(entry);
|
||||
}
|
||||
|
||||
throw new DeliveryFailedError({
|
||||
cause: new Error(lastError),
|
||||
context: {
|
||||
notificationId: notification.id,
|
||||
channel: channel.name,
|
||||
attempts: entry.attempts,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Delay for `attempt` (1-indexed) using exponential backoff. */
|
||||
public calculateDelay(attempt: number): number {
|
||||
const delay = this.config.baseDelayMs * 2 ** (attempt - 1);
|
||||
// `DEFAULT_CONFIG.maxDelayMs` is always merged into `this.config`
|
||||
// via the constructor spread, so the field is always defined; the
|
||||
// `as number` cast keeps `noUncheckedIndexedAccess` happy without
|
||||
// introducing an unreachable `??` branch.
|
||||
const maxDelay = this.config.maxDelayMs as number;
|
||||
return Math.min(delay, maxDelay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NotificationChannelError } from "../error-codes/error-codes.js";
|
||||
import { notificationHooks, onNotificationRouting } from "../hooks/hooks.js";
|
||||
import type {
|
||||
ChannelConfig,
|
||||
RoutingRule,
|
||||
} from "../types/notification.types.js";
|
||||
import { NotificationRouter } from "./notification-router.js";
|
||||
|
||||
function makeRouter(
|
||||
options: {
|
||||
channels?: ChannelConfig[];
|
||||
routingRules?: RoutingRule[];
|
||||
clock?: () => number;
|
||||
} = {}
|
||||
): NotificationRouter {
|
||||
return new NotificationRouter({
|
||||
channels: options.channels ?? [
|
||||
{ channel: "email", enabled: true },
|
||||
{ channel: "sms", enabled: true },
|
||||
{ channel: "push", enabled: true },
|
||||
{ channel: "in_app", enabled: true },
|
||||
],
|
||||
...(options.routingRules !== undefined
|
||||
? { routingRules: options.routingRules }
|
||||
: {}),
|
||||
defaultChannel: "email",
|
||||
...(options.clock !== undefined ? { clock: options.clock } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("NotificationRouter — rate limiting", () => {
|
||||
it("enforces rate limit per channel", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter({
|
||||
channels: [{ channel: "email", enabled: true, rateLimit: 2 }],
|
||||
});
|
||||
await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "A",
|
||||
channel: "email",
|
||||
});
|
||||
await router.send({
|
||||
recipient: "b@b.com",
|
||||
body: "B",
|
||||
channel: "email",
|
||||
});
|
||||
await expect(
|
||||
router.send({
|
||||
recipient: "c@c.com",
|
||||
body: "C",
|
||||
channel: "email",
|
||||
})
|
||||
).rejects.toThrow(NotificationChannelError);
|
||||
});
|
||||
|
||||
it("does not rate limit when no limit set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await router.send({
|
||||
recipient: `u${i}@a.com`,
|
||||
body: "msg",
|
||||
channel: "email",
|
||||
});
|
||||
}
|
||||
const stats = await router.getChannelStats();
|
||||
expect(stats.email.sent).toBe(50);
|
||||
});
|
||||
|
||||
it("resets rate-limit window after the 60s window", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 1_000_000;
|
||||
const router = makeRouter({
|
||||
channels: [{ channel: "email", enabled: true, rateLimit: 1 }],
|
||||
clock: () => now,
|
||||
});
|
||||
await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "A",
|
||||
channel: "email",
|
||||
});
|
||||
now += 61_000;
|
||||
const result = await router.send({
|
||||
recipient: "b@b.com",
|
||||
body: "B",
|
||||
channel: "email",
|
||||
});
|
||||
expect(result.status).toBe("sent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRouter — routing rules", () => {
|
||||
it("routes based on priority rule", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter({
|
||||
routingRules: [
|
||||
{
|
||||
id: "r1",
|
||||
name: "Urgent to SMS",
|
||||
channels: ["sms"],
|
||||
priority: "urgent",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "+15551234567",
|
||||
body: "Urgent!",
|
||||
priority: "urgent",
|
||||
});
|
||||
expect(result.channel).toBe("sms");
|
||||
});
|
||||
|
||||
it("routes based on condition", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter({
|
||||
routingRules: [
|
||||
{
|
||||
id: "r1",
|
||||
name: "VIP to push",
|
||||
channels: ["push"],
|
||||
condition: (recipient) => recipient.startsWith("vip_"),
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "vip_user",
|
||||
body: "VIP notification",
|
||||
});
|
||||
expect(result.channel).toBe("push");
|
||||
});
|
||||
|
||||
it("falls back to default when no rule matches", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter({
|
||||
routingRules: [
|
||||
{
|
||||
id: "r1",
|
||||
name: "Urgent to SMS",
|
||||
channels: ["sms"],
|
||||
priority: "urgent",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Normal",
|
||||
priority: "normal",
|
||||
});
|
||||
expect(result.channel).toBe("email");
|
||||
});
|
||||
|
||||
it("adds routing rules dynamically", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
await router.addRoutingRule({
|
||||
id: "r2",
|
||||
name: "All to in_app",
|
||||
channels: ["in_app"],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "user",
|
||||
body: "Hello",
|
||||
});
|
||||
expect(result.channel).toBe("in_app");
|
||||
});
|
||||
|
||||
it("skips rules with non-matching conditions", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter({
|
||||
routingRules: [
|
||||
{
|
||||
id: "r1",
|
||||
name: "Admin only",
|
||||
channels: ["push"],
|
||||
condition: (r) => r === "admin",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "regular_user",
|
||||
body: "Hello",
|
||||
});
|
||||
expect(result.channel).toBe("email");
|
||||
});
|
||||
|
||||
it("uses first matching rule channel", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter({
|
||||
routingRules: [
|
||||
{ id: "r1", name: "First", channels: ["sms", "push"] },
|
||||
],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "user",
|
||||
body: "Hello",
|
||||
});
|
||||
expect(result.channel).toBe("sms");
|
||||
});
|
||||
|
||||
it("falls back to default when rule has empty channel list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter({
|
||||
routingRules: [{ id: "r1", name: "empty", channels: [] }],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "user",
|
||||
body: "Hello",
|
||||
});
|
||||
expect(result.channel).toBe("email");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRouter — priority handling", () => {
|
||||
it("stores notification with priority", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "Urgent!",
|
||||
priority: "urgent",
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.priority).toBe("urgent");
|
||||
});
|
||||
|
||||
it("defaults priority to normal", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "Hi",
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.priority).toBe("normal");
|
||||
});
|
||||
|
||||
it("stores low priority", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "Low",
|
||||
priority: "low",
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.priority).toBe("low");
|
||||
});
|
||||
|
||||
it("stores high priority", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "High",
|
||||
priority: "high",
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.priority).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationRouter — hook integration", () => {
|
||||
it("notification:routing hook can override resolved channel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
const off = onNotificationRouting((data) => {
|
||||
if (data.input.priority === "urgent") {
|
||||
return { resolvedChannel: "sms" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
try {
|
||||
const result = await router.send({
|
||||
recipient: "+15551234567",
|
||||
body: "Urgent!",
|
||||
priority: "urgent",
|
||||
});
|
||||
expect(result.channel).toBe("sms");
|
||||
} finally {
|
||||
off();
|
||||
notificationHooks.clear("notification:routing");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
# `notification-router` — Primary multi-channel router
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves the target channel (input override → routing rules → default), runs the `notification:routing` hook pipeline, resolves the body / subject (template render or raw), checks the channel is enabled and within rate limit, optionally dispatches to a registered `DeliveryChannel`, persists the notification record, and emits a lifecycle event (`notification.sent` / `notification.failed` / `notification.cancelled` / `notification.created`) on its internal `EventBus`. Every public boundary is Zod-validated.
|
||||
|
||||
## Features
|
||||
|
||||
- Channel resolution: input override → routing rules (priority and predicate filters) → default channel.
|
||||
- Template-based content with `{{var}}` substitution.
|
||||
- Per-channel 60-second window rate limiting (in-process — wire `ChannelRateLimiter` for richer policies).
|
||||
- Lifecycle events via `EventBus`: `notification.created`, `notification.sent`, `notification.failed`, `notification.cancelled`.
|
||||
- Optional in-pipeline `DeliveryChannel` dispatch.
|
||||
- OpenTelemetry meters on every send.
|
||||
- Zod-validated config at the constructor boundary.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { NotificationRouter } from "@suaveplan/notification-channel";
|
||||
|
||||
const router = new NotificationRouter({
|
||||
channels: [
|
||||
{ channel: "email", enabled: true },
|
||||
{ channel: "sms", enabled: true },
|
||||
],
|
||||
defaultChannel: "email",
|
||||
});
|
||||
|
||||
const result = await router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Welcome!",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
EmailChannel,
|
||||
NotificationRouter,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const email = new EmailChannel({ from: "noreply@x", sendFn: provider.send });
|
||||
|
||||
const router = new NotificationRouter({
|
||||
channels: [{ channel: "email", enabled: true, rateLimit: 100 }],
|
||||
templates: [
|
||||
{
|
||||
id: "welcome",
|
||||
name: "Welcome",
|
||||
channel: "email",
|
||||
subject: "Welcome {{name}}",
|
||||
bodyTemplate: "Hi {{name}}",
|
||||
defaultPriority: "normal",
|
||||
},
|
||||
],
|
||||
routingRules: [
|
||||
{ id: "urgent-to-sms", name: "u", channels: ["sms"], priority: "urgent" },
|
||||
],
|
||||
defaultChannel: "email",
|
||||
deliveryChannels: [email],
|
||||
});
|
||||
|
||||
router.on("notification.failed", (n) => audit.record(n));
|
||||
await router.send({
|
||||
recipient: "u@x",
|
||||
body: "",
|
||||
templateId: "welcome",
|
||||
templateData: { name: "Alice" },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NotificationRouter(config: NotificationRouterConfig)` — constructor.
|
||||
- `send(input)`, `enqueue(input)`, `sendBatch(inputs)`, `getNotification(id)`, `cancel(id)`, `getChannelStats()`.
|
||||
- `registerChannel(c)`, `registerTemplate(t)`, `addRoutingRule(r)`, `registerDeliveryChannel(dc)`.
|
||||
- `on(event, listener)` — subscribe to lifecycle events.
|
||||
- `NotificationRouterConfig` — `{ channels, templates?, routingRules?, defaultChannel, deliveryChannels?, clock? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal 60-second rate-limit window is a fixed bucket per channel — for sliding-window or per-user limits, layer `ChannelRateLimiter` outside the router. `cancel` consults `VALID_TRANSITIONS` so cancelling a `sent` notification rejects (cannot transition `sent → cancelled`). The router uses `EventBus<NotificationEventMap>` so consumers get typed `on(...)` and the underlying envelope semantics — interceptors, abort signals — that ship in `@suaveplan/events`. Construction Zod-parses the config; runtime calls trust the inputs because `send` runs on every notification and re-validating on every call is wasteful — consumers MUST validate `NotificationInput` themselves if they accept it from untrusted sources.
|
||||
@@ -0,0 +1,534 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
} from "../channels/delivery-channel.js";
|
||||
import { NotificationChannelError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
ChannelConfig,
|
||||
Notification,
|
||||
NotificationTemplate,
|
||||
} from "../types/notification.types.js";
|
||||
import { NotificationRouter } from "./notification-router.js";
|
||||
|
||||
function makeRouter(
|
||||
overrides: {
|
||||
channels?: ChannelConfig[];
|
||||
templates?: NotificationTemplate[];
|
||||
} = {}
|
||||
): NotificationRouter {
|
||||
return new NotificationRouter({
|
||||
channels: overrides.channels ?? [
|
||||
{ channel: "email", enabled: true },
|
||||
{ channel: "sms", enabled: true },
|
||||
{ channel: "push", enabled: true },
|
||||
{ channel: "in_app", enabled: true },
|
||||
],
|
||||
...(overrides.templates !== undefined
|
||||
? { templates: overrides.templates }
|
||||
: {}),
|
||||
defaultChannel: "email",
|
||||
});
|
||||
}
|
||||
|
||||
describe("NotificationRouter — send + lifecycle", () => {
|
||||
it("sends a notification to a channel", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Hello",
|
||||
channel: "email",
|
||||
});
|
||||
expect(result.status).toBe("sent");
|
||||
expect(result.channel).toBe("email");
|
||||
expect(result.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses default channel when not specified", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Hello",
|
||||
});
|
||||
expect(result.channel).toBe("email");
|
||||
});
|
||||
|
||||
it("includes sentAt timestamp", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Hello",
|
||||
});
|
||||
expect(result.sentAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits notification.sent event", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
let emitted = false;
|
||||
router.on("notification.sent", () => {
|
||||
emitted = true;
|
||||
});
|
||||
await router.send({ recipient: "user@example.com", body: "Hello" });
|
||||
expect(emitted).toBe(true);
|
||||
});
|
||||
|
||||
it("throws when channel is disabled", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter({
|
||||
channels: [{ channel: "email", enabled: false }],
|
||||
});
|
||||
await expect(
|
||||
router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Hello",
|
||||
channel: "email",
|
||||
})
|
||||
).rejects.toThrow(NotificationChannelError);
|
||||
});
|
||||
|
||||
it("throws when channel is not registered", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter({
|
||||
channels: [{ channel: "email", enabled: true }],
|
||||
});
|
||||
await expect(
|
||||
router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Hello",
|
||||
channel: "webhook",
|
||||
})
|
||||
).rejects.toThrow(NotificationChannelError);
|
||||
});
|
||||
|
||||
it("sends to sms channel", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "+15551234567",
|
||||
body: "Hello SMS",
|
||||
channel: "sms",
|
||||
});
|
||||
expect(result.channel).toBe("sms");
|
||||
});
|
||||
|
||||
it("sends with subject", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Hello",
|
||||
subject: "Test Subject",
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.subject).toBe("Test Subject");
|
||||
});
|
||||
|
||||
it("sends with metadata", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Hello",
|
||||
metadata: { source: "test" },
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.metadata).toEqual({ source: "test" });
|
||||
});
|
||||
|
||||
it("assigns unique ids to each notification", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
const r1 = await router.send({ recipient: "a@a.com", body: "A" });
|
||||
const r2 = await router.send({ recipient: "b@b.com", body: "B" });
|
||||
expect(r1.id).not.toBe(r2.id);
|
||||
});
|
||||
|
||||
it("sendBatch returns one result per input", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
const results = await router.sendBatch([
|
||||
{ recipient: "a@a.com", body: "Hello A" },
|
||||
{ recipient: "b@b.com", body: "Hello B" },
|
||||
{ recipient: "c@c.com", body: "Hello C" },
|
||||
]);
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.every((r) => r.status === "sent")).toBe(true);
|
||||
});
|
||||
|
||||
it("sendBatch returns empty for empty input", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
const results = await router.sendBatch([]);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("retrieves a sent notification", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "Hello",
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.body).toBe("Hello");
|
||||
expect(notif.recipient).toBe("a@a.com");
|
||||
});
|
||||
|
||||
it("getNotification throws for non-existent id", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
await expect(router.getNotification("fake")).rejects.toThrow(
|
||||
NotificationChannelError
|
||||
);
|
||||
});
|
||||
|
||||
it("enqueue creates a pending notification", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.enqueue({
|
||||
recipient: "a@a.com",
|
||||
body: "Hello",
|
||||
});
|
||||
expect(result.status).toBe("pending");
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("enqueue records subject and metadata when provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.enqueue({
|
||||
recipient: "a@a.com",
|
||||
body: "Hi",
|
||||
subject: "Pending subject",
|
||||
metadata: { trace: "t-1" },
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.subject).toBe("Pending subject");
|
||||
expect(notif.metadata).toEqual({ trace: "t-1" });
|
||||
});
|
||||
|
||||
it("enqueue emits notification.created event", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
let emitted = false;
|
||||
router.on("notification.created", () => {
|
||||
emitted = true;
|
||||
});
|
||||
await router.enqueue({ recipient: "a@a.com", body: "Hello" });
|
||||
expect(emitted).toBe(true);
|
||||
});
|
||||
|
||||
it("cancels a pending notification", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.enqueue({
|
||||
recipient: "a@a.com",
|
||||
body: "Hello",
|
||||
});
|
||||
const cancelled = await router.cancel(result.id);
|
||||
expect(cancelled.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("emits notification.cancelled event", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
let emitted = false;
|
||||
router.on("notification.cancelled", () => {
|
||||
emitted = true;
|
||||
});
|
||||
const result = await router.enqueue({
|
||||
recipient: "a@a.com",
|
||||
body: "Hello",
|
||||
});
|
||||
await router.cancel(result.id);
|
||||
expect(emitted).toBe(true);
|
||||
});
|
||||
|
||||
it("throws when cancelling sent notification", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "Hello",
|
||||
});
|
||||
await expect(router.cancel(result.id)).rejects.toThrow(
|
||||
NotificationChannelError
|
||||
);
|
||||
});
|
||||
|
||||
it("cancel throws for non-existent id", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter();
|
||||
await expect(router.cancel("fake")).rejects.toThrow(
|
||||
NotificationChannelError
|
||||
);
|
||||
});
|
||||
|
||||
it("getChannelStats reports per-channel sent count", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "A",
|
||||
channel: "email",
|
||||
});
|
||||
await router.send({
|
||||
recipient: "b@b.com",
|
||||
body: "B",
|
||||
channel: "sms",
|
||||
});
|
||||
const stats = await router.getChannelStats();
|
||||
expect(stats.email.sent).toBe(1);
|
||||
expect(stats.sms.sent).toBe(1);
|
||||
expect(stats.email.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("registerChannel overrides existing channel config", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
await router.registerChannel({ channel: "email", enabled: false });
|
||||
await expect(
|
||||
router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "Hello",
|
||||
channel: "email",
|
||||
})
|
||||
).rejects.toThrow(NotificationChannelError);
|
||||
});
|
||||
|
||||
it("registerChannel can add a new channel", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter({ channels: [] });
|
||||
await router.registerChannel({ channel: "webhook", enabled: true });
|
||||
const result = await router.send({
|
||||
recipient: "https://hook.example.com",
|
||||
body: "{}",
|
||||
channel: "webhook",
|
||||
});
|
||||
expect(result.channel).toBe("webhook");
|
||||
});
|
||||
|
||||
it("registerTemplate registers and uses a template", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
await router.registerTemplate({
|
||||
id: "tpl_welcome",
|
||||
name: "Welcome",
|
||||
channel: "email",
|
||||
subject: "Welcome {{name}}",
|
||||
bodyTemplate: "Hello {{name}}, welcome to {{app}}!",
|
||||
defaultPriority: "normal",
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "",
|
||||
templateId: "tpl_welcome",
|
||||
templateData: { name: "Alice", app: "Suaveplan" },
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.body).toBe("Hello Alice, welcome to Suaveplan!");
|
||||
expect(notif.subject).toBe("Welcome Alice");
|
||||
});
|
||||
|
||||
it("templates render body with variables", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter({
|
||||
templates: [
|
||||
{
|
||||
id: "tpl_1",
|
||||
name: "Test",
|
||||
channel: "email",
|
||||
bodyTemplate: "Hi {{user}}, code: {{code}}",
|
||||
defaultPriority: "normal",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "",
|
||||
templateId: "tpl_1",
|
||||
templateData: { user: "Bob", code: "1234" },
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.body).toBe("Hi Bob, code: 1234");
|
||||
});
|
||||
|
||||
it("templates throws for non-existent template", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
await expect(
|
||||
router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "",
|
||||
templateId: "fake",
|
||||
})
|
||||
).rejects.toThrow(NotificationChannelError);
|
||||
});
|
||||
|
||||
it("uses input subject over template subject", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter({
|
||||
templates: [
|
||||
{
|
||||
id: "tpl_1",
|
||||
name: "Test",
|
||||
channel: "email",
|
||||
subject: "Template Subject",
|
||||
bodyTemplate: "Body",
|
||||
defaultPriority: "normal",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "",
|
||||
subject: "Custom Subject",
|
||||
templateId: "tpl_1",
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.subject).toBe("Custom Subject");
|
||||
});
|
||||
|
||||
it("renders template without data", { timeout: 5000 }, async () => {
|
||||
const router = makeRouter({
|
||||
templates: [
|
||||
{
|
||||
id: "tpl_1",
|
||||
name: "Test",
|
||||
channel: "email",
|
||||
bodyTemplate: "Static body",
|
||||
defaultPriority: "normal",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "",
|
||||
templateId: "tpl_1",
|
||||
});
|
||||
const notif = await router.getNotification(result.id);
|
||||
expect(notif.body).toBe("Static body");
|
||||
});
|
||||
|
||||
it("dispatches to registered DeliveryChannel and reports failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failing: DeliveryChannel = {
|
||||
name: "email",
|
||||
canSend: () => true,
|
||||
send: async (): Promise<DeliveryResult> => ({
|
||||
success: false,
|
||||
channel: "email",
|
||||
error: "downstream went boom",
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
const router = new NotificationRouter({
|
||||
channels: [{ channel: "email", enabled: true }],
|
||||
defaultChannel: "email",
|
||||
deliveryChannels: [failing],
|
||||
});
|
||||
let failedEvent: Notification | undefined;
|
||||
router.on("notification.failed", (n) => {
|
||||
failedEvent = n;
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "Hello",
|
||||
});
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error).toBe("downstream went boom");
|
||||
expect(failedEvent?.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("dispatches failure without an error string when channel omits one", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const silentlyFailing: DeliveryChannel = {
|
||||
name: "email",
|
||||
canSend: () => true,
|
||||
send: async (): Promise<DeliveryResult> => ({
|
||||
success: false,
|
||||
channel: "email",
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
const router = new NotificationRouter({
|
||||
channels: [{ channel: "email", enabled: true }],
|
||||
defaultChannel: "email",
|
||||
deliveryChannels: [silentlyFailing],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "Hello",
|
||||
});
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dispatches to registered DeliveryChannel on success", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ok: DeliveryChannel = {
|
||||
name: "email",
|
||||
canSend: () => true,
|
||||
send: async (): Promise<DeliveryResult> => ({
|
||||
success: true,
|
||||
channel: "email",
|
||||
messageId: "msg-x",
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
const router = new NotificationRouter({
|
||||
channels: [{ channel: "email", enabled: true }],
|
||||
defaultChannel: "email",
|
||||
deliveryChannels: [ok],
|
||||
});
|
||||
const result = await router.send({
|
||||
recipient: "a@a.com",
|
||||
body: "Hello",
|
||||
});
|
||||
expect(result.status).toBe("sent");
|
||||
});
|
||||
|
||||
it("registerDeliveryChannel adds runtime delivery channel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const router = makeRouter();
|
||||
let invoked = false;
|
||||
router.registerDeliveryChannel({
|
||||
name: "email",
|
||||
canSend: () => true,
|
||||
send: async () => {
|
||||
invoked = true;
|
||||
return {
|
||||
success: true,
|
||||
channel: "email",
|
||||
messageId: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
});
|
||||
await router.send({ recipient: "a@a.com", body: "Hi" });
|
||||
expect(invoked).toBe(true);
|
||||
});
|
||||
|
||||
it("Zod boundary rejects malformed router config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
() =>
|
||||
new NotificationRouter({
|
||||
channels: "wrong" as unknown as ChannelConfig[],
|
||||
defaultChannel: "email",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { DeliveryChannel } from "../channels/delivery-channel.js";
|
||||
import {
|
||||
ChannelDisabledError,
|
||||
NotificationNotFoundError,
|
||||
RateLimitExceededError,
|
||||
RoutingFailedError,
|
||||
TemplateNotFoundError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { notificationHooks } from "../hooks/hooks.js";
|
||||
import { recordDeliveryAttempt, recordRouted } from "../otel/metrics.js";
|
||||
import { NotificationRouterConfigSchema } from "../schemas/schemas.js";
|
||||
import type {
|
||||
ChannelConfig,
|
||||
Notification,
|
||||
NotificationChannel,
|
||||
NotificationEventMap,
|
||||
NotificationInput,
|
||||
NotificationResult,
|
||||
NotificationTemplate,
|
||||
RoutingRule,
|
||||
} from "../types/notification.types.js";
|
||||
import { VALID_TRANSITIONS } from "../types/notification.types.js";
|
||||
|
||||
/** Public configuration for `NotificationRouter`. */
|
||||
export interface NotificationRouterConfig {
|
||||
readonly channels: readonly ChannelConfig[];
|
||||
readonly templates?: readonly NotificationTemplate[];
|
||||
readonly routingRules?: readonly RoutingRule[];
|
||||
readonly defaultChannel: NotificationChannel;
|
||||
readonly deliveryChannels?: readonly DeliveryChannel[];
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
interface ChannelState {
|
||||
config: ChannelConfig;
|
||||
sentCount: number;
|
||||
windowStart: number;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Routes notifications across channels with template rendering,
|
||||
* rate-limit gating, lifecycle tracking, and EventBus emission. The
|
||||
* router is the single integration point a consumer talks to;
|
||||
* everything else (rate limiter, scheduler, retry, preferences) is
|
||||
* composable on top.
|
||||
*/
|
||||
export class NotificationRouter {
|
||||
private readonly notifications = new Map<string, Notification>();
|
||||
private readonly channelStates = new Map<
|
||||
NotificationChannel,
|
||||
ChannelState
|
||||
>();
|
||||
private readonly templates = new Map<string, NotificationTemplate>();
|
||||
private readonly routingRules: RoutingRule[] = [];
|
||||
private readonly deliveryChannels = new Map<string, DeliveryChannel>();
|
||||
private readonly defaultChannel: NotificationChannel;
|
||||
private readonly clock: Clock;
|
||||
private readonly bus = new EventBus<NotificationEventMap>();
|
||||
private nextId = 1;
|
||||
|
||||
public constructor(config: NotificationRouterConfig) {
|
||||
// Zod boundary — reject malformed config before any state mutates.
|
||||
NotificationRouterConfigSchema.parse({
|
||||
channels: config.channels,
|
||||
...(config.templates !== undefined
|
||||
? { templates: config.templates }
|
||||
: {}),
|
||||
...(config.routingRules !== undefined
|
||||
? { routingRules: config.routingRules }
|
||||
: {}),
|
||||
defaultChannel: config.defaultChannel,
|
||||
...(config.deliveryChannels !== undefined
|
||||
? { deliveryChannels: config.deliveryChannels }
|
||||
: {}),
|
||||
...(config.clock !== undefined ? { clock: config.clock } : {}),
|
||||
});
|
||||
|
||||
this.defaultChannel = config.defaultChannel;
|
||||
this.clock = config.clock ?? defaultClock;
|
||||
|
||||
for (const ch of config.channels) {
|
||||
this.channelStates.set(ch.channel, {
|
||||
config: ch,
|
||||
sentCount: 0,
|
||||
windowStart: this.clock(),
|
||||
});
|
||||
}
|
||||
|
||||
if (config.templates !== undefined) {
|
||||
for (const tpl of config.templates) {
|
||||
this.templates.set(tpl.id, tpl);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.routingRules !== undefined) {
|
||||
this.routingRules.push(...config.routingRules);
|
||||
}
|
||||
|
||||
if (config.deliveryChannels !== undefined) {
|
||||
for (const dc of config.deliveryChannels) {
|
||||
this.deliveryChannels.set(dc.name, dc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to a router-emitted lifecycle event. */
|
||||
public on<K extends keyof NotificationEventMap>(
|
||||
type: K,
|
||||
listener: (payload: NotificationEventMap[K]) => void
|
||||
): () => void {
|
||||
return this.bus.on(type, listener);
|
||||
}
|
||||
|
||||
/** Send a notification end-to-end. */
|
||||
public async send(input: NotificationInput): Promise<NotificationResult> {
|
||||
const start = this.clock();
|
||||
let channel = this.resolveChannel(input);
|
||||
const hookResult = await notificationHooks.applyHooksAsync(
|
||||
"notification:routing",
|
||||
{
|
||||
input,
|
||||
resolvedChannel: channel,
|
||||
metadata: {},
|
||||
}
|
||||
);
|
||||
channel = hookResult.resolvedChannel;
|
||||
const { body, subject } = this.resolveContent(hookResult.input);
|
||||
const priority = hookResult.input.priority ?? "normal";
|
||||
|
||||
try {
|
||||
this.checkChannel(channel);
|
||||
this.checkRateLimit(channel);
|
||||
} catch (err) {
|
||||
recordRouted(channel, "failed", this.clock() - start);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const id = `notif_${this.nextId++}`;
|
||||
const now = new Date(this.clock()).toISOString();
|
||||
|
||||
const baseNotification: Notification = {
|
||||
id,
|
||||
channel,
|
||||
recipient: input.recipient,
|
||||
...(subject !== undefined ? { subject } : {}),
|
||||
body,
|
||||
priority,
|
||||
status: "sent",
|
||||
...(input.metadata !== undefined
|
||||
? { metadata: input.metadata }
|
||||
: {}),
|
||||
createdAt: now,
|
||||
sentAt: now,
|
||||
};
|
||||
|
||||
this.notifications.set(id, baseNotification);
|
||||
this.incrementRateCounter(channel);
|
||||
|
||||
const dc = this.deliveryChannels.get(channel);
|
||||
if (dc !== undefined) {
|
||||
const deliveryResult = await dc.send(baseNotification);
|
||||
if (!deliveryResult.success) {
|
||||
const failed: Notification = {
|
||||
...baseNotification,
|
||||
status: "failed",
|
||||
};
|
||||
this.notifications.set(id, failed);
|
||||
this.bus.emit("notification.failed", failed);
|
||||
recordDeliveryAttempt(channel, "failed");
|
||||
recordRouted(channel, "failed", this.clock() - start);
|
||||
const errResult: NotificationResult = {
|
||||
id,
|
||||
channel,
|
||||
status: "failed",
|
||||
...(deliveryResult.error !== undefined
|
||||
? { error: deliveryResult.error }
|
||||
: {}),
|
||||
};
|
||||
return errResult;
|
||||
}
|
||||
recordDeliveryAttempt(channel, "ok");
|
||||
}
|
||||
|
||||
this.bus.emit("notification.sent", baseNotification);
|
||||
recordRouted(channel, "ok", this.clock() - start);
|
||||
return { id, channel, status: "sent", sentAt: now };
|
||||
}
|
||||
|
||||
/** Enqueue a `pending` notification without delivering. */
|
||||
public async enqueue(
|
||||
input: NotificationInput
|
||||
): Promise<NotificationResult> {
|
||||
const channel = this.resolveChannel(input);
|
||||
const { body, subject } = this.resolveContent(input);
|
||||
const priority = input.priority ?? "normal";
|
||||
this.checkChannel(channel);
|
||||
const id = `notif_${this.nextId++}`;
|
||||
const now = new Date(this.clock()).toISOString();
|
||||
const notification: Notification = {
|
||||
id,
|
||||
channel,
|
||||
recipient: input.recipient,
|
||||
...(subject !== undefined ? { subject } : {}),
|
||||
body,
|
||||
priority,
|
||||
status: "pending",
|
||||
...(input.metadata !== undefined
|
||||
? { metadata: input.metadata }
|
||||
: {}),
|
||||
createdAt: now,
|
||||
};
|
||||
this.notifications.set(id, notification);
|
||||
this.bus.emit("notification.created", notification);
|
||||
return { id, channel, status: "pending" };
|
||||
}
|
||||
|
||||
/** Send a batch sequentially, returning a result per input. */
|
||||
public async sendBatch(
|
||||
inputs: readonly NotificationInput[]
|
||||
): Promise<NotificationResult[]> {
|
||||
const results: NotificationResult[] = [];
|
||||
for (const input of inputs) {
|
||||
results.push(await this.send(input));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Retrieve a notification by id. */
|
||||
public async getNotification(id: string): Promise<Notification> {
|
||||
const notif = this.notifications.get(id);
|
||||
if (notif === undefined) {
|
||||
throw new NotificationNotFoundError({
|
||||
context: { notificationId: id },
|
||||
});
|
||||
}
|
||||
return notif;
|
||||
}
|
||||
|
||||
/** Cancel a pending notification (rejects when transition is illegal). */
|
||||
public async cancel(id: string): Promise<Notification> {
|
||||
const notif = this.notifications.get(id);
|
||||
if (notif === undefined) {
|
||||
throw new NotificationNotFoundError({
|
||||
context: { notificationId: id },
|
||||
});
|
||||
}
|
||||
|
||||
const validTargets = VALID_TRANSITIONS[notif.status];
|
||||
if (!validTargets.includes("cancelled")) {
|
||||
throw new RoutingFailedError({
|
||||
context: { notificationId: id, fromStatus: notif.status },
|
||||
});
|
||||
}
|
||||
|
||||
const updated: Notification = { ...notif, status: "cancelled" };
|
||||
this.notifications.set(id, updated);
|
||||
this.bus.emit("notification.cancelled", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Snapshot of per-channel stats for observability. */
|
||||
public async getChannelStats(): Promise<
|
||||
Record<NotificationChannel, { sent: number; enabled: boolean }>
|
||||
> {
|
||||
const stats = {} as Record<
|
||||
NotificationChannel,
|
||||
{ sent: number; enabled: boolean }
|
||||
>;
|
||||
|
||||
for (const [channel, state] of this.channelStates) {
|
||||
stats[channel] = {
|
||||
sent: state.sentCount,
|
||||
enabled: state.config.enabled,
|
||||
};
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** Register or replace a channel config at runtime. */
|
||||
public async registerChannel(config: ChannelConfig): Promise<void> {
|
||||
this.channelStates.set(config.channel, {
|
||||
config,
|
||||
sentCount: 0,
|
||||
windowStart: this.clock(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Register or replace a template at runtime. */
|
||||
public async registerTemplate(
|
||||
template: NotificationTemplate
|
||||
): Promise<void> {
|
||||
this.templates.set(template.id, template);
|
||||
}
|
||||
|
||||
/** Append a routing rule at runtime. */
|
||||
public async addRoutingRule(rule: RoutingRule): Promise<void> {
|
||||
this.routingRules.push(rule);
|
||||
}
|
||||
|
||||
/** Register a delivery-channel implementation. */
|
||||
public registerDeliveryChannel(channel: DeliveryChannel): void {
|
||||
this.deliveryChannels.set(channel.name, channel);
|
||||
}
|
||||
|
||||
private resolveChannel(input: NotificationInput): NotificationChannel {
|
||||
if (input.channel !== undefined) return input.channel;
|
||||
|
||||
for (const rule of this.routingRules) {
|
||||
if (
|
||||
rule.priority !== undefined &&
|
||||
input.priority !== undefined &&
|
||||
rule.priority !== input.priority
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
rule.condition !== undefined &&
|
||||
!rule.condition(input.recipient)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (rule.channels.length > 0) {
|
||||
// length > 0 ⇒ index 0 is defined; the `as` cast keeps
|
||||
// `noUncheckedIndexedAccess` happy without an unreachable
|
||||
// guard branch.
|
||||
return rule.channels[0] as NotificationChannel;
|
||||
}
|
||||
}
|
||||
|
||||
return this.defaultChannel;
|
||||
}
|
||||
|
||||
private resolveContent(input: NotificationInput): {
|
||||
body: string;
|
||||
subject?: string;
|
||||
} {
|
||||
if (input.templateId !== undefined) {
|
||||
const tpl = this.templates.get(input.templateId);
|
||||
if (tpl === undefined) {
|
||||
throw new TemplateNotFoundError({
|
||||
context: { templateId: input.templateId },
|
||||
});
|
||||
}
|
||||
|
||||
let body = tpl.bodyTemplate;
|
||||
let subject = tpl.subject;
|
||||
const data = input.templateData ?? {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const pattern = `{{${key}}}`;
|
||||
body = body.split(pattern).join(value);
|
||||
if (subject !== undefined) {
|
||||
subject = subject.split(pattern).join(value);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedSubject = input.subject ?? subject;
|
||||
return resolvedSubject !== undefined
|
||||
? { body, subject: resolvedSubject }
|
||||
: { body };
|
||||
}
|
||||
|
||||
return input.subject !== undefined
|
||||
? { body: input.body, subject: input.subject }
|
||||
: { body: input.body };
|
||||
}
|
||||
|
||||
private checkChannel(channel: NotificationChannel): void {
|
||||
const state = this.channelStates.get(channel);
|
||||
if (state === undefined || !state.config.enabled) {
|
||||
throw new ChannelDisabledError({ context: { channel } });
|
||||
}
|
||||
}
|
||||
|
||||
private checkRateLimit(channel: NotificationChannel): void {
|
||||
const state = this.channelStates.get(channel);
|
||||
if (state === undefined || state.config.rateLimit === undefined) return;
|
||||
|
||||
const now = this.clock();
|
||||
const windowMs = 60_000;
|
||||
|
||||
if (now - state.windowStart > windowMs) {
|
||||
state.sentCount = 0;
|
||||
state.windowStart = now;
|
||||
}
|
||||
|
||||
if (state.sentCount >= state.config.rateLimit) {
|
||||
throw new RateLimitExceededError({
|
||||
context: { channel, scope: "router" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private incrementRateCounter(channel: NotificationChannel): void {
|
||||
// `checkChannel` already proved `state` is non-undefined; cast
|
||||
// rather than guard so coverage does not see an unreachable
|
||||
// branch.
|
||||
const state = this.channelStates.get(channel) as ChannelState;
|
||||
state.sentCount++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# `notification-scheduler` — Future-delivery scheduler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. The scheduler does NOT call the router itself — callers register an `onDue(handler)` that receives the original `NotificationInput`, typically wiring it back into `NotificationRouter.send` or `enqueue`. The class exposes both manual `processDue()` (call from cron / queue worker) and `start()`/`stop()` for in-process polling.
|
||||
|
||||
## Features
|
||||
|
||||
- `schedule(input, deliverAt)` returns a stable id.
|
||||
- `cancel(id)`, `get(id)`, `listPending()`, `getDue(now?)` for inspection.
|
||||
- `processDue()` awaits the handler per entry and removes processed entries.
|
||||
- `start()`/`stop()` polling driver with idempotent semantics.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
- Throws `NotificationNotFoundError` for unknown ids on `cancel` / `get`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
NotificationRouter,
|
||||
NotificationScheduler,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const router = new NotificationRouter({ channels, defaultChannel: "email" });
|
||||
const scheduler = new NotificationScheduler();
|
||||
scheduler.onDue(async (input) => {
|
||||
await router.send(input);
|
||||
});
|
||||
scheduler.schedule({ recipient: "u@x", body: "Reminder" }, new Date(Date.now() + 60_000));
|
||||
scheduler.start();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const scheduler = new NotificationScheduler(5_000, () => clock.now());
|
||||
scheduler.onDue(async (input) => {
|
||||
try {
|
||||
await router.send(input);
|
||||
} catch (err) {
|
||||
await failureQueue.push({ input, err });
|
||||
}
|
||||
});
|
||||
|
||||
// Hook into a cron worker instead of in-process polling:
|
||||
cron.schedule("* * * * *", () => {
|
||||
void scheduler.processDue();
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NotificationScheduler(checkIntervalMs?, clock?)` — constructor.
|
||||
- `schedule(input, deliverAt)`, `cancel(id)`, `get(id)`, `listPending()`, `getDue(now?)`, `processDue()`, `start()`, `stop()`, `size`.
|
||||
- `ScheduledNotification` — `{ id, input, scheduledFor, createdAt, cancelled }`.
|
||||
- `ScheduleHandler` — `(input: NotificationInput) => Promise<void>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`start()` is idempotent — calling twice has no effect; the second call is dropped before `setInterval` runs. `stop()` is idempotent — calling twice is safe and a leading call before `start()` is a no-op. `processDue()` is the single place that mutates the schedule map — both the polling driver and manual / cron callers use it, so behaviour is identical regardless of how processing is triggered. The scheduler does not persist across process restarts — production deployments should layer their own durable store atop `schedule`/`cancel` (e.g. snapshot `listPending()` to disk on shutdown, restore on boot via additional `schedule` calls).
|
||||
@@ -0,0 +1,219 @@
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NotificationChannelError } from "../error-codes/error-codes.js";
|
||||
import { NotificationScheduler } from "./notification-scheduler.js";
|
||||
|
||||
describe("NotificationScheduler", () => {
|
||||
let scheduler: NotificationScheduler | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
scheduler?.stop();
|
||||
});
|
||||
|
||||
describe("schedule", () => {
|
||||
it("schedules a notification for future delivery", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
const entry = scheduler.schedule(
|
||||
{ recipient: "user@test.com", body: "Hello" },
|
||||
future
|
||||
);
|
||||
expect(entry.id).toMatch(/^sched_/);
|
||||
expect(entry.scheduledFor).toBe(future.getTime());
|
||||
expect(entry.cancelled).toBe(false);
|
||||
});
|
||||
|
||||
it("assigns unique ids", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
const e1 = scheduler.schedule(
|
||||
{ recipient: "a", body: "A" },
|
||||
future
|
||||
);
|
||||
const e2 = scheduler.schedule(
|
||||
{ recipient: "b", body: "B" },
|
||||
future
|
||||
);
|
||||
expect(e1.id).not.toBe(e2.id);
|
||||
});
|
||||
|
||||
it("tracks size", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
scheduler.schedule({ recipient: "a", body: "A" }, future);
|
||||
scheduler.schedule({ recipient: "b", body: "B" }, future);
|
||||
expect(scheduler.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel", () => {
|
||||
it("cancels a scheduled notification", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const entry = scheduler.schedule(
|
||||
{ recipient: "a", body: "A" },
|
||||
new Date(Date.now() + 60_000)
|
||||
);
|
||||
const cancelled = scheduler.cancel(entry.id);
|
||||
expect(cancelled.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("throws for non-existent id", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
expect(() => scheduler?.cancel("fake")).toThrow(
|
||||
NotificationChannelError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("retrieves a scheduled notification", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const entry = scheduler.schedule(
|
||||
{ recipient: "a", body: "A" },
|
||||
new Date(Date.now() + 60_000)
|
||||
);
|
||||
const retrieved = scheduler.get(entry.id);
|
||||
expect(retrieved.id).toBe(entry.id);
|
||||
});
|
||||
|
||||
it("throws for non-existent id", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
expect(() => scheduler?.get("fake")).toThrow(
|
||||
NotificationChannelError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listPending", () => {
|
||||
it("returns non-cancelled entries", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
scheduler.schedule({ recipient: "a", body: "A" }, future);
|
||||
const e2 = scheduler.schedule(
|
||||
{ recipient: "b", body: "B" },
|
||||
future
|
||||
);
|
||||
scheduler.cancel(e2.id);
|
||||
const pending = scheduler.listPending();
|
||||
expect(pending).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty array when none pending", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
expect(scheduler.listPending()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDue", () => {
|
||||
it("returns entries scheduled before now", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const past = new Date(Date.now() - 1_000);
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
scheduler.schedule({ recipient: "a", body: "Past" }, past);
|
||||
scheduler.schedule({ recipient: "b", body: "Future" }, future);
|
||||
const due = scheduler.getDue();
|
||||
expect(due).toHaveLength(1);
|
||||
expect(due[0]?.input.body).toBe("Past");
|
||||
});
|
||||
|
||||
it("excludes cancelled entries", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const past = new Date(Date.now() - 1_000);
|
||||
const entry = scheduler.schedule(
|
||||
{ recipient: "a", body: "A" },
|
||||
past
|
||||
);
|
||||
scheduler.cancel(entry.id);
|
||||
expect(scheduler.getDue()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts custom time for testing", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const target = Date.now() + 5_000;
|
||||
scheduler.schedule(
|
||||
{ recipient: "a", body: "A" },
|
||||
new Date(target - 1)
|
||||
);
|
||||
expect(scheduler.getDue(target)).toHaveLength(1);
|
||||
expect(scheduler.getDue(target - 2)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processDue", () => {
|
||||
it("processes due notifications and removes them", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const past = new Date(Date.now() - 1_000);
|
||||
const processed: string[] = [];
|
||||
scheduler.onDue(async (input) => {
|
||||
processed.push(input.body);
|
||||
});
|
||||
scheduler.schedule({ recipient: "a", body: "Due1" }, past);
|
||||
scheduler.schedule({ recipient: "b", body: "Due2" }, past);
|
||||
const count = await scheduler.processDue();
|
||||
expect(count).toBe(2);
|
||||
expect(processed).toEqual(["Due1", "Due2"]);
|
||||
expect(scheduler.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not process future notifications", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
scheduler.onDue(async () => {
|
||||
/* noop */
|
||||
});
|
||||
scheduler.schedule({ recipient: "a", body: "Future" }, future);
|
||||
const count = await scheduler.processDue();
|
||||
expect(count).toBe(0);
|
||||
expect(scheduler.size).toBe(1);
|
||||
});
|
||||
|
||||
it("works without a handler (drops due entries)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
const past = new Date(Date.now() - 1_000);
|
||||
scheduler.schedule({ recipient: "a", body: "A" }, past);
|
||||
const count = await scheduler.processDue();
|
||||
expect(count).toBe(0);
|
||||
expect(scheduler.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("start/stop", () => {
|
||||
it("start is idempotent", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler(10_000);
|
||||
scheduler.start();
|
||||
scheduler.start();
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
it("stop is idempotent", { timeout: 5000 }, () => {
|
||||
scheduler = new NotificationScheduler();
|
||||
scheduler.stop();
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
it("start triggers interval-based processing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Use a tiny interval and a past-due entry; the interval tick
|
||||
// is what we care about — the entry is removed after one tick.
|
||||
scheduler = new NotificationScheduler(1);
|
||||
scheduler.schedule(
|
||||
{ recipient: "a", body: "A" },
|
||||
new Date(Date.now() - 1)
|
||||
);
|
||||
scheduler.onDue(async () => {
|
||||
/* noop */
|
||||
});
|
||||
scheduler.start();
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
expect(scheduler.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { NotificationNotFoundError } from "../error-codes/error-codes.js";
|
||||
import type { NotificationInput } from "../types/notification.types.js";
|
||||
|
||||
/** A scheduled notification entry. */
|
||||
export interface ScheduledNotification {
|
||||
readonly id: string;
|
||||
readonly input: NotificationInput;
|
||||
readonly scheduledFor: number;
|
||||
readonly createdAt: string;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
/** Callback invoked when a scheduled notification is due. */
|
||||
export type ScheduleHandler = (input: NotificationInput) => Promise<void>;
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Schedules notifications for future delivery. The scheduler does not
|
||||
* fire the actual send — callers register an `onDue` handler that
|
||||
* receives the original `NotificationInput`, typically wiring it back
|
||||
* into `NotificationRouter.send` or `enqueue`.
|
||||
*/
|
||||
export class NotificationScheduler {
|
||||
private readonly scheduled = new Map<string, ScheduledNotification>();
|
||||
private nextId = 1;
|
||||
private handler?: ScheduleHandler;
|
||||
private intervalId: ReturnType<typeof setInterval> | undefined = undefined;
|
||||
private readonly checkIntervalMs: number;
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(checkIntervalMs = 1_000, clock: Clock = defaultClock) {
|
||||
this.checkIntervalMs = checkIntervalMs;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** Register the handler invoked for each due entry. */
|
||||
public onDue(handler: ScheduleHandler): void {
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
/** Schedule a notification for future delivery. */
|
||||
public schedule(
|
||||
input: NotificationInput,
|
||||
deliverAt: Date
|
||||
): ScheduledNotification {
|
||||
const id = `sched_${this.nextId++}`;
|
||||
const entry: ScheduledNotification = {
|
||||
id,
|
||||
input,
|
||||
scheduledFor: deliverAt.getTime(),
|
||||
createdAt: new Date(this.clock()).toISOString(),
|
||||
cancelled: false,
|
||||
};
|
||||
this.scheduled.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Cancel a scheduled notification (throws when id is unknown). */
|
||||
public cancel(id: string): ScheduledNotification {
|
||||
const entry = this.scheduled.get(id);
|
||||
if (entry === undefined) {
|
||||
throw new NotificationNotFoundError({
|
||||
context: { notificationId: id },
|
||||
});
|
||||
}
|
||||
entry.cancelled = true;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Retrieve a scheduled notification (throws when id is unknown). */
|
||||
public get(id: string): ScheduledNotification {
|
||||
const entry = this.scheduled.get(id);
|
||||
if (entry === undefined) {
|
||||
throw new NotificationNotFoundError({
|
||||
context: { notificationId: id },
|
||||
});
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** List every pending (non-cancelled) scheduled notification. */
|
||||
public listPending(): ScheduledNotification[] {
|
||||
return [...this.scheduled.values()].filter((e) => !e.cancelled);
|
||||
}
|
||||
|
||||
/** Notifications whose `scheduledFor <= now`. */
|
||||
public getDue(now?: number): ScheduledNotification[] {
|
||||
const currentTime = now ?? this.clock();
|
||||
return [...this.scheduled.values()].filter(
|
||||
(e) => !e.cancelled && e.scheduledFor <= currentTime
|
||||
);
|
||||
}
|
||||
|
||||
/** Process every due entry through the handler and remove them. */
|
||||
public async processDue(): Promise<number> {
|
||||
const due = this.getDue();
|
||||
let processed = 0;
|
||||
|
||||
for (const entry of due) {
|
||||
if (this.handler !== undefined && !entry.cancelled) {
|
||||
await this.handler(entry.input);
|
||||
processed++;
|
||||
}
|
||||
this.scheduled.delete(entry.id);
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/** Start automatic interval-based processing. */
|
||||
public start(): void {
|
||||
if (this.intervalId !== undefined) return;
|
||||
this.intervalId = setInterval(() => {
|
||||
void this.processDue();
|
||||
}, this.checkIntervalMs);
|
||||
}
|
||||
|
||||
/** Stop automatic processing (idempotent). */
|
||||
public stop(): void {
|
||||
if (this.intervalId !== undefined) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Total number of scheduled (including cancelled) entries. */
|
||||
public get size(): number {
|
||||
return this.scheduled.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# `schemas` — Zod schemas at every public boundary
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input before passing it into a constructor or public method. The `NotificationRouter` constructor calls `NotificationRouterConfigSchema.parse(...)` at the boundary; other classes leave validation as an opt-in for the caller because validating on every send is wasteful — the consumer chooses when to enforce.
|
||||
|
||||
## Features
|
||||
|
||||
- Schemas for every channel config, every public bundle, and every enum: `NotificationChannelSchema`, `NotificationPrioritySchema`, `NotificationStatusSchema`, `RateLimitRuleSchema`, `ChannelConfigSchema`, `RoutingRuleSchema`, `NotificationTemplateSchema`, `TemplateRegistrationSchema`, `NotificationInputSchema`, `NotificationRouterConfigSchema`, `EmailChannelConfigSchema`, `SmsChannelConfigSchema`, `VapidKeysSchema`, `PushChannelConfigSchema`, `WebhookChannelConfigSchema`, `DigestChannelConfigSchema`, `RetryConfigSchema`, `QuietHoursConfigSchema`, `StoredPreferencesSchema`, `PreferenceManagerOptionsSchema`.
|
||||
- Function-valued fields (`sendFn`, `getSubscriptions`, `fetchFn`, `condition`, `formatDigest`, `store`, `clock`) are typed as `z.unknown()` — Zod cannot introspect callables, and the TypeScript surface is the source of truth for shapes.
|
||||
- Boundary defaults are NOT injected — schemas validate shape only, defaults live in the consuming classes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { NotificationInputSchema } from "@suaveplan/notification-channel";
|
||||
|
||||
const input = NotificationInputSchema.parse(req.body);
|
||||
await router.send(input);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
NotificationRouterConfigSchema,
|
||||
StoredPreferencesSchema,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
// Validate config loaded from a YAML file:
|
||||
const cfg = NotificationRouterConfigSchema.parse(yaml.parse(text));
|
||||
const router = new NotificationRouter(cfg);
|
||||
|
||||
// Validate stored prefs at the persistence boundary:
|
||||
const safe = StoredPreferencesSchema.parse(row);
|
||||
await store.set(safe.userId, safe);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Each named export is a `z.ZodType`. Use `.parse(value)` to validate-or-throw, `.safeParse(value)` to validate-or-result. The schemas describe shape only — runtime behaviour, defaults, and engine-pluggable callables remain owned by the consuming classes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`exactOptionalPropertyTypes: true` in `tsconfig.base.json` makes Zod's `.optional()` behave correctly: an explicit `undefined` value is rejected when the field is absent in the input record, but is accepted on output because the consumer might spread defaults. We use `z.record(z.string(), z.unknown())` for `metadata`-style fields because notification metadata is genuinely free-form. The `RoutingRule.condition` and every `sendFn` / `fetchFn` are `z.unknown()` so YAML-loaded configs (which cannot carry callables) parse cleanly — TypeScript will still complain at the call site if the values are not callable.
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ChannelConfigSchema,
|
||||
DigestChannelConfigSchema,
|
||||
EmailChannelConfigSchema,
|
||||
NotificationChannelSchema,
|
||||
NotificationInputSchema,
|
||||
NotificationPrioritySchema,
|
||||
NotificationRouterConfigSchema,
|
||||
NotificationStatusSchema,
|
||||
NotificationTemplateSchema,
|
||||
PreferenceManagerOptionsSchema,
|
||||
PushChannelConfigSchema,
|
||||
QuietHoursConfigSchema,
|
||||
RateLimitRuleSchema,
|
||||
RetryConfigSchema,
|
||||
RoutingRuleSchema,
|
||||
SmsChannelConfigSchema,
|
||||
StoredPreferencesSchema,
|
||||
TemplateRegistrationSchema,
|
||||
VapidKeysSchema,
|
||||
WebhookChannelConfigSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("schemas", () => {
|
||||
it("every public option schema parses a real example", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(NotificationChannelSchema.parse("email")).toBe("email");
|
||||
expect(NotificationPrioritySchema.parse("urgent")).toBe("urgent");
|
||||
expect(NotificationStatusSchema.parse("sent")).toBe("sent");
|
||||
expect(
|
||||
RateLimitRuleSchema.parse({ maxCount: 5, windowMs: 60_000 })
|
||||
).toEqual({ maxCount: 5, windowMs: 60_000 });
|
||||
expect(
|
||||
ChannelConfigSchema.parse({
|
||||
channel: "email",
|
||||
enabled: true,
|
||||
rateLimit: 10,
|
||||
})
|
||||
).toMatchObject({ channel: "email", enabled: true, rateLimit: 10 });
|
||||
expect(
|
||||
RoutingRuleSchema.parse({
|
||||
id: "r1",
|
||||
name: "n",
|
||||
channels: ["email"],
|
||||
})
|
||||
).toMatchObject({ id: "r1", name: "n" });
|
||||
expect(
|
||||
NotificationTemplateSchema.parse({
|
||||
id: "t1",
|
||||
name: "n",
|
||||
channel: "email",
|
||||
bodyTemplate: "Hi {{x}}",
|
||||
defaultPriority: "normal",
|
||||
})
|
||||
).toMatchObject({ id: "t1", defaultPriority: "normal" });
|
||||
expect(
|
||||
TemplateRegistrationSchema.parse({
|
||||
id: "t1",
|
||||
name: "n",
|
||||
defaultTemplate: { bodyTemplate: "Body" },
|
||||
})
|
||||
).toMatchObject({ id: "t1" });
|
||||
expect(
|
||||
NotificationInputSchema.parse({
|
||||
recipient: "a@b.c",
|
||||
body: "Hi",
|
||||
})
|
||||
).toMatchObject({ recipient: "a@b.c" });
|
||||
expect(
|
||||
NotificationRouterConfigSchema.parse({
|
||||
channels: [{ channel: "email", enabled: true }],
|
||||
defaultChannel: "email",
|
||||
})
|
||||
).toMatchObject({ defaultChannel: "email" });
|
||||
expect(
|
||||
EmailChannelConfigSchema.parse({
|
||||
from: "from@example.com",
|
||||
sendFn: () => undefined,
|
||||
})
|
||||
).toMatchObject({ from: "from@example.com" });
|
||||
expect(
|
||||
SmsChannelConfigSchema.parse({
|
||||
from: "+1",
|
||||
sendFn: () => undefined,
|
||||
})
|
||||
).toMatchObject({ from: "+1" });
|
||||
expect(
|
||||
VapidKeysSchema.parse({
|
||||
publicKey: "p",
|
||||
privateKey: "k",
|
||||
subject: "mailto:a@b.c",
|
||||
})
|
||||
).toMatchObject({ publicKey: "p" });
|
||||
expect(
|
||||
PushChannelConfigSchema.parse({
|
||||
vapid: {
|
||||
publicKey: "p",
|
||||
privateKey: "k",
|
||||
subject: "mailto:a@b.c",
|
||||
},
|
||||
getSubscriptions: () => undefined,
|
||||
})
|
||||
).toMatchObject({});
|
||||
expect(WebhookChannelConfigSchema.parse({})).toEqual({});
|
||||
expect(DigestChannelConfigSchema.parse({ delegate: {} })).toMatchObject(
|
||||
{}
|
||||
);
|
||||
expect(
|
||||
RetryConfigSchema.parse({ maxRetries: 1, baseDelayMs: 100 })
|
||||
).toMatchObject({ maxRetries: 1 });
|
||||
expect(
|
||||
QuietHoursConfigSchema.parse({ startHour: 22, endHour: 7 })
|
||||
).toMatchObject({ startHour: 22 });
|
||||
expect(
|
||||
StoredPreferencesSchema.parse({
|
||||
userId: "u",
|
||||
enabledChannels: ["email"],
|
||||
disabledChannels: [],
|
||||
})
|
||||
).toMatchObject({ userId: "u" });
|
||||
expect(
|
||||
PreferenceManagerOptionsSchema.parse({ store: {} })
|
||||
).toMatchObject({});
|
||||
});
|
||||
|
||||
it("router config schema rejects malformed channels", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
NotificationRouterConfigSchema.parse({
|
||||
channels: "not-an-array",
|
||||
defaultChannel: "email",
|
||||
})
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
NotificationRouterConfigSchema.parse({
|
||||
channels: [{ channel: "email", enabled: true }],
|
||||
defaultChannel: "carrier-pigeon",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("input schema rejects empty recipient", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
NotificationInputSchema.parse({ recipient: "", body: "x" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("quiet-hours schema rejects out-of-range hours", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
QuietHoursConfigSchema.parse({ startHour: 25, endHour: 7 })
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schemas for every public option bundle. Callers may invoke
|
||||
* `.parse(...)` to validate untrusted input before passing it into
|
||||
* any constructor or public method.
|
||||
*
|
||||
* The schemas describe shape only — function-valued fields like
|
||||
* `sendFn`, `fetchFn`, `condition`, and `getSubscriptions` are typed
|
||||
* as `z.unknown()` so Zod does not attempt to introspect callable
|
||||
* values; the TypeScript surface remains the source of truth for
|
||||
* callable shapes.
|
||||
*/
|
||||
|
||||
export const NotificationChannelSchema = z.enum([
|
||||
"email",
|
||||
"sms",
|
||||
"push",
|
||||
"in_app",
|
||||
"webhook",
|
||||
]);
|
||||
|
||||
export const NotificationPrioritySchema = z.enum([
|
||||
"low",
|
||||
"normal",
|
||||
"high",
|
||||
"urgent",
|
||||
]);
|
||||
|
||||
export const NotificationStatusSchema = z.enum([
|
||||
"pending",
|
||||
"sending",
|
||||
"sent",
|
||||
"delivered",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export const RateLimitRuleSchema = z.object({
|
||||
maxCount: z.number().int().nonnegative(),
|
||||
windowMs: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const ChannelConfigSchema = z.object({
|
||||
channel: NotificationChannelSchema,
|
||||
enabled: z.boolean(),
|
||||
rateLimit: z.number().int().nonnegative().optional(),
|
||||
retryPolicy: z
|
||||
.object({
|
||||
maxRetries: z.number().int().nonnegative(),
|
||||
backoffMs: z.number().int().nonnegative(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const RoutingRuleSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
channels: z.array(NotificationChannelSchema),
|
||||
priority: NotificationPrioritySchema.optional(),
|
||||
condition: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const NotificationTemplateSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
channel: NotificationChannelSchema,
|
||||
subject: z.string().optional(),
|
||||
bodyTemplate: z.string(),
|
||||
defaultPriority: NotificationPrioritySchema,
|
||||
});
|
||||
|
||||
export const TemplateRegistrationSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
defaultTemplate: z.object({
|
||||
subject: z.string().optional(),
|
||||
bodyTemplate: z.string(),
|
||||
}),
|
||||
channelTemplates: z
|
||||
.record(
|
||||
NotificationChannelSchema,
|
||||
z.object({
|
||||
subject: z.string().optional(),
|
||||
bodyTemplate: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const NotificationInputSchema = z.object({
|
||||
recipient: z.string().min(1),
|
||||
channel: NotificationChannelSchema.optional(),
|
||||
subject: z.string().optional(),
|
||||
body: z.string(),
|
||||
priority: NotificationPrioritySchema.optional(),
|
||||
templateId: z.string().optional(),
|
||||
templateData: z.record(z.string(), z.string()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const NotificationRouterConfigSchema = z.object({
|
||||
channels: z.array(ChannelConfigSchema),
|
||||
templates: z.array(NotificationTemplateSchema).optional(),
|
||||
routingRules: z.array(RoutingRuleSchema).optional(),
|
||||
defaultChannel: NotificationChannelSchema,
|
||||
deliveryChannels: z.array(z.unknown()).optional(),
|
||||
clock: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const EmailChannelConfigSchema = z.object({
|
||||
from: z.string().min(1),
|
||||
sendFn: z.unknown(),
|
||||
clock: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const SmsChannelConfigSchema = z.object({
|
||||
from: z.string().min(1),
|
||||
sendFn: z.unknown(),
|
||||
clock: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const VapidKeysSchema = z.object({
|
||||
publicKey: z.string().min(1),
|
||||
privateKey: z.string().min(1),
|
||||
subject: z.string().min(1),
|
||||
});
|
||||
|
||||
export const PushChannelConfigSchema = z.object({
|
||||
vapid: VapidKeysSchema,
|
||||
getSubscriptions: z.unknown(),
|
||||
sendFn: z.unknown().optional(),
|
||||
fetchFn: z.unknown().optional(),
|
||||
ttl: z.number().int().positive().optional(),
|
||||
clock: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const WebhookChannelConfigSchema = z.object({
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
fetchFn: z.unknown().optional(),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
clock: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const DigestChannelConfigSchema = z.object({
|
||||
delegate: z.unknown(),
|
||||
intervalMs: z.number().int().nonnegative().optional(),
|
||||
maxPerDigest: z.number().int().positive().optional(),
|
||||
formatDigest: z.unknown().optional(),
|
||||
clock: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const RetryConfigSchema = z.object({
|
||||
maxRetries: z.number().int().nonnegative(),
|
||||
baseDelayMs: z.number().int().nonnegative(),
|
||||
maxDelayMs: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const QuietHoursConfigSchema = z.object({
|
||||
startHour: z.number().int().min(0).max(23),
|
||||
endHour: z.number().int().min(0).max(23),
|
||||
days: z.array(z.number().int().min(0).max(6)).optional(),
|
||||
});
|
||||
|
||||
export const StoredPreferencesSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
enabledChannels: z.array(NotificationChannelSchema),
|
||||
disabledChannels: z.array(NotificationChannelSchema),
|
||||
quietHours: QuietHoursConfigSchema.optional(),
|
||||
urgentThreshold: NotificationPrioritySchema.optional(),
|
||||
});
|
||||
|
||||
export const PreferenceManagerOptionsSchema = z.object({
|
||||
store: z.unknown(),
|
||||
defaultChannels: z.array(NotificationChannelSchema).optional(),
|
||||
clock: z.unknown().optional(),
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
# `template-engine` — Template registry and renderer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered output. Templates carry a `defaultTemplate` (used when no channel variant matches) plus optional `channelTemplates` keyed by `NotificationChannel`, with `{{var}}` placeholders substituted from a flat `Record<string,string>`. Missing keys are left as-is so QA can spot them.
|
||||
|
||||
## Features
|
||||
|
||||
- Register / unregister / list templates by string id.
|
||||
- Per-channel variants (`channelTemplates.email`, `.sms`, etc.) with fallback to `defaultTemplate`.
|
||||
- `{{var}}` placeholder substitution against a flat string-valued record.
|
||||
- Throws `TemplateNotFoundError` for unknown ids — no silent empty renders.
|
||||
- Universal — no DOM/Node-specific APIs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TemplateEngine } from "@suaveplan/notification-channel";
|
||||
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "welcome",
|
||||
name: "Welcome",
|
||||
defaultTemplate: {
|
||||
subject: "Welcome {{name}}",
|
||||
bodyTemplate: "Hello {{name}}, welcome to {{app}}!",
|
||||
},
|
||||
});
|
||||
|
||||
const rendered = engine.render("welcome", { name: "Alice", app: "Suaveplan" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
engine.register({
|
||||
id: "order-status",
|
||||
name: "Order status",
|
||||
defaultTemplate: {
|
||||
subject: "Order {{orderId}} — {{status}}",
|
||||
bodyTemplate: "Order #{{orderId}}: {{status}}",
|
||||
},
|
||||
channelTemplates: {
|
||||
sms: { bodyTemplate: "Order {{orderId}}: {{status}}" },
|
||||
email: {
|
||||
subject: "Order {{orderId}} update",
|
||||
bodyTemplate: "<h1>Order {{orderId}}</h1><p>{{status}}</p>",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sms = engine.render(
|
||||
"order-status",
|
||||
{ orderId: "12345", status: "shipped" },
|
||||
"sms"
|
||||
);
|
||||
const email = engine.render(
|
||||
"order-status",
|
||||
{ orderId: "12345", status: "shipped" },
|
||||
"email"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TemplateEngine` class.
|
||||
- `register(t: TemplateRegistration): void`
|
||||
- `unregister(id: string): boolean`
|
||||
- `has(id: string): boolean`
|
||||
- `listIds(): string[]`
|
||||
- `render(id, data?, channel?): RenderedTemplate`
|
||||
- `TemplateRegistration`, `ChannelTemplateBody`, `ChannelTemplate`, `RenderedTemplate`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Substitution uses `String.prototype.split(...).join(...)` rather than `replace(/.../g, ...)` so dollar-escape sequences in the value (`$&`, `$1`) flow through verbatim. Subjects flow through the same substitution pass when set. Unknown placeholders are intentionally preserved — silently dropping them masks integration mismatches that QA would otherwise catch. The engine is intentionally simple: no conditionals, no loops, no template inheritance. Consumers needing those features should plug a real template language (Handlebars, Liquid, MJML) into a custom `TemplateEngine` decorator or render upstream.
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NotificationChannelError } from "../error-codes/error-codes.js";
|
||||
import { TemplateEngine } from "./template-engine.js";
|
||||
|
||||
describe("TemplateEngine", () => {
|
||||
describe("register and lookup", () => {
|
||||
it("registers a template", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "tpl_1",
|
||||
name: "Welcome",
|
||||
defaultTemplate: { bodyTemplate: "Hello {{name}}" },
|
||||
});
|
||||
expect(engine.has("tpl_1")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports false for unregistered template", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
expect(engine.has("nonexistent")).toBe(false);
|
||||
});
|
||||
|
||||
it("lists registered template ids", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "tpl_1",
|
||||
name: "A",
|
||||
defaultTemplate: { bodyTemplate: "A" },
|
||||
});
|
||||
engine.register({
|
||||
id: "tpl_2",
|
||||
name: "B",
|
||||
defaultTemplate: { bodyTemplate: "B" },
|
||||
});
|
||||
expect(engine.listIds()).toEqual(["tpl_1", "tpl_2"]);
|
||||
});
|
||||
|
||||
it("unregisters a template", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "tpl_1",
|
||||
name: "A",
|
||||
defaultTemplate: { bodyTemplate: "A" },
|
||||
});
|
||||
expect(engine.unregister("tpl_1")).toBe(true);
|
||||
expect(engine.has("tpl_1")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when unregistering non-existent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
expect(engine.unregister("fake")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("render", () => {
|
||||
it("renders with variable substitution", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "tpl_1",
|
||||
name: "Greet",
|
||||
defaultTemplate: {
|
||||
subject: "Hello {{name}}",
|
||||
bodyTemplate: "Welcome, {{name}}! Your code is {{code}}.",
|
||||
},
|
||||
});
|
||||
const result = engine.render("tpl_1", {
|
||||
name: "Alice",
|
||||
code: "1234",
|
||||
});
|
||||
expect(result.body).toBe("Welcome, Alice! Your code is 1234.");
|
||||
expect(result.subject).toBe("Hello Alice");
|
||||
});
|
||||
|
||||
it("renders without data", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "tpl_1",
|
||||
name: "Static",
|
||||
defaultTemplate: { bodyTemplate: "No variables here" },
|
||||
});
|
||||
const result = engine.render("tpl_1");
|
||||
expect(result.body).toBe("No variables here");
|
||||
});
|
||||
|
||||
it("throws for non-existent template", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
expect(() => engine.render("fake")).toThrow(
|
||||
NotificationChannelError
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves unreplaced variables intact", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "tpl_1",
|
||||
name: "Partial",
|
||||
defaultTemplate: {
|
||||
bodyTemplate: "Hi {{name}}, code: {{code}}",
|
||||
},
|
||||
});
|
||||
const result = engine.render("tpl_1", { name: "Bob" });
|
||||
expect(result.body).toBe("Hi Bob, code: {{code}}");
|
||||
});
|
||||
|
||||
it("returns undefined subject when not set", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "tpl_1",
|
||||
name: "NoSubject",
|
||||
defaultTemplate: { bodyTemplate: "Body only" },
|
||||
});
|
||||
const result = engine.render("tpl_1");
|
||||
expect(result.subject).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("channel-specific templates", () => {
|
||||
it("uses channel-specific template when available", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "tpl_1",
|
||||
name: "Multi",
|
||||
defaultTemplate: {
|
||||
subject: "Default Subject",
|
||||
bodyTemplate: "Default body for {{name}}",
|
||||
},
|
||||
channelTemplates: {
|
||||
sms: { bodyTemplate: "SMS: Hi {{name}}" },
|
||||
email: {
|
||||
subject: "Email Subject for {{name}}",
|
||||
bodyTemplate: "<h1>Hello {{name}}</h1>",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const smsResult = engine.render("tpl_1", { name: "Alice" }, "sms");
|
||||
expect(smsResult.body).toBe("SMS: Hi Alice");
|
||||
expect(smsResult.subject).toBeUndefined();
|
||||
|
||||
const emailResult = engine.render(
|
||||
"tpl_1",
|
||||
{ name: "Alice" },
|
||||
"email"
|
||||
);
|
||||
expect(emailResult.body).toBe("<h1>Hello Alice</h1>");
|
||||
expect(emailResult.subject).toBe("Email Subject for Alice");
|
||||
});
|
||||
|
||||
it("falls back to default when channel variant missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "tpl_1",
|
||||
name: "Fallback",
|
||||
defaultTemplate: { bodyTemplate: "Default body" },
|
||||
channelTemplates: {
|
||||
email: { bodyTemplate: "Email body" },
|
||||
},
|
||||
});
|
||||
const result = engine.render("tpl_1", undefined, "sms");
|
||||
expect(result.body).toBe("Default body");
|
||||
});
|
||||
|
||||
it("uses default when no channel specified", { timeout: 5000 }, () => {
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "tpl_1",
|
||||
name: "NoChannel",
|
||||
defaultTemplate: { bodyTemplate: "Default" },
|
||||
channelTemplates: { email: { bodyTemplate: "Email" } },
|
||||
});
|
||||
const result = engine.render("tpl_1");
|
||||
expect(result.body).toBe("Default");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { TemplateNotFoundError } from "../error-codes/error-codes.js";
|
||||
import type { NotificationChannel } from "../types/notification.types.js";
|
||||
|
||||
/** Channel-specific template variant. */
|
||||
export interface ChannelTemplateBody {
|
||||
readonly subject?: string;
|
||||
readonly bodyTemplate: string;
|
||||
}
|
||||
|
||||
/** Template registration entry supporting channel-specific variants. */
|
||||
export interface TemplateRegistration {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
/** Default template used when no channel-specific variant exists. */
|
||||
readonly defaultTemplate: ChannelTemplateBody;
|
||||
/** Channel-specific template overrides. */
|
||||
readonly channelTemplates?: Partial<
|
||||
Record<NotificationChannel, ChannelTemplateBody>
|
||||
>;
|
||||
}
|
||||
|
||||
/** A registered template projected for a specific channel. */
|
||||
export interface ChannelTemplate {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly subject?: string;
|
||||
readonly bodyTemplate: string;
|
||||
readonly channel?: NotificationChannel;
|
||||
}
|
||||
|
||||
/** Result of rendering a template. */
|
||||
export interface RenderedTemplate {
|
||||
readonly subject?: string;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages notification templates with `{{var}}` substitution and
|
||||
* channel-specific variants. Variables that have no value in `data`
|
||||
* are left intact so callers can detect missing substitutions during
|
||||
* QA without the template silently producing nonsense.
|
||||
*/
|
||||
export class TemplateEngine {
|
||||
private readonly templates = new Map<string, TemplateRegistration>();
|
||||
|
||||
/** Register a template (overrides any existing entry with the same id). */
|
||||
public register(template: TemplateRegistration): void {
|
||||
this.templates.set(template.id, template);
|
||||
}
|
||||
|
||||
/** Remove a registered template. */
|
||||
public unregister(id: string): boolean {
|
||||
return this.templates.delete(id);
|
||||
}
|
||||
|
||||
/** Check whether a template exists. */
|
||||
public has(id: string): boolean {
|
||||
return this.templates.has(id);
|
||||
}
|
||||
|
||||
/** List every registered template id. */
|
||||
public listIds(): string[] {
|
||||
return [...this.templates.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a template with variable substitution. Uses the
|
||||
* channel-specific variant when available, otherwise the default.
|
||||
*/
|
||||
public render(
|
||||
templateId: string,
|
||||
data?: Readonly<Record<string, string>>,
|
||||
channel?: NotificationChannel
|
||||
): RenderedTemplate {
|
||||
const registration = this.templates.get(templateId);
|
||||
if (registration === undefined) {
|
||||
throw new TemplateNotFoundError({
|
||||
context: { templateId },
|
||||
});
|
||||
}
|
||||
|
||||
const variantOverride =
|
||||
channel !== undefined
|
||||
? registration.channelTemplates?.[channel]
|
||||
: undefined;
|
||||
const variant: ChannelTemplateBody =
|
||||
variantOverride ?? registration.defaultTemplate;
|
||||
|
||||
let body = variant.bodyTemplate;
|
||||
let subject = variant.subject;
|
||||
|
||||
if (data !== undefined) {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const pattern = `{{${key}}}`;
|
||||
body = body.split(pattern).join(value);
|
||||
if (subject !== undefined) {
|
||||
subject = subject.split(pattern).join(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return subject !== undefined ? { subject, body } : { body };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { VALID_TRANSITIONS } from "./notification.types.js";
|
||||
|
||||
describe("VALID_TRANSITIONS", () => {
|
||||
it("allows pending to sending", { timeout: 5000 }, () => {
|
||||
expect(VALID_TRANSITIONS.pending).toContain("sending");
|
||||
});
|
||||
|
||||
it("allows pending to cancelled", { timeout: 5000 }, () => {
|
||||
expect(VALID_TRANSITIONS.pending).toContain("cancelled");
|
||||
});
|
||||
|
||||
it("allows sending to sent", { timeout: 5000 }, () => {
|
||||
expect(VALID_TRANSITIONS.sending).toContain("sent");
|
||||
});
|
||||
|
||||
it("allows sending to failed", { timeout: 5000 }, () => {
|
||||
expect(VALID_TRANSITIONS.sending).toContain("failed");
|
||||
});
|
||||
|
||||
it("allows sent to delivered", { timeout: 5000 }, () => {
|
||||
expect(VALID_TRANSITIONS.sent).toContain("delivered");
|
||||
});
|
||||
|
||||
it("allows sent to failed (e.g. async bounce)", { timeout: 5000 }, () => {
|
||||
expect(VALID_TRANSITIONS.sent).toContain("failed");
|
||||
});
|
||||
|
||||
it("has no transitions from delivered", { timeout: 5000 }, () => {
|
||||
expect(VALID_TRANSITIONS.delivered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("has no transitions from failed", { timeout: 5000 }, () => {
|
||||
expect(VALID_TRANSITIONS.failed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("has no transitions from cancelled", { timeout: 5000 }, () => {
|
||||
expect(VALID_TRANSITIONS.cancelled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(VALID_TRANSITIONS)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/** Supported notification delivery channels. */
|
||||
export type NotificationChannel =
|
||||
| "email"
|
||||
| "sms"
|
||||
| "push"
|
||||
| "in_app"
|
||||
| "webhook";
|
||||
|
||||
/** Priority levels for notifications. */
|
||||
export type NotificationPriority = "low" | "normal" | "high" | "urgent";
|
||||
|
||||
/** Notification delivery status. */
|
||||
export type NotificationStatus =
|
||||
| "pending"
|
||||
| "sending"
|
||||
| "sent"
|
||||
| "delivered"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
/** A notification record. */
|
||||
export interface Notification {
|
||||
readonly id: string;
|
||||
readonly channel: NotificationChannel;
|
||||
readonly recipient: string;
|
||||
readonly subject?: string;
|
||||
readonly body: string;
|
||||
readonly priority: NotificationPriority;
|
||||
readonly status: NotificationStatus;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
readonly createdAt: string;
|
||||
readonly sentAt?: string;
|
||||
readonly deliveredAt?: string;
|
||||
}
|
||||
|
||||
/** A notification template with variable substitution. */
|
||||
export interface NotificationTemplate {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly channel: NotificationChannel;
|
||||
readonly subject?: string;
|
||||
readonly bodyTemplate: string;
|
||||
readonly defaultPriority: NotificationPriority;
|
||||
}
|
||||
|
||||
/** Per-channel retry policy. */
|
||||
export interface ChannelRetryPolicy {
|
||||
readonly maxRetries: number;
|
||||
readonly backoffMs: number;
|
||||
}
|
||||
|
||||
/** Channel configuration with rate limiting and retry policy. */
|
||||
export interface ChannelConfig {
|
||||
readonly channel: NotificationChannel;
|
||||
readonly enabled: boolean;
|
||||
readonly rateLimit?: number;
|
||||
readonly retryPolicy?: ChannelRetryPolicy;
|
||||
}
|
||||
|
||||
/** A routing rule that directs notifications to channels. */
|
||||
export interface RoutingRule {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly channels: readonly NotificationChannel[];
|
||||
readonly priority?: NotificationPriority;
|
||||
readonly condition?: (recipient: string) => boolean;
|
||||
}
|
||||
|
||||
/** Input for sending a notification. */
|
||||
export interface NotificationInput {
|
||||
readonly recipient: string;
|
||||
readonly channel?: NotificationChannel;
|
||||
readonly subject?: string;
|
||||
readonly body: string;
|
||||
readonly priority?: NotificationPriority;
|
||||
readonly templateId?: string;
|
||||
readonly templateData?: Record<string, string>;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Result of sending a notification. */
|
||||
export interface NotificationResult {
|
||||
readonly id: string;
|
||||
readonly channel: NotificationChannel;
|
||||
readonly status: NotificationStatus;
|
||||
readonly sentAt?: string;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
/** Bus event payloads emitted by the NotificationRouter. */
|
||||
export type NotificationEventMap = {
|
||||
"notification.created": Notification;
|
||||
"notification.sent": Notification;
|
||||
"notification.delivered": Notification;
|
||||
"notification.failed": Notification;
|
||||
"notification.cancelled": Notification;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
/** Valid notification status transitions. */
|
||||
export const VALID_TRANSITIONS: Readonly<
|
||||
Record<NotificationStatus, readonly NotificationStatus[]>
|
||||
> = Object.freeze({
|
||||
pending: ["sending", "cancelled"],
|
||||
sending: ["sent", "failed"],
|
||||
sent: ["delivered", "failed"],
|
||||
delivered: [],
|
||||
failed: [],
|
||||
cancelled: [],
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Test-environment setup for @suaveplan/notification-channel.
|
||||
*
|
||||
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any source
|
||||
* module loads so the meter resolved at module load is the real provider's
|
||||
* meter (and not the no-op fallback). Tests that inspect emitted instruments
|
||||
* read them out of `testInMemoryMetricExporter` via `flushTestMetrics()`.
|
||||
*
|
||||
* Universal: no `node:fs`, no `process.cwd()`, no DOM globals.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics(): Promise<
|
||||
ReturnType<typeof testInMemoryMetricExporter.getMetrics>
|
||||
> {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/notification-channel",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user