Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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(),
};
}
}
}

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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;
}
}
},
};
}
}

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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
);
}
}

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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(),
};
}
}
}

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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(),
};
}
}
}