Initial Commit
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user