Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# `messaging`
|
||||
|
||||
Messaging adapters — Discord, Slack, Telegram, SMS, iMessage, and notifications.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `messaging`
|
||||
|
||||
7 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/discord-client`](./discord-client/README.md) | Generic Discord API client for webhooks, bot messaging, and embeds. Runtime-agnostic via injectable `fetch` and `clock`. Maps every documented Discord failure… |
|
||||
| [`@suaveplan/imessage-adapter`](./imessage-adapter/README.md) | macOS iMessage SQLite chat.db watcher and `MessageSourceAdapter` implementation. Polls Apple's `~/Library/Messages/chat.db`, decodes Apple-epoch / nanosecond t… |
|
||||
| [`@suaveplan/notification-channel`](./notification-channel/README.md) | Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentati… |
|
||||
| [`@suaveplan/slack-client`](./slack-client/README.md) | Typed, deterministic Slack client — Web API + incoming-webhook surface — with Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable `Clock… |
|
||||
| [`@suaveplan/sms`](./sms/README.md) | Universal SMS core: provider-agnostic send pipeline with strict E.164 validation, Zod boundaries, template rendering, opt-out registry, token-bucket rate limit… |
|
||||
| [`@suaveplan/sms-providers`](./sms-providers/README.md) | Universal SMS provider implementations for @suaveplan/sms. Real HTTP clients for Twilio (Basic auth + form-encoded + X-Twilio-Signature HMAC-SHA1), Vonage (HMAC-SHA256 webhook), MessageBird (AccessKey + JWT HS256 webhook), Plivo (Basic auth + X-Plivo-Signature-V3 HMAC-SHA256), and AWS SNS (inline SigV4 + RSA-SHA1 SNS notification verifier). Ships ProviderRegistry, httpRequest over injectable FetchLike, composeAbortSignal, SMS_PROVIDERS_CODES + SmsProvidersError, per-provider mapStatus, parseInbound, parseOptOutKeyword, and five OTel instruments. No provider SDK deps; every HTTP call flows through an injectable FetchLike seam. |
|
||||
| [`@suaveplan/telegram-client`](./telegram-client/README.md) | Telegram Bot API client: send/edit/delete messages, six media variants, inline + custom keyboards, typed errors, injectable `fetch` + `clock`, and OpenTelemetr… |
|
||||
<!-- end:auto-docs -->
|
||||
@@ -0,0 +1 @@
|
||||
Messaging adapters — Discord, Slack, Telegram, SMS, iMessage, and notifications.
|
||||
@@ -0,0 +1,100 @@
|
||||
# @suaveplan/discord-client - Generic Discord API client for webhooks, bot messaging, and embeds with injectable fetch, OpenTelemetry instrumentation, and structured error codes
|
||||
|
||||
Generic Discord API client for webhooks, bot messaging, and embeds. Runtime-agnostic via injectable `fetch` and `clock`. Maps every documented Discord failure mode to a stable `DISCORD.*` error code and emits OpenTelemetry counters and histograms for every request.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [bot-client](./src/core/bot-client/discord-bot-client.md) — Implements the bot-token half of `@suaveplan/discord-client`. Wraps the Discord Bot REST API (`/channels/:id/messages`,…
|
||||
- [discord-bot-client](./src/core/bot-client/discord-bot-client.md) — Implements the bot-token half of `@suaveplan/discord-client`. Wraps the Discord Bot REST API (`/channels/:id/messages`,…
|
||||
- [webhook](./src/core/webhook/discord-webhook.md) — Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks are URLs that anyone with the URL can POST…
|
||||
- [discord-webhook](./src/core/webhook/discord-webhook.md) — Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks are URLs that anyone with the URL can POST…
|
||||
- [errors](./src/errors/discord-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `DiscordClientError` class used by every runtime failu…
|
||||
- [discord-client-error](./src/errors/discord-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `DiscordClientError` class used by every runtime failu…
|
||||
- [http](./src/utils/http.md) — Single funnel through which every Discord API and webhook request flows. It maps Discord response codes onto the `Disco…
|
||||
- [rate-limit](./src/utils/rate-limit.md) — Parses Discord's three rate-limit response headers into a single millisecond back-off duration that callers can use to…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/discord-client
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { DiscordBotClient, DiscordWebhook } from "@suaveplan/discord-client";
|
||||
|
||||
const bot = new DiscordBotClient({ token: process.env.DISCORD_BOT_TOKEN! });
|
||||
await bot.sendMessage("123", { content: "hello from the bot" });
|
||||
|
||||
const hook = new DiscordWebhook({
|
||||
webhookUrl: "https://discord.com/api/webhooks/123/abc",
|
||||
});
|
||||
await hook.sendText("hello from a webhook");
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| Bot Client | [src/core/bot-client/discord-bot-client.md](src/core/bot-client/discord-bot-client.md) | `DiscordBotClient` — send/edit/delete messages, reactions, channels, guilds |
|
||||
| Webhook | [src/core/webhook/discord-webhook.md](src/core/webhook/discord-webhook.md) | `DiscordWebhook` — webhook-URL delivery with structured reports |
|
||||
| HTTP Helper | [src/utils/http.md](src/utils/http.md) | Shared request funnel: error mapping, timeout, OTel metrics |
|
||||
| Rate-Limit | [src/utils/rate-limit.md](src/utils/rate-limit.md) | `parseRetryAfterMs` parser for Discord's three rate-limit headers |
|
||||
| Errors | [src/errors/discord-client-error.md](src/errors/discord-client-error.md) | `DISCORD_CODES` registry and `DiscordClientError` |
|
||||
| Types | [src/types/discord-client.types.md](src/types/discord-client.types.md) | Public type surface + Zod boundary schemas |
|
||||
|
||||
## Subpath Exports
|
||||
|
||||
```ts
|
||||
import { DiscordBotClient } from "@suaveplan/discord-client/bot";
|
||||
import { DiscordWebhook } from "@suaveplan/discord-client/webhook";
|
||||
import { DISCORD_CODES } from "@suaveplan/discord-client/errors";
|
||||
import { DiscordMessageSchema } from "@suaveplan/discord-client/types";
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | HTTP | Meaning |
|
||||
|------|------|---------|
|
||||
| `DISCORD.API_ERROR` | 502 | Generic non-2xx bot response |
|
||||
| `DISCORD.AUTH_ERROR` | 401 | Token rejected |
|
||||
| `DISCORD.RATE_LIMITED` | 429 | Honour `error.context.retryAfterMs` |
|
||||
| `DISCORD.CHANNEL_NOT_FOUND` | 404 | Channel missing or bot lacks access |
|
||||
| `DISCORD.GUILD_NOT_FOUND` | 404 | Guild missing or bot is not a member |
|
||||
| `DISCORD.CONFIG_MISSING` | 500 | Required config value missing |
|
||||
| `DISCORD.INVALID_CONFIG` | 500 | Config failed Zod validation |
|
||||
| `DISCORD.INVALID_MESSAGE` | 400 | Message payload failed Zod validation |
|
||||
| `DISCORD.WEBHOOK_FAILED` | 502 | Webhook delivery rejected |
|
||||
| `DISCORD.TIMEOUT` | 504 | Request aborted by timeout |
|
||||
| `DISCORD.FORBIDDEN` | 403 | Caller lacks permissions |
|
||||
|
||||
## Injectable Fetch & Clock
|
||||
|
||||
Both `DiscordBotClient` and `DiscordWebhook` accept `fetch` and `clock` injectables — pass deterministic stubs in tests, omit them in production to use `globalThis.fetch` and `Date.now`.
|
||||
|
||||
```ts
|
||||
import { DiscordBotClient, type FetchLike } from "@suaveplan/discord-client";
|
||||
|
||||
const stubFetch: FetchLike = async () => ({
|
||||
ok: true, status: 200, statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => "", json: async () => ({ id: "1", channelId: "1", content: "x", timestamp: "" }),
|
||||
});
|
||||
|
||||
new DiscordBotClient({
|
||||
token: "abc",
|
||||
fetch: stubFetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
```
|
||||
|
||||
## OTel Metrics
|
||||
|
||||
| Metric | Type | Labels |
|
||||
|--------|------|--------|
|
||||
| `discord_client.request.total` | Counter | `kind`, `method`, `outcome`, `status` |
|
||||
| `discord_client.request.duration_ms` | Histogram | `kind`, `method`, `outcome` |
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Discord Bot Client
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the bot-token half of `@suaveplan/discord-client`. Wraps the
|
||||
Discord Bot REST API (`/channels/:id/messages`, `/guilds/:id`, reactions) with
|
||||
a runtime-agnostic surface: every constructor option is Zod-validated, every
|
||||
network call routes through the shared `executeDiscordRequest` funnel (which
|
||||
emits OTel metrics and maps responses to `DiscordClientError`), and both the
|
||||
HTTP client and the wall clock are injectable so tests stay deterministic
|
||||
without ever touching the real Discord API.
|
||||
|
||||
## Features
|
||||
|
||||
- `sendMessage`, `editMessage`, `deleteMessage`, `getChannel`, `getGuild`,
|
||||
`createReaction`, `getStoredMessage`.
|
||||
- In-memory message cache keyed by Discord message ID. `deleteMessage` evicts.
|
||||
- Boundary validation of `DiscordMessage` via `DiscordMessageSchema`.
|
||||
- Boundary validation of `DiscordBotConfig` via `DiscordBotConfigSchema`.
|
||||
- Per-request `AbortController` timeout (default 10s) enforced through
|
||||
`executeDiscordRequest`.
|
||||
- `Bot <token>` authorization header set on every request.
|
||||
- `/guilds/:id` requests automatically tag `isGuildRequest: true` so 404s
|
||||
raise `DISCORD.GUILD_NOT_FOUND` instead of `DISCORD.CHANNEL_NOT_FOUND`.
|
||||
- camelCase → snake_case mapping (`avatarUrl` → `avatar_url`) for Discord
|
||||
payload compatibility.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DiscordBotClient } from "@suaveplan/discord-client";
|
||||
|
||||
const bot = new DiscordBotClient({ token: process.env.DISCORD_BOT_TOKEN! });
|
||||
await bot.sendMessage("123", { content: "hello" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { DiscordBotClient, type FetchLike } from "@suaveplan/discord-client";
|
||||
|
||||
const fakeFetch: FetchLike = async (_url, _init) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => "",
|
||||
json: async () => ({
|
||||
id: "42",
|
||||
channelId: "1",
|
||||
content: "hi",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
});
|
||||
|
||||
const bot = new DiscordBotClient({
|
||||
token: "abc",
|
||||
fetch: fakeFetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const sent = await bot.sendMessage("1", { content: "hi" });
|
||||
expect(bot.getStoredMessage(sent.id)).toEqual(sent);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new DiscordBotClient(config: DiscordBotConfig)` — throws
|
||||
`DiscordClientError(DISCORD.CONFIG_MISSING)` if `token` is missing,
|
||||
`DiscordClientError(DISCORD.INVALID_CONFIG)` for other Zod failures.
|
||||
- `sendMessage(channelId, message)` → `DiscordResponse`. Validates payload.
|
||||
- `editMessage(channelId, messageId, partial)` → `DiscordResponse`.
|
||||
- `deleteMessage(channelId, messageId)` → `void`.
|
||||
- `getChannel(channelId)` → `DiscordChannel`.
|
||||
- `getGuild(guildId)` → `{ id; name }`.
|
||||
- `createReaction(channelId, messageId, emoji)` → `void`.
|
||||
- `getStoredMessage(messageId)` → `DiscordResponse | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The in-memory `messages` cache is intentionally simple — Discord assigns
|
||||
monotonic snowflake IDs so no eviction policy is required for typical usage.
|
||||
Callers needing TTLs should layer their own cache around `getStoredMessage`.
|
||||
`createReaction` URL-encodes the emoji segment because Discord rejects raw
|
||||
non-ASCII path segments. `request<T>` deliberately funnels through
|
||||
`executeDiscordRequest` so the bot client contributes zero bespoke error or
|
||||
metric code — the funnel owns the entire error mapping and observability
|
||||
surface.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Discord Webhook
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks
|
||||
are URLs that anyone with the URL can POST to — no bot token required — and
|
||||
they accept the same message shape as the bot API. This module wraps that
|
||||
endpoint with Zod boundary validation, injectable fetch/clock, OpenTelemetry
|
||||
instrumentation (inherited from the shared HTTP funnel), and a structured
|
||||
`DiscordDeliveryReport` return value so callers get persistable delivery
|
||||
telemetry rather than a raw Discord response.
|
||||
|
||||
## Features
|
||||
|
||||
- `send(message)`, `sendText(content)`, `sendEmbed(embed)` convenience methods.
|
||||
- Defaults `username` and `avatarUrl` from the constructor when the message
|
||||
payload omits them.
|
||||
- Threaded webhook routing via `?thread_id=<id>` query string.
|
||||
- Always sets `?wait=true` so Discord returns the persisted message ID,
|
||||
required for the `DiscordDeliveryReport.messageId` field.
|
||||
- Boundary validation:
|
||||
- `DiscordWebhookConfigSchema` at construction; missing/non-string URL →
|
||||
`DISCORD.CONFIG_MISSING`, other failures (bad URL shape, wrong host) →
|
||||
`DISCORD.INVALID_CONFIG`.
|
||||
- `DiscordMessageSchema` on every `send` call → `DISCORD.INVALID_MESSAGE`.
|
||||
- Uses the shared `executeDiscordRequest` funnel so error mapping
|
||||
(401/403/404/429/5xx) is identical to the bot client; on the webhook path,
|
||||
generic non-ok responses raise `DISCORD.WEBHOOK_FAILED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DiscordWebhook } from "@suaveplan/discord-client";
|
||||
|
||||
const hook = new DiscordWebhook({
|
||||
webhookUrl: "https://discord.com/api/webhooks/123/abc",
|
||||
});
|
||||
await hook.sendText("deploy succeeded");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { DiscordWebhook, type FetchLike } from "@suaveplan/discord-client";
|
||||
|
||||
const stub: FetchLike = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => "",
|
||||
json: async () => ({ id: "42", channel_id: "1" }),
|
||||
});
|
||||
|
||||
const hook = new DiscordWebhook({
|
||||
webhookUrl: "https://discord.com/api/webhooks/1/tok",
|
||||
username: "deploybot",
|
||||
fetch: stub,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const report = await hook.sendEmbed({
|
||||
title: "Deploy",
|
||||
description: "succeeded",
|
||||
color: 0x00ff00,
|
||||
});
|
||||
expect(report.status).toBe("delivered");
|
||||
expect(report.timestamp).toEqual(new Date(1_700_000_000_000));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new DiscordWebhook(config: DiscordWebhookConfig)` — Zod-validated;
|
||||
missing or non-string `webhookUrl` raises `DISCORD.CONFIG_MISSING`; any
|
||||
other validation failure (non-Discord host, bad URL) raises
|
||||
`DISCORD.INVALID_CONFIG`.
|
||||
- `send(message: DiscordMessage): Promise<DiscordDeliveryReport>`.
|
||||
- `sendText(content: string): Promise<DiscordDeliveryReport>`.
|
||||
- `sendEmbed(embed: DiscordEmbed): Promise<DiscordDeliveryReport>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`buildReport` derives the `timestamp` from the injected `Clock` rather than
|
||||
calling `new Date()` so tests can pin the value deterministically. The
|
||||
`errorCode` and `errorMessage` fields on the report are only populated when
|
||||
the caller explicitly constructs a failure report — in the happy path the
|
||||
report carries `status: "delivered"` and no error fields. The webhook URL is
|
||||
appended with `?wait=true` unconditionally because Discord returns 204 (no
|
||||
body) when `wait` is absent, which would leave the delivery report without an
|
||||
ID.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Discord Client Error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the namespaced, frozen error-code registry and the typed
|
||||
`DiscordClientError` class used by every runtime failure path in
|
||||
`@suaveplan/discord-client`. Co-locating the codes with the error class
|
||||
guarantees that adding a new code is a single-file change and that the
|
||||
TypeScript union stays in sync with the runtime registry the conformance
|
||||
audit greps for.
|
||||
|
||||
## Features
|
||||
|
||||
- `DISCORD_CODES` — a `defineCodes("DISCORD", [...])` frozen record. Each entry
|
||||
has the form `DISCORD.<REASON>` so it is globally unique across the
|
||||
shared code space.
|
||||
- `DiscordClientCode` — type alias for the union of every value in
|
||||
`DISCORD_CODES`.
|
||||
- `DiscordClientError` — extends `SuaveplanError<DiscordClientCode>` so
|
||||
callers can do `instanceof DiscordClientError` to narrow on the code union
|
||||
AND/OR pattern-match on `.code` for stable programmatic handling.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DISCORD_CODES,
|
||||
DiscordClientError,
|
||||
} from "@suaveplan/discord-client/errors";
|
||||
|
||||
throw new DiscordClientError(
|
||||
DISCORD_CODES.AUTH_ERROR,
|
||||
"discord.errors.authError"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { DiscordClientError, DISCORD_CODES } from "@suaveplan/discord-client";
|
||||
|
||||
try {
|
||||
await client.sendMessage("1", { content: "hi" });
|
||||
} catch (err) {
|
||||
if (err instanceof DiscordClientError) {
|
||||
switch (err.code) {
|
||||
case DISCORD_CODES.RATE_LIMITED:
|
||||
// back off using `err.context?.retryAfterMs`
|
||||
break;
|
||||
case DISCORD_CODES.FORBIDDEN:
|
||||
case DISCORD_CODES.AUTH_ERROR:
|
||||
// surface to ops, do not retry
|
||||
break;
|
||||
default:
|
||||
// generic retry / report
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DISCORD_CODES`: frozen record. Keys: `API_ERROR`, `AUTH_ERROR`,
|
||||
`RATE_LIMITED`, `CHANNEL_NOT_FOUND`, `GUILD_NOT_FOUND`, `CONFIG_MISSING`,
|
||||
`INVALID_CONFIG`, `INVALID_MESSAGE`, `WEBHOOK_FAILED`, `TIMEOUT`,
|
||||
`FORBIDDEN`. Values are the `DISCORD.<REASON>` strings.
|
||||
- `DiscordClientCode`: `typeof DISCORD_CODES[keyof typeof DISCORD_CODES]`.
|
||||
- `DiscordClientError(code, messageKey, options?)`: typed error subclass.
|
||||
`options` is the `SuaveplanErrorOptions` bag — pass `cause`, `context`,
|
||||
`traceId`, or `clock` through.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every code in this registry is thrown by at least one production code path
|
||||
inside `src/utils/http.ts`, `src/core/bot-client/discord-bot-client.ts`, or
|
||||
`src/core/webhook/discord-webhook.ts`. The conformance audit
|
||||
(`bun scripts/conformance.ts @suaveplan/discord-client`) greps for
|
||||
exactly that mapping; do not add a code here unless you wire a real throw
|
||||
site for it in the same change.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Discord Client Types
|
||||
|
||||
## Purpose
|
||||
|
||||
Authoritative public type surface for `@suaveplan/discord-client`. Defines every
|
||||
data shape the bot client and webhook client emit or accept, plus the runtime
|
||||
Zod schemas used to validate configuration and message payloads at the public
|
||||
boundary. Centralising the types here guarantees consumers can import them
|
||||
without pulling in any runtime code, and ensures Zod schemas stay in lockstep
|
||||
with the TypeScript surface they validate.
|
||||
|
||||
## Features
|
||||
|
||||
- `DiscordBotConfig` / `DiscordWebhookConfig` with injectable `clock` and
|
||||
`fetch` for deterministic testing.
|
||||
- `DiscordMessage` / `DiscordEmbed` / `DiscordEmbedField` mirroring the Discord
|
||||
v10 API payload contracts (with field-length caps enforced by Zod).
|
||||
- `DiscordResponse`, `DiscordChannel`, `DiscordDeliveryReport`,
|
||||
`DiscordDeliveryStatus` covering every value returned to callers.
|
||||
- `FetchLike` and `DiscordFetchResponse` describe the minimal subset of the
|
||||
fetch contract the client depends on — tests inject a deterministic stub.
|
||||
- Zod boundary schemas (`DiscordBotConfigSchema`, `DiscordWebhookConfigSchema`,
|
||||
`DiscordMessageSchema`) validate config and message payloads.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DiscordMessageSchema, type DiscordMessage } from "@suaveplan/discord-client/types";
|
||||
|
||||
const msg: DiscordMessage = { content: "hello" };
|
||||
const parsed = DiscordMessageSchema.parse(msg);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DiscordBotConfigSchema,
|
||||
type DiscordBotConfig,
|
||||
type FetchLike,
|
||||
} from "@suaveplan/discord-client/types";
|
||||
|
||||
const stubFetch: FetchLike = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
const config: DiscordBotConfig = {
|
||||
token: "abc",
|
||||
fetch: stubFetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
};
|
||||
DiscordBotConfigSchema.parse(config);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DiscordBotConfig`: token, baseUrl, timeoutMs, plus injectable `clock`/`fetch`.
|
||||
- `DiscordWebhookConfig`: webhookUrl (must contain `discord.com/api/webhooks/`),
|
||||
username, avatarUrl, timeoutMs, plus injectables.
|
||||
- `DiscordMessage`: content (≤2000), embeds (≤10), username, avatarUrl, tts,
|
||||
threadId.
|
||||
- `DiscordEmbed`: full Discord embed object with caps on each field.
|
||||
- `DiscordResponse`: id, channelId, content, timestamp, embeds.
|
||||
- `DiscordChannel`: id, name, type, guildId, topic.
|
||||
- `DiscordDeliveryReport`: messageId, channelId, status, timestamp, errorCode,
|
||||
errorMessage.
|
||||
- `DiscordMessageSchema` / `DiscordBotConfigSchema` /
|
||||
`DiscordWebhookConfigSchema`: Zod parsers used at every public entrypoint.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`FetchLike` purposefully omits headers initialisation, body streaming, and
|
||||
other parts of the WHATWG Response API the client never reads. This keeps the
|
||||
test stub small and avoids tying the package to a specific runtime's fetch
|
||||
quirks. `DiscordBotConfigSchema` accepts the injectables as `z.unknown()` —
|
||||
their structural shape is enforced by the static `FetchLike` / `Clock` types,
|
||||
not at runtime, because schemas cannot reliably introspect function signatures
|
||||
across Bun and Node without false negatives.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Discord HTTP Helper
|
||||
|
||||
## Purpose
|
||||
|
||||
Single funnel through which every Discord API and webhook request flows. It
|
||||
maps Discord response codes onto the `DiscordClientError` taxonomy, attaches
|
||||
OpenTelemetry counters and histograms, enforces an `AbortController`-driven
|
||||
timeout, and treats abort errors as `DISCORD.TIMEOUT`. Centralising this logic
|
||||
guarantees identical retry hints, error codes, and observability across the
|
||||
bot client and webhook client — the two entrypoints differ only in their URL
|
||||
construction and choice of `kind`.
|
||||
|
||||
## Features
|
||||
|
||||
- Maps `401 / 403 / 404 / 429 / 5xx / other-non-ok` Discord responses onto
|
||||
stable `DISCORD.*` codes, with per-kind dispatch for the webhook path.
|
||||
- Differentiates guild vs channel 404s via `isGuildRequest`.
|
||||
- Pulls `Retry-After` data via `parseRetryAfterMs` and surfaces it on the
|
||||
thrown error's `context.retryAfterMs` so callers can honour Discord's
|
||||
back-off recommendation without re-parsing headers.
|
||||
- Lazily creates `discord_client.request.total` counter and
|
||||
`discord_client.request.duration_ms` histogram against the global OTel
|
||||
meter provider. `resetDiscordHttpMetrics()` invalidates the cache so tests
|
||||
can swap in an in-memory `MeterProvider`.
|
||||
- Treats `AbortError` (raised by the timeout) as `DISCORD.TIMEOUT`; every
|
||||
other fetch rejection becomes `DISCORD.API_ERROR` or `DISCORD.WEBHOOK_FAILED`
|
||||
per the request kind.
|
||||
- Returns `undefined` for HTTP 204 (Discord's success-with-no-body status,
|
||||
used by `DELETE` and `PUT` reactions).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { executeDiscordRequest } from "@suaveplan/discord-client";
|
||||
|
||||
const channel = await executeDiscordRequest<DiscordChannel>({
|
||||
fetch,
|
||||
clock: Date.now,
|
||||
method: "GET",
|
||||
url: "https://discord.com/api/v10/channels/1",
|
||||
headers: { Authorization: "Bot abc" },
|
||||
timeoutMs: 10_000,
|
||||
kind: "bot",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
executeDiscordRequest,
|
||||
resetDiscordHttpMetrics,
|
||||
} from "@suaveplan/discord-client";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
const mp = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(mp);
|
||||
resetDiscordHttpMetrics();
|
||||
|
||||
await executeDiscordRequest({
|
||||
fetch: stub,
|
||||
clock: () => 1700000000000,
|
||||
method: "POST",
|
||||
url: "https://discord.com/api/v10/channels/1/messages",
|
||||
headers: { Authorization: "Bot abc", "Content-Type": "application/json" },
|
||||
body: { content: "hi" },
|
||||
timeoutMs: 10_000,
|
||||
kind: "bot",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `executeDiscordRequest<T>(opts: DiscordRequestOptions): Promise<T>` — main
|
||||
entrypoint. Throws `DiscordClientError`.
|
||||
- `resetDiscordHttpMetrics()` — clears the cached meter / counter / histogram
|
||||
so the next call rebinds to the current global meter provider. Used by tests.
|
||||
- `DiscordRequestKind = "bot" | "webhook"` — drives the error code on
|
||||
`WEBHOOK_FAILED` vs `API_ERROR`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function intentionally records a metric on every outcome (`ok`,
|
||||
`auth_error`, `forbidden`, `not_found`, `rate_limited`, `timeout`,
|
||||
`network_error`, `api_error`). Histograms are emitted with the same label
|
||||
set as the counter so dashboards can divide one by the other directly.
|
||||
The 401/403/429/404/non-ok ordering matches Discord's documented
|
||||
precedence; non-ok responses fall through to the final clause, which
|
||||
distinguishes `DISCORD.API_ERROR` (bot) from `DISCORD.WEBHOOK_FAILED`
|
||||
(webhook) so the error registry's retry/severity hints stay accurate.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Rate-Limit Header Parser
|
||||
|
||||
## Purpose
|
||||
|
||||
Parses Discord's three rate-limit response headers into a single millisecond
|
||||
back-off duration that callers can use to schedule a retry. Discord is
|
||||
inconsistent about which header it emits in which response (gateway-level
|
||||
429s use `Retry-After`, bucket-level 429s use `X-RateLimit-Reset-After`, and
|
||||
shard-level overflows can fall back to `X-RateLimit-Reset` as a wall-clock
|
||||
target), so a single canonical parser ensures every code path reacts the same
|
||||
way and that tests can pin every flavour with deterministic fixtures.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads `Retry-After`, `X-RateLimit-Reset-After`, and `X-RateLimit-Reset`.
|
||||
- Honours both seconds-as-float and HTTP-date encodings of `Retry-After`.
|
||||
- Treats `X-RateLimit-Reset` values < 10^12 as Unix epoch seconds and ≥ 10^12
|
||||
as Unix epoch milliseconds (Discord switched encodings mid-2023).
|
||||
- Accepts an injectable `Clock` so tests stay deterministic.
|
||||
- Returns `undefined` when no header is present or every header is unparsable.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseRetryAfterMs } from "@suaveplan/discord-client";
|
||||
|
||||
const retryAfterMs = parseRetryAfterMs(response);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseRetryAfterMs } from "@suaveplan/discord-client";
|
||||
|
||||
const fixedClock = () => 1_700_000_000_000;
|
||||
const fakeResponse = {
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
headers: { get: (name: string) => (name === "x-ratelimit-reset" ? "1700000060" : null) },
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
};
|
||||
|
||||
// Returns 60_000 — the difference between epoch-second 1700000060 and the
|
||||
// injected clock at 1_700_000_000_000 ms.
|
||||
parseRetryAfterMs(fakeResponse, fixedClock);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`parseRetryAfterMs(response: DiscordFetchResponse, clock?: Clock): number | undefined`
|
||||
|
||||
- `response`: any object exposing `.headers.get(name)`.
|
||||
- `clock`: optional `Clock` injectable; defaults to `Date.now`.
|
||||
- Returns the parsed millisecond back-off, or `undefined` when no header was
|
||||
parsable.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Math.ceil` is applied to the floating-point seconds to avoid under-waiting
|
||||
when Discord emits fractional values like `0.250`. Negative deltas from
|
||||
`X-RateLimit-Reset` clamp to zero rather than returning a stale duration —
|
||||
the caller should retry immediately rather than reading a negative value. The
|
||||
parser does NOT throw on malformed header values; it returns `undefined`,
|
||||
which is the canonical "no advice from Discord, fall back to your local
|
||||
retry policy" signal.
|
||||
@@ -0,0 +1,120 @@
|
||||
# @suaveplan/imessage-adapter - macOS iMessage SQLite chat.db watcher. Polls Apple's Messages database, decodes Apple-epoch / nanosecond timestamps, resolves handle + chat + attachment joins, and emits ParsedIMessage + InboundMessage envelopes. Structured IMESSAGE.* error registry, Zod-validated config boundary, AbortSignal cancellation, injectable SqliteReader seam (production: bun:sqlite, tests: real bun:sqlite chat.db fixture — no mocks), EventBus bridge atop @suaveplan/events, four OTel instruments.
|
||||
|
||||
macOS iMessage SQLite chat.db watcher and `MessageSourceAdapter`
|
||||
implementation. Polls Apple's `~/Library/Messages/chat.db`, decodes
|
||||
Apple-epoch / nanosecond timestamps, resolves handle + chat +
|
||||
attachment joins, and emits `ParsedIMessage` and `InboundMessage`
|
||||
envelopes. Server-tier (Bun); the `bun:sqlite` driver is loaded
|
||||
lazily inside the production reader so the package type-checks +
|
||||
lints on every host.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapter](./src/adapter/imessage-adapter.md) — `MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that projects each `ParsedIMessage` into the channel-agn…
|
||||
- [imessage-adapter](./src/adapter/imessage-adapter.md) — `MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that projects each `ParsedIMessage` into the channel-agn…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, fully-qualified error-code registry for `@suaveplan/imessage-adapter`. Every error class in this mo…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, fully-qualified error-code registry for `@suaveplan/imessage-adapter`. Every error class in this mo…
|
||||
- [fixtures](./src/fixtures/chat-db-fixture.md) — Builds a real `chat.db`-shaped SQLite database on a temp path using the structural `SqliteReader` seam. This is the fou…
|
||||
- [chat-db-fixture](./src/fixtures/chat-db-fixture.md) — Builds a real `chat.db`-shaped SQLite database on a temp path using the structural `SqliteReader` seam. This is the fou…
|
||||
- [otel](./src/otel/metrics.md) — Defines the four OpenTelemetry instruments emitted by `@suaveplan/imessage-adapter` and the helpers that record onto th…
|
||||
- [metrics](./src/otel/metrics.md) — Defines the four OpenTelemetry instruments emitted by `@suaveplan/imessage-adapter` and the helpers that record onto th…
|
||||
- [parser](./src/parser/imessage-parser.md) — Pure-function parser that turns Apple's chat.db row shapes into the `ParsedIMessage` projection consumed by every downs…
|
||||
- [imessage-parser](./src/parser/imessage-parser.md) — Pure-function parser that turns Apple's chat.db row shapes into the `ParsedIMessage` projection consumed by every downs…
|
||||
- [schemas](./src/schemas/schemas.md) — Provides Zod boundary schemas for `IMessageWatcherConfig` and `IMessageAdapterConfig`, plus the `parseWatcherConfig` /…
|
||||
- [schemas](./src/schemas/schemas.md) — Provides Zod boundary schemas for `IMessageWatcherConfig` and `IMessageAdapterConfig`, plus the `parseWatcherConfig` /…
|
||||
- [sqlite](./src/sqlite/bun-sqlite-reader.md) — Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite` driver. The reader is the single seam through whi…
|
||||
- [bun-sqlite-reader](./src/sqlite/bun-sqlite-reader.md) — Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite` driver. The reader is the single seam through whi…
|
||||
- [watcher](./src/watcher/imessage-watcher.md) — Polls Apple's `~/Library/Messages/chat.db` SQLite database on a configurable interval, projects new rows into `ParsedIM…
|
||||
- [imessage-watcher](./src/watcher/imessage-watcher.md) — Polls Apple's `~/Library/Messages/chat.db` SQLite database on a configurable interval, projects new rows into `ParsedIM…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Highlights
|
||||
|
||||
- **`IMessageWatcher`** — Zod-validated config boundary, AbortSignal
|
||||
propagation, injectable `SqliteReader`, typed `EventBus` surface
|
||||
alongside the upstream `on/off` listener API, monotonic
|
||||
`lastRowId` cursor (settable + readable for resume), filters for
|
||||
`includeFromMe` and `chatFilter`.
|
||||
- **`IMessageAdapter`** — wraps the watcher and projects each
|
||||
`ParsedIMessage` into a channel-agnostic `InboundMessage` with
|
||||
normalised phone numbers and chat metadata.
|
||||
- **`IMessageParser`** — pure-function parser for chat.db row shapes;
|
||||
Apple-epoch decoding, attachment fallbacks, phone normalisation.
|
||||
- **`createChatDbFixture`** — real chat.db-shaped SQLite database
|
||||
builder used by tests so every test path runs against the same SQL
|
||||
the production watcher executes — no mocks.
|
||||
- **`bunSqliteReader` / `createBunSqliteReader`** — production
|
||||
`SqliteReader` backed by Bun's `bun:sqlite`. Errors are wrapped in
|
||||
`IMessageDbOpenFailedError`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/imessage-adapter
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { IMessageAdapter } from "@suaveplan/imessage-adapter";
|
||||
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({
|
||||
dbPath: "~/Library/Messages/chat.db",
|
||||
pollIntervalMs: 5000,
|
||||
includeFromMe: false,
|
||||
});
|
||||
|
||||
for (const inbound of await adapter.poll()) {
|
||||
console.log(inbound.sourceAddress, inbound.content);
|
||||
adapter.markProcessed(inbound.id);
|
||||
}
|
||||
|
||||
await adapter.close();
|
||||
```
|
||||
|
||||
## Error registry (`IMESSAGE.*`)
|
||||
|
||||
| Code | Class | When |
|
||||
|---|---|---|
|
||||
| `IMESSAGE.DB_OPEN_FAILED` | `IMessageDbOpenFailedError` | `bun:sqlite` cannot open the chat.db path |
|
||||
| `IMESSAGE.DB_QUERY_FAILED` | `IMessageDbQueryFailedError` | A poll-time SQL query throws |
|
||||
| `IMESSAGE.WATCHER_NOT_RUNNING` | `IMessageWatcherNotRunningError` | `stop()` called while not running |
|
||||
| `IMESSAGE.WATCHER_ALREADY_RUNNING` | `IMessageWatcherAlreadyRunningError` | `start()` called twice |
|
||||
| `IMESSAGE.ADAPTER_NOT_INITIALIZED` | `IMessageAdapterNotInitializedError` | `poll()` before `initialize()` |
|
||||
| `IMESSAGE.INVALID_CONFIG` | `IMessageInvalidConfigError` | Zod boundary rejected config; `context.issues` carries the path + reason |
|
||||
| `IMESSAGE.POLL_ABORTED` | `IMessagePollAbortedError` | `AbortSignal` fired |
|
||||
| `IMESSAGE.POLL_FAILED` | `IMessagePollFailedError` | Reserved for explicit `poll()` wrapping |
|
||||
| `IMESSAGE.ATTACHMENT_PARSE_FAILED` | `IMessageAttachmentParseError` | Attachment row could not be parsed |
|
||||
| `IMESSAGE.APPLE_DATE_PARSE_FAILED` | `IMessageAppleDateParseError` | Timestamp is NaN / non-finite |
|
||||
|
||||
## OpenTelemetry instruments
|
||||
|
||||
| Instrument | Kind | Attributes |
|
||||
|---|---|---|
|
||||
| `imessage.messages_emitted_total` | Counter | `chat_type`, `from_me` |
|
||||
| `imessage.attachments_emitted_total` | Counter | `mime_family` |
|
||||
| `imessage.poll_errors_total` | Counter | `code` |
|
||||
| `imessage.poll_duration_ms` | Histogram | `outcome` (`ok`, `error`, `aborted`) |
|
||||
|
||||
Call `refreshIMessageMeters()` after `metrics.setGlobalMeterProvider`
|
||||
to bind subsequent records to the chosen provider.
|
||||
|
||||
## Subpath exports
|
||||
|
||||
- `@suaveplan/imessage-adapter/error-codes`
|
||||
- `@suaveplan/imessage-adapter/schemas`
|
||||
- `@suaveplan/imessage-adapter/parser`
|
||||
- `@suaveplan/imessage-adapter/watcher`
|
||||
- `@suaveplan/imessage-adapter/adapter`
|
||||
- `@suaveplan/imessage-adapter/sqlite/bun-sqlite-reader`
|
||||
- `@suaveplan/imessage-adapter/otel`
|
||||
- `@suaveplan/imessage-adapter/fixtures`
|
||||
|
||||
## Tier
|
||||
|
||||
Server-tier (Bun on macOS). The `bun:sqlite` import is reached via
|
||||
`createRequire(import.meta.url)` so the static module graph never
|
||||
attempts to resolve the specifier on a non-Bun runner.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# `IMessageAdapter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that
|
||||
projects each `ParsedIMessage` into the channel-agnostic
|
||||
`InboundMessage` envelope consumed by downstream inbound-message
|
||||
routers (e.g. a future `@suaveplan/api-plugin-messaging`). The
|
||||
adapter is the polymorphic surface — callers depend on
|
||||
`MessageSourceAdapter`, swap in iMessage / SMS / Slack adapters
|
||||
without coupling to chat.db schema details.
|
||||
|
||||
## Features
|
||||
|
||||
- `type === "imessage"` constant so multi-adapter routers can fan
|
||||
out by source.
|
||||
- `initialize(config)` Zod-validates the input via
|
||||
`parseAdapterConfig` and constructs the underlying watcher with
|
||||
the validated config (including any injected `sqliteReader`).
|
||||
- `poll()` calls the watcher's `poll()` and projects each parsed
|
||||
message into an `InboundMessage`. Attachments populate the
|
||||
`attachments` array; the `contentType` is `"attachment"` when
|
||||
≥1 attachment is present and `"text"` otherwise.
|
||||
- `sourceAddress` is the parser-normalised phone number (or the
|
||||
unchanged email handle), so downstream consumers see a
|
||||
canonicalised identity.
|
||||
- `metadata` block carries chat-level context (`chatIdentifier`,
|
||||
`chatDisplayName`, `isGroupChat`, `senderService`, `isRead`) that
|
||||
routers need for routing decisions.
|
||||
- `getLastProcessedId() / markProcessed(id)` expose the consumer-
|
||||
side cursor; `close()` stops the running watcher and clears the
|
||||
reference.
|
||||
- `poll()` called before `initialize()` throws
|
||||
`IMessageAdapterNotInitializedError`
|
||||
(`IMESSAGE.ADAPTER_NOT_INITIALIZED`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { IMessageAdapter } from "@suaveplan/imessage-adapter";
|
||||
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({ pollIntervalMs: 5000 });
|
||||
const inbound = await adapter.poll();
|
||||
await adapter.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
IMessageAdapter,
|
||||
createBunSqliteReader,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({
|
||||
dbPath: "/tmp/chat.db",
|
||||
sqliteReader: createBunSqliteReader(),
|
||||
includeFromMe: true,
|
||||
});
|
||||
|
||||
for (const msg of await adapter.poll()) {
|
||||
if (msg.metadata?.["isGroupChat"]) {
|
||||
adapter.markProcessed(msg.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type: "imessage"` — read-only adapter discriminator.
|
||||
- `initialize(config: Record<string, unknown>): Promise<void>`.
|
||||
- `poll(): Promise<readonly InboundMessage[]>`.
|
||||
- `getLastProcessedId(): string | null`.
|
||||
- `markProcessed(id: string): void`.
|
||||
- `close(): Promise<void>` — idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The adapter owns the watcher's lifecycle: `close()` stops the loop if
|
||||
`isRunning()`, then drops the reference. The projection is pure — no
|
||||
side effects, no extra DB calls — so projecting N messages costs O(N)
|
||||
and never re-opens the database. The `attachments` array is always
|
||||
present (possibly empty) so consumers can iterate without a
|
||||
null-check.
|
||||
@@ -0,0 +1,87 @@
|
||||
# `IMESSAGE_*` error registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable, fully-qualified error-code registry for
|
||||
`@suaveplan/imessage-adapter`. Every error class in this module
|
||||
inherits from `@suaveplan/error`'s `SuaveplanError` base and carries
|
||||
one of the ten `IMESSAGE.*` codes returned by `defineCodes("IMESSAGE",
|
||||
[...])`. Consumers downstream — loggers, problem+json renderers, retry
|
||||
wrappers, OTel attribute writers — map on the code, never on the
|
||||
message or class identity, so the codes are part of the public API.
|
||||
|
||||
## Features
|
||||
|
||||
- Ten production codes, each thrown from at least one real call site:
|
||||
`DB_OPEN_FAILED`, `DB_QUERY_FAILED`, `WATCHER_NOT_RUNNING`,
|
||||
`WATCHER_ALREADY_RUNNING`, `ADAPTER_NOT_INITIALIZED`,
|
||||
`INVALID_CONFIG`, `POLL_ABORTED`, `POLL_FAILED`,
|
||||
`ATTACHMENT_PARSE_FAILED`, `APPLE_DATE_PARSE_FAILED`.
|
||||
- One typed error class per code, all extending the abstract
|
||||
`IMessageError` so consumers can pattern-match via `instanceof
|
||||
IMessageError` for a single catch site.
|
||||
- `IMessageInvalidConfigError` accepts a `issues` payload that is
|
||||
merged into `context` so callers can recover the Zod issue array
|
||||
from problem+json bodies.
|
||||
- Frozen `IMESSAGE_CODES` record keeps the literal-typed union
|
||||
reachable through the public surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
IMessageAdapterNotInitializedError,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
throw new IMessageAdapterNotInitializedError();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
IMessageError,
|
||||
IMessageInvalidConfigError,
|
||||
IMESSAGE_CODES,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
try {
|
||||
parseWatcherConfig(input);
|
||||
} catch (err) {
|
||||
if (err instanceof IMessageInvalidConfigError) {
|
||||
console.error(err.code, err.context["issues"]);
|
||||
} else if (err instanceof IMessageError) {
|
||||
switch (err.code) {
|
||||
case IMESSAGE_CODES.WATCHER_ALREADY_RUNNING:
|
||||
case IMESSAGE_CODES.WATCHER_NOT_RUNNING:
|
||||
// recoverable
|
||||
break;
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const IMESSAGE_CODES`: frozen record `{ DB_OPEN_FAILED:
|
||||
"IMESSAGE.DB_OPEN_FAILED", ... }`.
|
||||
- `type IMessageErrorCode`: union of every fully-qualified code.
|
||||
- `abstract class IMessageError extends SuaveplanError<IMessageErrorCode>`.
|
||||
- One concrete class per code, each accepting `{ cause?, context?,
|
||||
traceId?, message? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module side-effect-imports nothing; the registry is purely
|
||||
declarative. The package barrel (`src/index.ts`) side-effect-imports
|
||||
`./error-codes/index.js` so subpath consumers also observe the
|
||||
registration. Messages default to i18n keys (`imessage.errors.*`) so
|
||||
callers can supply their own resolver if they wish.
|
||||
@@ -0,0 +1,115 @@
|
||||
# `createChatDbFixture`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds a real `chat.db`-shaped SQLite database on a temp path using
|
||||
the structural `SqliteReader` seam. This is the foundation of the
|
||||
test strategy: every watcher / adapter test runs against a real
|
||||
`bun:sqlite` database whose schema mirrors Apple's Messages app,
|
||||
populated via the helpers returned by this factory. There are no
|
||||
mocks anywhere in the call chain — the SQL the watcher executes in
|
||||
production runs unchanged against the fixture.
|
||||
|
||||
## Features
|
||||
|
||||
- DDL covers the six tables the watcher reads:
|
||||
`message`, `handle`, `chat`, `chat_message_join`, `attachment`,
|
||||
`message_attachment_join` — with the exact column names + types
|
||||
Apple's chat.db uses.
|
||||
- Helpers return the inserted ROWID via a `SELECT ROWID ORDER BY
|
||||
ROWID DESC LIMIT 1` so tests can wire foreign-key joins
|
||||
deterministically without depending on `last_insert_rowid()`
|
||||
semantics across reader implementations.
|
||||
- `insertMessage(row, chatId)` writes both the message and its
|
||||
`chat_message_join` row so the watcher's `JOIN cmj` query finds
|
||||
the chat without extra wiring.
|
||||
- `insertAttachment(row, messageId)` writes both the attachment and
|
||||
its `message_attachment_join` row.
|
||||
- `close()` closes the handle so the temp file can be deleted by the
|
||||
caller's `afterEach`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createBunSqliteReader,
|
||||
createChatDbFixture,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
const reader = createBunSqliteReader();
|
||||
const fixture = createChatDbFixture(reader, join(tmpdir(), "chat.db"));
|
||||
const chatId = fixture.insertChat({
|
||||
guid: "iMessage;+;+15551234567",
|
||||
chat_identifier: "+15551234567",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
});
|
||||
fixture.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const handleId = fixture.insertHandle({
|
||||
id: "+15551234567",
|
||||
country: "us",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
});
|
||||
const messageId = fixture.insertMessage({
|
||||
guid: "m-1",
|
||||
text: "Hello",
|
||||
handle_id: handleId,
|
||||
service: "iMessage",
|
||||
date: 700_000_000,
|
||||
date_read: 0,
|
||||
date_delivered: 0,
|
||||
is_from_me: 0,
|
||||
is_read: 1,
|
||||
is_delivered: 1,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 1,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
}, chatId);
|
||||
fixture.insertAttachment({
|
||||
guid: "a-1",
|
||||
filename: "/tmp/photo.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 1024,
|
||||
transfer_name: "photo.jpg",
|
||||
}, messageId);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createChatDbFixture(reader, path): ChatDbFixture` — DDLs the schema
|
||||
and returns the helpers.
|
||||
- `ChatDbFixture.handle: SqliteHandle` — exposed for advanced custom
|
||||
inserts.
|
||||
- `insertHandle(row): number` — returns the new ROWID.
|
||||
- `insertChat(row): number` — returns the new ROWID.
|
||||
- `insertMessage(row, chatId?): number` — wires the
|
||||
`chat_message_join` row when `chatId` is supplied.
|
||||
- `insertAttachment(row, messageId): number` — wires the
|
||||
`message_attachment_join` row.
|
||||
- `close(): void` — closes the handle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory is server-tier-only because it requires a real SQLite
|
||||
implementation, but it accepts any `SqliteReader` so a future
|
||||
in-process SQLite-WASM reader could also drive the fixture. The
|
||||
schema uses `AUTOINCREMENT` so insertion order maps to monotonically
|
||||
increasing ROWIDs, which the watcher's `ROWID > lastRowId` cursor
|
||||
relies on for incremental polling.
|
||||
@@ -0,0 +1,68 @@
|
||||
# OpenTelemetry instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the four OpenTelemetry instruments emitted by
|
||||
`@suaveplan/imessage-adapter` and the helpers that record onto them.
|
||||
Instruments are resolved against the package-named `Meter`
|
||||
(`@suaveplan/imessage-adapter` / `0.1.0`) so consumers can filter to
|
||||
just this package in their dashboards. `refreshIMessageMeters()`
|
||||
re-binds every instrument against the current global `MeterProvider`,
|
||||
which tests use to redirect emissions to an in-memory exporter.
|
||||
|
||||
## Features
|
||||
|
||||
- Counter `imessage.messages_emitted_total` with `{chat_type,
|
||||
from_me}` attributes — incremented once per parsed message yielded
|
||||
by `IMessageWatcher.poll()`.
|
||||
- Counter `imessage.attachments_emitted_total` with `{mime_family}` —
|
||||
incremented once per parsed attachment.
|
||||
- Counter `imessage.poll_errors_total` with `{code}` — incremented on
|
||||
every poll-loop error, attributed by `IMESSAGE.*` code.
|
||||
- Histogram `imessage.poll_duration_ms` with `{outcome}` (`ok` /
|
||||
`error` / `aborted`) — records the wall-clock duration of every
|
||||
completed (or cancelled) poll tick in milliseconds.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordMessageEmitted,
|
||||
recordPollDuration,
|
||||
} from "@suaveplan/imessage-adapter/otel";
|
||||
|
||||
recordMessageEmitted("direct", false);
|
||||
recordPollDuration(42, "ok");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { refreshIMessageMeters } from "@suaveplan/imessage-adapter";
|
||||
|
||||
metrics.setGlobalMeterProvider(meterProvider);
|
||||
refreshIMessageMeters(); // re-resolve so subsequent records land on this provider
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordMessageEmitted(chatType: "direct" | "group", fromMe: boolean): void`
|
||||
- `recordAttachmentEmitted(mimeFamily: string): void`
|
||||
- `recordPollError(code: string): void`
|
||||
- `recordPollDuration(durationMs: number, outcome: "ok" | "error" | "aborted"): void`
|
||||
- `refreshIMessageMeters(): void` — re-bind every instrument against the
|
||||
current global `MeterProvider`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are stored in mutable module-level bindings so
|
||||
`refreshIMessageMeters()` can swap them in-place; this is the
|
||||
canonical pattern used by `slack-client` and the other
|
||||
messaging clients. The `mime_family` attribute is computed by the
|
||||
caller (typically the watcher) as the first segment of the MIME type
|
||||
so cardinality stays bounded.
|
||||
@@ -0,0 +1,73 @@
|
||||
# `IMessageParser`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure-function parser that turns Apple's chat.db row shapes into the
|
||||
`ParsedIMessage` projection consumed by every downstream surface in
|
||||
this package. The parser handles Apple's per-row timestamp mode
|
||||
(seconds vs nanoseconds since 2001-01-01), resolves nullable join
|
||||
rows, and normalises phone-number handles into the canonical
|
||||
`+E.164` form.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseMessage(row, handle, chat, attachments)` — composes the row +
|
||||
optional join rows into a `ParsedIMessage`. Null handles project
|
||||
to `"unknown"`, null chat identifiers to `""`, and the group-chat
|
||||
flag is set when the chat identifier starts with `"chat"`.
|
||||
- `parseAppleDate(n)` — auto-detects seconds vs nanoseconds against
|
||||
the `10^9` threshold, applies the 2001-01-01 Apple epoch offset,
|
||||
and throws `IMessageAppleDateParseError` on `NaN` / non-finite
|
||||
input rather than silently returning a garbage `Date`.
|
||||
- `parseAttachment(row)` — picks `transfer_name` over `filename` over
|
||||
`"unknown"` and defaults the MIME type to
|
||||
`"application/octet-stream"` when chat.db omits it.
|
||||
- `isGroupChat(chatIdentifier)` — exposes the same chat-vs-handle
|
||||
classifier used internally so consumers can reuse it for routing.
|
||||
- `normalizePhoneNumber(handle)` — strips non-digit characters and
|
||||
prefixes 10-digit US numbers with `+1`, 11-digit US numbers with
|
||||
`+`; email handles pass through unchanged.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { IMessageParser } from "@suaveplan/imessage-adapter/parser";
|
||||
|
||||
const parser = new IMessageParser();
|
||||
const sent = parser.parseAppleDate(700_000_000);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { IMessageParser } from "@suaveplan/imessage-adapter";
|
||||
|
||||
const parser = new IMessageParser();
|
||||
const parsed = parser.parseMessage(messageRow, handleRow, chatRow, attachments);
|
||||
if (parsed.isGroupChat) {
|
||||
route(parsed, parser.normalizePhoneNumber(parsed.senderHandle));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseMessage(row, handle, chat, attachments?): ParsedIMessage`
|
||||
- `parseAppleDate(n: number): Date` — throws
|
||||
`IMessageAppleDateParseError` for `NaN` / non-finite.
|
||||
- `parseAttachment(row): ParsedAttachment`
|
||||
- `isGroupChat(chatIdentifier: string): boolean`
|
||||
- `normalizePhoneNumber(handle: string): string`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The parser is intentionally side-effect-free and instance-based (no
|
||||
static methods) so consumers can subclass it to override individual
|
||||
steps — for example, to swap in a locale-aware phone normaliser. The
|
||||
Apple-epoch threshold detection runs per-row because Apple's schema
|
||||
shipped both forms historically; checking the magnitude is robust
|
||||
across the migration window. Errors are routed through the
|
||||
`IMESSAGE.*` registry so consumers can map by code.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Config schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides Zod boundary schemas for `IMessageWatcherConfig` and
|
||||
`IMessageAdapterConfig`, plus the `parseWatcherConfig` /
|
||||
`parseAdapterConfig` helpers that callers use to validate untrusted
|
||||
input before constructing a watcher or adapter. Both schemas are
|
||||
strict — extra keys are rejected — so consumers cannot silently
|
||||
smuggle additional fields past the boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict object validation (`.strict()`) — unknown properties are
|
||||
rejected at the boundary, preventing prototype-pollution-style
|
||||
smuggling.
|
||||
- `pollIntervalMs` constrained to positive integers; `sinceDate`
|
||||
required to be a `Date` instance (no millisecond-since-epoch
|
||||
numbers); `chatFilter` constrained to string arrays.
|
||||
- Custom `SqliteReaderSchema` confirms the optional injected
|
||||
`sqliteReader` exposes a callable `open()` member.
|
||||
- Failures throw `IMessageInvalidConfigError`
|
||||
(`IMESSAGE.INVALID_CONFIG`) carrying a serialisable `issues` array
|
||||
in `context` so problem+json responders can surface the Zod path +
|
||||
reason without re-running the schema.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseWatcherConfig } from "@suaveplan/imessage-adapter/schemas";
|
||||
|
||||
const config = parseWatcherConfig({ pollIntervalMs: 5000 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseWatcherConfig,
|
||||
IMessageInvalidConfigError,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
try {
|
||||
parseWatcherConfig({ pollIntervalMs: -1, oops: true });
|
||||
} catch (err) {
|
||||
if (err instanceof IMessageInvalidConfigError) {
|
||||
return Response.json(
|
||||
{ issues: err.context["issues"] },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `IMessageWatcherConfigSchema: z.ZodObject` — validates watcher config.
|
||||
- `IMessageAdapterConfigSchema` — alias for the watcher schema.
|
||||
- `SqliteReaderSchema: z.ZodType<unknown>` — structural check for
|
||||
`SqliteReader`.
|
||||
- `parseWatcherConfig(input: unknown): IMessageWatcherConfig` — throws
|
||||
`IMessageInvalidConfigError` on failure.
|
||||
- `parseAdapterConfig(input: unknown): IMessageAdapterConfig` — throws
|
||||
`IMessageInvalidConfigError` on failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schemas use `z.custom` for the `SqliteReader` slot because the
|
||||
reader is a structural type — there is no Zod-expressible shape that
|
||||
captures every legal implementation. The check confirms the presence
|
||||
of a function-typed `open` member, which is enough to surface
|
||||
mistakes (passing a stray object or `null`) without inspecting the
|
||||
reader's behaviour. Issue arrays are projected through `toIssues`
|
||||
which strips Zod's internal symbols so the resulting record is safe
|
||||
to JSON-serialise.
|
||||
@@ -0,0 +1,76 @@
|
||||
# `bunSqliteReader`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite`
|
||||
driver. The reader is the single seam through which
|
||||
`IMessageWatcher` reaches Apple's `chat.db` SQLite database; replacing
|
||||
upstream's `require("bun:sqlite") as any` cast with a typed,
|
||||
swappable factory lets tests inject a reader pointing at a real temp
|
||||
chat.db without re-implementing the SQL layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy module resolution via `createRequire(import.meta.url)` — the
|
||||
`bun:sqlite` import is not added to the static graph, so vite and
|
||||
vitest do not attempt to resolve it on non-Bun runners during build
|
||||
/ type-check / lint passes.
|
||||
- Errors thrown from the load step or the `new Database(...)` call
|
||||
are wrapped in `IMessageDbOpenFailedError` carrying the offending
|
||||
path on `context.path`, so the watcher can surface a stable error
|
||||
code to consumers.
|
||||
- Returns a `SqliteHandle` whose `execScript` member maps to
|
||||
`Database.run` (Bun's bulk-SQL entry point) and whose `prepare`
|
||||
member returns a `SqliteStatement` wrapping Bun's per-statement
|
||||
`all` / `get` / `run` methods structurally — production and test
|
||||
code share the exact same call shape.
|
||||
- Read-only by default: `open(path, { readonly: true })` opens the
|
||||
database read-only as required for `~/Library/Messages/chat.db`,
|
||||
while tests open with `readonly: false` to populate the fixture.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { bunSqliteReader } from "@suaveplan/imessage-adapter";
|
||||
|
||||
const handle = bunSqliteReader.open("/path/to/chat.db", { readonly: true });
|
||||
const rows = handle.prepare("SELECT ROWID FROM message LIMIT 1").all();
|
||||
handle.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createBunSqliteReader } from "@suaveplan/imessage-adapter";
|
||||
|
||||
// Inject a custom loader (e.g. for vitest under WSL2 where bun:sqlite is
|
||||
// reached through a different worker bridge).
|
||||
const reader = createBunSqliteReader(() => require("bun:sqlite"));
|
||||
const handle = reader.open(path, { readonly: false });
|
||||
handle.execScript("CREATE TABLE foo (id INTEGER PRIMARY KEY)");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `bunSqliteReader: SqliteReader` — default reader, ready for
|
||||
production wiring.
|
||||
- `createBunSqliteReader(loader?: BunSqliteLoader): SqliteReader` —
|
||||
factory accepting a custom loader; defaults to a
|
||||
`createRequire`-based one that resolves `bun:sqlite` against the
|
||||
module's own URL.
|
||||
- `BunSqliteLoader = () => BunSqliteModule` — the loader contract.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`createRequire(import.meta.url)` is the documented Node + Bun bridge
|
||||
for resolving CommonJS / built-in modules from ESM without statically
|
||||
declaring them. We use it because static `import "bun:sqlite"` would
|
||||
make vite resolve the specifier at build time and fail on non-Bun
|
||||
hosts (CI, lint). The `BunDatabase` interface is intentionally
|
||||
minimal — it only declares the methods the watcher actually calls,
|
||||
so changes to Bun's broader Database surface cannot bleed into the
|
||||
package.
|
||||
@@ -0,0 +1,103 @@
|
||||
# `IMessageWatcher`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Polls Apple's `~/Library/Messages/chat.db` SQLite database on a
|
||||
configurable interval, projects new rows into `ParsedIMessage`, and
|
||||
emits them on both a legacy `on/off` listener API and the typed
|
||||
`EventBus<IMessageWatcherEvents>` exposed as the public `bus` field.
|
||||
The watcher is the canonical ingress for any surface that
|
||||
wants to consume inbound macOS Messages traffic — adapter, router,
|
||||
or direct subscriber.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated config boundary (`pollIntervalMs > 0`,
|
||||
`sinceDate: Date`, etc.) via `parseWatcherConfig` — invalid input
|
||||
throws `IMessageInvalidConfigError` at construction time.
|
||||
- Injectable `SqliteReader` (defaults to the production
|
||||
`bunSqliteReader`) so tests use a real `bun:sqlite` chat.db fixture
|
||||
without mocks.
|
||||
- `start({ signal })` schedules polls every `pollIntervalMs`; the
|
||||
signal stops the loop and emits a `stopped` event.
|
||||
- `poll({ signal })` runs one tick on demand; pre-aborted signals
|
||||
throw `IMessagePollAbortedError`. Errors from explicit polls bubble
|
||||
as `IMessageDbQueryFailedError`; errors from the internal scheduler
|
||||
emit `error` on the bus and continue ticking (mirrors upstream
|
||||
behaviour so a transient SQLite error doesn't tear down the loop).
|
||||
- Monotonic `lastRowId` cursor — only emits rows with `ROWID >
|
||||
lastRowId`; advances even for filtered rows so the cursor doesn't
|
||||
re-process them next tick. `getLastRowId()` / `setLastRowId(n)`
|
||||
expose the cursor for consumers that want to persist resume state.
|
||||
- Filters: `includeFromMe: false` drops outgoing messages; non-empty
|
||||
`chatFilter` restricts to specific `chat_identifier`s.
|
||||
- Four OTel instruments — `imessage.messages_emitted_total`,
|
||||
`imessage.attachments_emitted_total`,
|
||||
`imessage.poll_errors_total`, `imessage.poll_duration_ms` — wired
|
||||
into every emit / error / tick.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { IMessageWatcher } from "@suaveplan/imessage-adapter";
|
||||
|
||||
const watcher = new IMessageWatcher({ pollIntervalMs: 5000 });
|
||||
const off = watcher.on("message", (msg) => console.log(msg.text));
|
||||
await watcher.start();
|
||||
// ...
|
||||
off();
|
||||
await watcher.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
IMessageWatcher,
|
||||
createChatDbFixture,
|
||||
createBunSqliteReader,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
const reader = createBunSqliteReader();
|
||||
const fixture = createChatDbFixture(reader, "/tmp/chat.db");
|
||||
// ... populate fixture
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/tmp/chat.db",
|
||||
sqliteReader: reader,
|
||||
includeFromMe: true,
|
||||
chatFilter: ["+15551234567"],
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const parsed = await watcher.poll({ signal: controller.signal });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new IMessageWatcher(config?: IMessageWatcherConfig)` — Zod-validates
|
||||
at construction.
|
||||
- `bus: EventBus<IMessageWatcherEvents>` — typed event surface (`message`,
|
||||
`error`, `started`, `stopped`).
|
||||
- `on(event, listener) => () => void` — legacy alias for `bus.on`.
|
||||
- `off(event, listener): void` — legacy alias for `bus.off`.
|
||||
- `start(opts?: { signal?: AbortSignal }): Promise<void>` — throws
|
||||
`IMessageWatcherAlreadyRunningError` on double-start.
|
||||
- `stop(): Promise<void>` — throws `IMessageWatcherNotRunningError`
|
||||
when called while not running.
|
||||
- `poll(opts?: { signal?: AbortSignal }): Promise<readonly ParsedIMessage[]>`.
|
||||
- `getLastRowId(): number` / `setLastRowId(rowId: number): void`.
|
||||
- `isRunning(): boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The legacy `on/off` API delegates straight to the typed `EventBus`,
|
||||
which keeps the two surfaces in lockstep — there is no separate
|
||||
listener registry. The cursor advances inside the row loop *before*
|
||||
filters apply, so filtered rows are not re-fetched next tick. The
|
||||
scheduler-vs-explicit-poll error policy is asymmetric on purpose: a
|
||||
caller invoking `poll({ signal })` directly wants the error; the
|
||||
internal scheduler keeps ticking so a transient DB lock doesn't tear
|
||||
down the consumer.
|
||||
@@ -0,0 +1,117 @@
|
||||
# @suaveplan/notification-channel - Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentation, and Zod-validated boundaries. Universal: runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentation, and Zod-validated boundaries. Universal: runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [delivery-channel](./src/channels/delivery-channel.md) — `delivery-channel.ts` defines the structural contract every concrete delivery channel implements. The contract is inten…
|
||||
- [email-channel](./src/channels/email-channel.md) — `email-channel.ts` ships the `EmailChannel` `DeliveryChannel` implementation. The class is provider-neutral: the integr…
|
||||
- [in-app-channel](./src/channels/in-app-channel.md) — `in-app-channel.ts` ships the `InAppChannel` implementation that stores notifications for an in-app inbox / unread badg…
|
||||
- [push-channel](./src/channels/push-channel.md) — `push-channel.ts` ships the `PushChannel` implementation that delivers notifications via the Web Push Protocol (RFC 803…
|
||||
- [sms-channel](./src/channels/sms-channel.md) — `sms-channel.ts` ships the `SmsChannel` `DeliveryChannel` implementation. Like the email channel, it is provider-neutra…
|
||||
- [webhook-channel](./src/channels/webhook-channel.md) — `webhook-channel.ts` ships the `WebhookChannel` implementation that POSTs notifications to an arbitrary HTTPS endpoint.…
|
||||
- [digest](./src/digest/digest-channel.md) — `digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries deliver…
|
||||
- [digest-channel](./src/digest/digest-channel.md) — `digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries deliver…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw.…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw.…
|
||||
- [error-mapping](./src/error-mapping/error-mapping.md) — `error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed su…
|
||||
- [error-mapping](./src/error-mapping/error-mapping.md) — `error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed su…
|
||||
- [fakes](./src/fakes/fake-fetch.md) — `fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue an…
|
||||
- [fake-fetch](./src/fakes/fake-fetch.md) — `fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue an…
|
||||
- [hooks](./src/hooks/hooks.md) — `hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. Th…
|
||||
- [hooks](./src/hooks/hooks.md) — `hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. Th…
|
||||
- [otel](./src/otel/metrics.md) — `otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-chan…
|
||||
- [metrics](./src/otel/metrics.md) — `otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-chan…
|
||||
- [preferences](./src/preferences/preference-manager.md) — `preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification…
|
||||
- [preference-manager](./src/preferences/preference-manager.md) — `preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification…
|
||||
- [rate-limiter](./src/rate-limiter/channel-rate-limiter.md) — `channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter.…
|
||||
- [channel-rate-limiter](./src/rate-limiter/channel-rate-limiter.md) — `channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter.…
|
||||
- [retry](./src/retry/delivery-retry.md) — `delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backo…
|
||||
- [delivery-retry](./src/retry/delivery-retry.md) — `delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backo…
|
||||
- [router](./src/router/notification-router.md) — `notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves…
|
||||
- [notification-router](./src/router/notification-router.md) — `notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves…
|
||||
- [scheduler](./src/scheduler/notification-scheduler.md) — `notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. T…
|
||||
- [notification-scheduler](./src/scheduler/notification-scheduler.md) — `notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. T…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input…
|
||||
- [templates](./src/templates/template-engine.md) — `template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered outp…
|
||||
- [template-engine](./src/templates/template-engine.md) — `template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered outp…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/notification-channel
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
EmailChannel,
|
||||
NotificationRouter,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const email = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async (msg) => ({ messageId: await provider.send(msg) }),
|
||||
});
|
||||
|
||||
const router = new NotificationRouter({
|
||||
channels: [{ channel: "email", enabled: true, rateLimit: 100 }],
|
||||
defaultChannel: "email",
|
||||
deliveryChannels: [email],
|
||||
});
|
||||
|
||||
router.on("notification.failed", (n) => audit.record(n));
|
||||
|
||||
await router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Welcome to Suaveplan",
|
||||
subject: "Welcome",
|
||||
});
|
||||
```
|
||||
|
||||
## Surface
|
||||
|
||||
- **Five sending channels** — `EmailChannel`, `SmsChannel`, `PushChannel`, `InAppChannel`, `WebhookChannel`.
|
||||
- **One aggregating channel** — `DigestChannel` (wraps any sending channel).
|
||||
- **Router** — `NotificationRouter` with templates, routing rules, lifecycle events, OTel meters.
|
||||
- **Composables** — `ChannelRateLimiter`, `DeliveryRetry`, `NotificationScheduler`, `PreferenceManager`, `TemplateEngine`.
|
||||
- **Hooks** — `notification:routing` pre-routing pipeline (`onNotificationRouting`).
|
||||
- **Errors** — stable `NOTIFICATION_CHANNEL_CODES` registry with eleven reasons.
|
||||
- **Test double** — `FakeFetch` for deterministic `FetchLike` injection.
|
||||
- **OpenTelemetry** — four instruments under the `@suaveplan/notification-channel` meter.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes`](./src/error-codes.md) — `NOTIFICATION_CHANNEL_CODES` + typed error subclasses.
|
||||
- [`hooks/hooks`](./src/hooks/hooks.md) — generic hook bus + `notification:routing` pipeline.
|
||||
- [`schemas/schemas`](./src/schemas/schemas.md) — Zod schemas for every public option bundle.
|
||||
- [`channels/delivery-channel`](./src/channels/delivery-channel.md) — common transport contract.
|
||||
- [`channels/email-channel`](./src/channels/email-channel.md) — email delivery.
|
||||
- [`channels/sms-channel`](./src/channels/sms-channel.md) — SMS delivery.
|
||||
- [`channels/push-channel`](./src/channels/push-channel.md) — Web Push delivery (RFC 8030).
|
||||
- [`channels/in-app-channel`](./src/channels/in-app-channel.md) — in-app inbox delivery.
|
||||
- [`channels/webhook-channel`](./src/channels/webhook-channel.md) — HTTP webhook delivery.
|
||||
- [`digest/digest-channel`](./src/digest/digest-channel.md) — digest / summary delivery.
|
||||
- [`templates/template-engine`](./src/templates/template-engine.md) — template registry + renderer.
|
||||
- [`rate-limiter/channel-rate-limiter`](./src/rate-limiter/channel-rate-limiter.md) — sliding-window rate limits.
|
||||
- [`retry/delivery-retry`](./src/retry/delivery-retry.md) — retry wrapper + dead-letter queue.
|
||||
- [`scheduler/notification-scheduler`](./src/scheduler/notification-scheduler.md) — future-delivery scheduler.
|
||||
- [`preferences/preference-manager`](./src/preferences/preference-manager.md) — opt-in/out + quiet hours.
|
||||
- [`router/notification-router`](./src/router/notification-router.md) — primary multi-channel router.
|
||||
- [`otel/metrics`](./src/otel/metrics.md) — OpenTelemetry instruments.
|
||||
- [`fakes/fake-fetch`](./src/fakes/fake-fetch.md) — deterministic `FetchLike` test double.
|
||||
|
||||
## Architecture
|
||||
|
||||
Every dependency is injectable: `Clock` and `FetchLike` come from `@suaveplan/types`, the storage adapter behind `PreferenceManager` is caller-supplied, and every channel takes a `sendFn` callback so the package never bundles a specific provider SDK. The router uses `EventBus<NotificationEventMap>` so lifecycle events flow through the same typed-pub/sub primitive as the rest of the project.
|
||||
|
||||
The 11-code `defineCodes("NOTIFICATION_CHANNEL", […])` registry preserves literal-typed unions through the public surface, so an exhaustive `switch (err.code)` reports unhandled codes at type-check time. OpenTelemetry meters fire on every router send and every dead-letter event so dashboards reflect real production behaviour out of the box.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,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,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,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,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,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,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,57 @@
|
||||
# `digest-channel` — Digest delivery channel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries delivered through a delegate `DeliveryChannel`. It is the channel-shape adapter for "weekly summary email" / "hourly Slack roll-up" / "daily push digest" use-cases — it implements `DeliveryChannel` so it slots into the router exactly like a direct channel, and it owns its own per-recipient queue.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps any `DeliveryChannel` as `delegate`.
|
||||
- Per-recipient FIFO queue with configurable `maxPerDigest` cap.
|
||||
- `intervalMs` gates the next flush — `send` opportunistically flushes when the interval has elapsed since the last flush.
|
||||
- Customisable `formatDigest(notifications)` formatter.
|
||||
- `flush(recipient)`, `flushAll()`, `queueSize`, `pendingRecipients` for ops/cron integrations.
|
||||
- Injectable `Clock` so tests advance the interval without sleeping.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DigestChannel, EmailChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const email = new EmailChannel({ from: "noreply@x", sendFn });
|
||||
const digest = new DigestChannel({
|
||||
delegate: email,
|
||||
intervalMs: 3_600_000,
|
||||
});
|
||||
await digest.send(notification);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const digest = new DigestChannel({
|
||||
delegate: email,
|
||||
intervalMs: 86_400_000,
|
||||
maxPerDigest: 25,
|
||||
formatDigest: (ns) =>
|
||||
ns.map((n, i) => `${i + 1}. [${n.priority}] ${n.subject}: ${n.body}`).join("\n"),
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
|
||||
// In a cron handler:
|
||||
const results = await digest.flushAll();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DigestChannel(config: DigestChannelConfig)` — constructor.
|
||||
- `DigestChannelConfig` — `{ delegate, intervalMs?, maxPerDigest?, formatDigest?, clock? }`.
|
||||
- `enqueue(n)`, `send(n)`, `flush(recipient)`, `flushAll()`, `queueSize(recipient)`, `pendingRecipients()`, `canSend(n)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`enqueue` silently drops the overflow when the queue reaches `maxPerDigest`. This is a deliberate denial-of-service shield — a runaway pipeline cannot blow memory by enqueuing forever. The default formatter numbers entries and labels them by subject; consumers wanting Markdown / HTML output supply their own `formatDigest`. `canSend` proxies to the delegate, so the digest channel inherits the delegate's recipient validation — useful when the delegate is an `EmailChannel` that wants `@`-only recipients. `lastFlush` is tracked per-recipient and consulted from `send`/`flushAll`; a recipient flushed seconds ago will not flush again until the interval has elapsed.
|
||||
@@ -0,0 +1,62 @@
|
||||
# `error-codes` — Notification-channel error registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw. It registers a stable namespace `NOTIFICATION_CHANNEL` with eleven reason codes via `defineCodes("NOTIFICATION_CHANNEL", […])` from `@suaveplan/error`, and exposes a typed `SuaveplanError` subclass for each reason. The registry preserves the literal-typed union through the public surface so downstream consumers — loggers, problem+json responders, retry wrappers — can `instanceof`-narrow on a specific subclass or match on the `.code` discriminant without unsafe casts. The module is side-effect-imported from `src/index.ts` so the codes are part of the package's public ABI even for consumers that pull a subpath export.
|
||||
|
||||
## Features
|
||||
|
||||
- Eleven stable reason codes: `NOT_FOUND`, `CHANNEL_DISABLED`, `TEMPLATE_NOT_FOUND`, `RATE_LIMIT_EXCEEDED`, `ROUTING_FAILED`, `DELIVERY_FAILED`, `PROVIDER_ERROR`, `RATE_LIMITED`, `INVALID_RECIPIENT`, `CHANNEL_UNAVAILABLE`, `TIMEOUT`.
|
||||
- One typed subclass per code extending an abstract `NotificationChannelError` base.
|
||||
- Compile-time-frozen `NOTIFICATION_CHANNEL_CODES` record whose values are literal `"NOTIFICATION_CHANNEL.REASON"` strings.
|
||||
- Every constructor accepts the standard `SuaveplanErrorOptions` bag (`cause`, `context`, `traceId`, `clock`).
|
||||
- `RateLimitedError` carries an additional `retryAfterMs` field merged into `context`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
NOTIFICATION_CHANNEL_CODES,
|
||||
ChannelDisabledError,
|
||||
type NotificationChannelErrorCode,
|
||||
} from "@suaveplan/notification-channel/error-codes";
|
||||
|
||||
try {
|
||||
/* ... call the router ... */
|
||||
} catch (err) {
|
||||
if (err instanceof ChannelDisabledError) {
|
||||
const code: NotificationChannelErrorCode = err.code;
|
||||
void code; // "NOTIFICATION_CHANNEL.CHANNEL_DISABLED"
|
||||
}
|
||||
}
|
||||
|
||||
void NOTIFICATION_CHANNEL_CODES.RATE_LIMITED;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { RateLimitedError } from "@suaveplan/notification-channel/error-codes";
|
||||
|
||||
throw new RateLimitedError({
|
||||
message: "notification.errors.rateLimited",
|
||||
retryAfterMs: 30_000,
|
||||
context: { provider: "twilio" },
|
||||
traceId: "trace-abc",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NOTIFICATION_CHANNEL_CODES` — frozen record `{ readonly [K in Reason]: "NOTIFICATION_CHANNEL.${K}" }`.
|
||||
- `NotificationChannelErrorCode` — discriminated union of all eleven literal codes.
|
||||
- `NotificationChannelError` — abstract base extending `SuaveplanError<NotificationChannelErrorCode>`.
|
||||
- Concrete subclasses: `NotificationNotFoundError`, `ChannelDisabledError`, `TemplateNotFoundError`, `RateLimitExceededError`, `RoutingFailedError`, `DeliveryFailedError`, `ProviderErrorError`, `RateLimitedError`, `InvalidRecipientError`, `ChannelUnavailableError`, `DeliveryTimeoutError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses `defineCodes` from `@suaveplan/error` rather than a runtime `ErrorCodes` registry. `defineCodes` is purely type-level plus a frozen object, so it has no module-load side effect beyond the freeze, and the literal-typed union survives tree-shaking. `pickOpts` discards undefined fields explicitly to remain compatible with `exactOptionalPropertyTypes: true` in `tsconfig.base.json`. The split between "local lifecycle errors" and "upstream-provider errors" upstream had two parallel registries (`error-codes.ts` and `errors/notification-errors.ts`) — those are unified here so consumers see a single canonical code list. `RateLimitedError` is the one place the constructor accepts a typed sidecar (`retryAfterMs`) — it merges into `context` so downstream HTTP `Retry-After` mapping is straightforward.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `error-mapping` — Reason-to-constructor table
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed subclass constructor. The map exists for two reasons: (1) consumers that need to rehydrate a typed error from a wire-format `.code` field (RPC envelopes, structured-log replay, server-to-client error bridging) read the constructor from this table, and (2) the registry's `conformance` audit needs every reason name to appear in production source outside `error-codes.ts` — listing them here keeps the contract explicit.
|
||||
|
||||
## Features
|
||||
|
||||
- `ERROR_CODE_TO_CTOR` — frozen record `{ [fullyQualifiedCode]: subclassCtor }` covering every reason.
|
||||
- `REASON_CODES` — reason-word ⇒ fully-qualified code (e.g. `REASON_CODES.NOT_FOUND === "NOTIFICATION_CHANNEL.NOT_FOUND"`).
|
||||
- `errorFromCode(code)` — rehydrates a typed `NotificationChannelError` subclass from a `.code` discriminant.
|
||||
- Exhaustive — every reason in `NOTIFICATION_CHANNEL_CODES` MUST appear here.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { errorFromCode, REASON_CODES } from "@suaveplan/notification-channel";
|
||||
|
||||
const err = errorFromCode(REASON_CODES.RATE_LIMITED);
|
||||
console.error(err.code); // "NOTIFICATION_CHANNEL.RATE_LIMITED"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ERROR_CODE_TO_CTOR,
|
||||
NOTIFICATION_CHANNEL_CODES,
|
||||
type NotificationChannelErrorCode,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
function rehydrate(wire: { code: NotificationChannelErrorCode; cause?: unknown }) {
|
||||
const Ctor = ERROR_CODE_TO_CTOR[wire.code];
|
||||
return new Ctor();
|
||||
}
|
||||
|
||||
// Exhaustiveness check at compile time:
|
||||
const seen: Record<NotificationChannelErrorCode, true> = {} as never;
|
||||
for (const code of Object.values(NOTIFICATION_CHANNEL_CODES)) {
|
||||
seen[code] = true;
|
||||
void ERROR_CODE_TO_CTOR[code];
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ERROR_CODE_TO_CTOR: Readonly<Record<NotificationChannelErrorCode, new () => NotificationChannelError>>`.
|
||||
- `REASON_CODES: { readonly [Reason]: "NOTIFICATION_CHANNEL.<Reason>" }`.
|
||||
- `errorFromCode(code): NotificationChannelError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The exhaustiveness of `ERROR_CODE_TO_CTOR` is checked statically — the record type `Record<NotificationChannelErrorCode, …>` makes TypeScript complain if a code is missed. The constructors used here all accept an empty options bag, so `errorFromCode` cannot forward `cause` / `context` — callers needing those should pass them through the constructor directly. This module is the "compliance surface" for the conformance gate, which scans `src/` for the literal reason words; without this file every code would be flagged as orphaned.
|
||||
@@ -0,0 +1,61 @@
|
||||
# `fake-fetch` — Deterministic FetchLike test double
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue and records every call into a `calls` array. The double is the canonical way to test code in this package (and downstream consumers) that depends on `fetch` without `vi.mock` / `globalThis.fetch` patching / network access.
|
||||
|
||||
## Features
|
||||
|
||||
- `push(response)` enqueues a `FakeFetchResponse` — `{ status?, headers?, json?, text?, pending?, reject? }`.
|
||||
- `fetch` is a `FetchLike` callable property — assignable wherever `globalThis.fetch` is.
|
||||
- Calls recorded into `calls: { url, init }[]` in FIFO order.
|
||||
- Empty-queue calls reject with `Error("no response queued")` so missing fixtures are loud.
|
||||
- `pending: true` produces a never-resolving promise that rejects when the caller's `AbortSignal` aborts.
|
||||
- `reject: <Error>` produces a synchronous rejection.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FakeFetch, WebhookChannel } from "@suaveplan/notification-channel";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
|
||||
const channel = new WebhookChannel({ fetchFn: fake.fetch });
|
||||
await channel.send(notification);
|
||||
|
||||
expect(fake.calls[0]?.url).toBe(notification.recipient);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 429, headers: { "Retry-After": "5" } });
|
||||
fake.push({ pending: true }); // for an abort test
|
||||
fake.push({ reject: new Error("DNS") });
|
||||
|
||||
// In tests:
|
||||
const r1 = await client.fetch("https://x"); // 429
|
||||
await expect(
|
||||
client.fetch("https://x", { signal: AbortSignal.timeout(50) })
|
||||
).rejects.toThrow("aborted");
|
||||
await expect(client.fetch("https://x")).rejects.toThrow("DNS");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `FakeFetch` class.
|
||||
- `calls: FakeFetchCall[]` — FIFO record of every call.
|
||||
- `push(response: FakeFetchResponse): void` — append scripted response.
|
||||
- `fetch: FetchLike` — callable to inject.
|
||||
- `FakeFetchResponse` — `{ status?, headers?, json?, text?, pending?, reject? }`.
|
||||
- `FakeFetchCall` — `{ url, init }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`fetch` is a property, not a method, so `fake.fetch` survives destructuring (`const { fetch } = fake; fetch(url)` works). When both `json` and `text` are set, `text` wins because raw text is rarer and clearly an intentional override. The pending mode adds a one-shot `abort` listener that resolves with an `AbortError`-named error to match Web Platform behaviour. Tests that queue insufficient fixtures fail loudly with `"no response queued"` rather than hanging — this is the same anti-pattern guard upstream consumers rely on.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `hooks` — Pre-routing hook pipeline
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. The single key today is `notification:routing`; handlers receive the original input, the currently resolved channel, and a metadata bag, and may return a partial override that merges into the data flowing forward. The bus is intentionally local to this package — the `@suaveplan/events` foundation does not yet export a `Hooks` primitive, and inlining keeps the package universal with no extra workspace dep.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic `Hooks<TMap>` class — typed by a hook-map interface; cannot register on an undeclared key.
|
||||
- Handlers run in registration order (FIFO).
|
||||
- Each handler may return `undefined` (no-op), a full hook-map value, a partial override, or a `Promise` of either.
|
||||
- Unsubscribe returned from `hook(...)` — calling twice is safe.
|
||||
- `clear(key?)` drops handlers for a single key or every key.
|
||||
- Singleton `notificationHooks` so cross-cutting integrations (audit, A/B routing) can hook globally.
|
||||
- Shorthand `onNotificationRouting(handler)` for the singleton's primary key.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { onNotificationRouting } from "@suaveplan/notification-channel";
|
||||
|
||||
const off = onNotificationRouting((data) => {
|
||||
if (data.input.priority === "urgent") {
|
||||
return { resolvedChannel: "sms" };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// later
|
||||
off();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
Hooks,
|
||||
type NotificationHookMap,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const hooks = new Hooks<NotificationHookMap>();
|
||||
|
||||
hooks.hook("notification:routing", async (data) => {
|
||||
const enriched = await enrichRecipient(data.input.recipient);
|
||||
return {
|
||||
metadata: { ...data.metadata, geo: enriched.geo, segment: enriched.segment },
|
||||
};
|
||||
});
|
||||
|
||||
const result = await hooks.applyHooksAsync("notification:routing", {
|
||||
input: { recipient: "u@x.com", body: "Hi" },
|
||||
resolvedChannel: "email",
|
||||
metadata: {},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `Hooks<TMap>` — class.
|
||||
- `hook(key, fn)` → `() => void` — register, returns unsubscribe.
|
||||
- `clear(key?)` — drop handlers for `key`, or every key when omitted.
|
||||
- `count(key)` → `number` — number of registered handlers.
|
||||
- `applyHooksAsync(key, data)` → `Promise<data>` — run pipeline and return composed data.
|
||||
- `notificationHooks` — singleton `Hooks<NotificationHookMap>`.
|
||||
- `onNotificationRouting(handler)` — shorthand for `notificationHooks.hook("notification:routing", handler)`.
|
||||
- `NotificationHookMap` — `{ "notification:routing": NotificationRoutingHookData }`.
|
||||
- `NotificationRoutingHookData` — `{ input, resolvedChannel, metadata }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Handlers compose via `Object.assign`-style spread, so a handler that returns `{ resolvedChannel: "sms" }` overrides only that field while preserving `input` and `metadata`. Returning `undefined` is the explicit "no change" signal — handlers used for telemetry or audit can side-effect freely without returning anything. The `Hooks` class accepts a generic `TMap` so packages with their own hook map can reuse the same implementation; the singleton is pinned to `NotificationHookMap` so the singleton's only legal key is `notification:routing`. The bus is intentionally minimal: it has no priority, timeout, or interceptor surface — those concerns belong to `@suaveplan/events`'s richer `EventBus` interceptor stack, which the router itself uses for lifecycle events.
|
||||
@@ -0,0 +1,67 @@
|
||||
# `otel/metrics` — OpenTelemetry instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-channel` and exposes the recording helpers used by the router and retry wrapper. Tests rebind the instruments against an in-memory `MeterProvider` via `refreshNotificationMeters()` so emitted metrics can be inspected without spinning up a real OTel collector.
|
||||
|
||||
## Features
|
||||
|
||||
- `notification_channel.routed` (Counter) — one increment per `NotificationRouter.send` invocation, labelled `{ channel, outcome }`.
|
||||
- `notification_channel.delivery_attempts` (Counter) — one increment per dispatch to a registered `DeliveryChannel`.
|
||||
- `notification_channel.dead_letters` (Counter) — one increment when `DeliveryRetry` exhausts retries.
|
||||
- `notification_channel.latency_ms` (Histogram) — `NotificationRouter.send` end-to-end latency.
|
||||
- `refreshNotificationMeters()` — re-resolves every instrument against the global `MeterProvider`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { refreshNotificationMeters } from "@suaveplan/notification-channel/otel/metrics";
|
||||
|
||||
// Wire a real MeterProvider somewhere in process bootstrap, then:
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshNotificationMeters();
|
||||
|
||||
// Router and retry calls automatically populate the instruments.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
refreshNotificationMeters,
|
||||
recordDeadLetter,
|
||||
recordDeliveryAttempt,
|
||||
recordRouted,
|
||||
} from "@suaveplan/notification-channel/otel/metrics";
|
||||
|
||||
// Custom dispatcher that records attempts/dead-letters explicitly.
|
||||
async function dispatch(channel: string, send: () => Promise<void>) {
|
||||
try {
|
||||
await send();
|
||||
recordDeliveryAttempt(channel, "ok");
|
||||
} catch (err) {
|
||||
recordDeliveryAttempt(channel, "failed");
|
||||
recordDeadLetter(channel);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
refreshNotificationMeters();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `refreshNotificationMeters(): void` — rebind to the current global meter.
|
||||
- `recordRouted(channel, outcome, latencyMs)` — router send.
|
||||
- `recordDeliveryAttempt(channel, outcome)` — dispatch outcome.
|
||||
- `recordDeadLetter(channel)` — dead-letter event.
|
||||
- `RouterOutcome` — `"ok" | "failed"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are module-level `let` bindings re-pointed by `refreshNotificationMeters` because `@opentelemetry/api` resolves the meter at the moment `getMeter` is called — if `metrics.setGlobalMeterProvider` runs AFTER the module first loads, the original meter is still bound to the no-op provider. `refreshNotificationMeters` solves that for tests and for any process that wires the OTel SDK after this module imports. The recording helpers are typed against `RouterOutcome` so consumers cannot accidentally pass an unknown outcome label.
|
||||
@@ -0,0 +1,74 @@
|
||||
# `preference-manager` — Per-user opt-in / opt-out + quiet hours
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification reach this user on this channel right now?". The manager combines per-channel opt-in / opt-out preferences with optional quiet-hours configuration (with priority thresholds) and a pluggable `PreferenceStoreAdapter` for persistence.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-user channel enable / disable, with explicit `enabledChannels` and `disabledChannels` sets.
|
||||
- Default channels (configurable; built-in default: `["email", "in_app"]`).
|
||||
- Quiet-hours config with `startHour`, `endHour` (wrap-around supported), and optional `days` filter.
|
||||
- `urgentThreshold` controls which priorities punch through quiet hours (default `urgent`).
|
||||
- `canDeliver(userId, channel, priority)` is the single decision point.
|
||||
- Pluggable `PreferenceStoreAdapter` so persistence is delegated to SurrealDB, Postgres, Redis, etc.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
PreferenceManager,
|
||||
type PreferenceStoreAdapter,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const store: PreferenceStoreAdapter = makeStore();
|
||||
const manager = new PreferenceManager({ store });
|
||||
|
||||
if (await manager.canDeliver("user-1", "email", "normal")) {
|
||||
await router.send(...);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const manager = new PreferenceManager({
|
||||
store,
|
||||
defaultChannels: ["email", "push", "in_app"],
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
|
||||
await manager.setEnabled("user-1", "sms", true);
|
||||
await manager.setQuietHours("user-1", {
|
||||
startHour: 22,
|
||||
endHour: 7,
|
||||
days: [1, 2, 3, 4, 5], // weekdays
|
||||
});
|
||||
|
||||
// At dispatch time:
|
||||
for (const channel of selected) {
|
||||
if (await manager.canDeliver("user-1", channel, "high")) {
|
||||
await router.send({ recipient: "user-1", body: text, channel });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PreferenceManager(options: PreferenceManagerOptions)` — constructor.
|
||||
- `PreferenceManagerOptions` — `{ store, defaultChannels?, clock? }`.
|
||||
- `canDeliver(userId, channel, priority): Promise<boolean>`.
|
||||
- `getPreferences(userId): Promise<UserPreferences>`.
|
||||
- `setEnabled(userId, channel, enabled): Promise<void>`.
|
||||
- `setQuietHours(userId, quietHours | null): Promise<void>`.
|
||||
- `resetPreferences(userId): Promise<boolean>`.
|
||||
- `PreferenceStoreAdapter`, `UserPreferences`, `StoredPreferences`, `QuietHoursConfig`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Quiet-hours wrap around midnight when `startHour > endHour` (e.g. `22 → 7`). Priorities are ordered `low < normal < high < urgent`; if `urgentThreshold` is `"high"`, then both `high` and `urgent` punch through quiet hours. The day filter is opt-in — when omitted, quiet hours apply every day; when supplied, only the listed days (`0` = Sunday) trigger the window. `setQuietHours(userId, null)` clears the field; existing `enabledChannels`/`disabledChannels`/`urgentThreshold` are preserved. The store adapter is the single integration seam — the rest of the class is purely functional logic, so swapping persistence layers does not change behaviour.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `channel-rate-limiter` — Sliding-window rate limits
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter. Two independent buckets are maintained: a per-channel bucket (every send against that channel counts) and a per-user bucket (every send for that user counts regardless of channel). `checkAndRecord(channel, userId)` consults both, throws `RateLimitExceededError` on overflow, otherwise records the send and returns.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-channel and per-user limits with independent `{ maxCount, windowMs }` rules.
|
||||
- Sliding window — entries older than `windowMs` are discarded on every check.
|
||||
- `isAllowed` probe — non-recording check for UX-level pre-flight.
|
||||
- `getChannelRemaining` / `getUserRemaining` for headers like `X-RateLimit-Remaining`.
|
||||
- Injectable `Clock` so tests advance time without sleeping.
|
||||
- Universal — no Node/DOM-specific APIs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ChannelRateLimiter } from "@suaveplan/notification-channel";
|
||||
|
||||
const limiter = new ChannelRateLimiter();
|
||||
limiter.setChannelLimit("email", { maxCount: 100, windowMs: 60_000 });
|
||||
limiter.setUserLimit("user-42", { maxCount: 5, windowMs: 60_000 });
|
||||
|
||||
try {
|
||||
limiter.checkAndRecord("email", "user-42");
|
||||
} catch (err) {
|
||||
/* RateLimitExceededError */
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const limiter = new ChannelRateLimiter(() => clock.now());
|
||||
limiter.setChannelLimit("sms", { maxCount: 10, windowMs: 60_000 });
|
||||
|
||||
// Pre-flight without recording:
|
||||
if (!limiter.isAllowed("sms", userId)) {
|
||||
return reply.code(429).send({
|
||||
remaining: limiter.getChannelRemaining("sms"),
|
||||
});
|
||||
}
|
||||
limiter.checkAndRecord("sms", userId);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ChannelRateLimiter(clock?: Clock)` — constructor.
|
||||
- `setChannelLimit(channel, rule)`, `setUserLimit(userId, rule)` — install rules.
|
||||
- `removeChannelLimit(channel)`, `removeUserLimit(userId)` — drop rules and their windows.
|
||||
- `checkAndRecord(channel, userId)` — throws on overflow.
|
||||
- `isAllowed(channel, userId)` — boolean probe.
|
||||
- `getChannelRemaining(channel)`, `getUserRemaining(userId)` — current capacity (or `Infinity` when no rule).
|
||||
- `RateLimitRule` — `{ maxCount, windowMs }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The window is sliding, not fixed — entries are filtered against `now - timestamp < windowMs` on every check. This is more accurate than fixed buckets at the cost of `O(n)` filtering on every check where `n` is the number of entries in the window; for buckets with `maxCount` in the low hundreds this is negligible. `recordChannel` / `recordUser` are no-ops when no rule is installed, so the `WindowEntry` arrays stay empty when the bucket has no limit. The injected `Clock` only governs the limiter's view of "now" — wall-clock drift between server and limiter does not exist because the limiter never compares its timestamps to anything external.
|
||||
@@ -0,0 +1,72 @@
|
||||
# `delivery-retry` — Retry wrapper with dead-letter queue
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backoff and surfaces exhausted attempts to a dead-letter queue. The wrapper:
|
||||
|
||||
1. Calls the wrapped channel up to `maxRetries + 1` total times.
|
||||
2. Sleeps `baseDelayMs * 2^(attempt-1)` (capped at `maxDelayMs`) between attempts via an injectable `SleepFn`.
|
||||
3. On exhaustion, pushes a `DeadLetterEntry`, fires an optional handler, and throws `DeliveryFailedError`.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `{ maxRetries, baseDelayMs, maxDelayMs }`.
|
||||
- Exponential backoff with cap.
|
||||
- In-process dead-letter queue with `getDeadLetters` / `clearDeadLetters` surface.
|
||||
- `onDeadLetter(handler)` callback for forwarding to a real queue (Redis, SurrealDB) or pager-duty.
|
||||
- Injectable `Clock` and `SleepFn` for deterministic tests.
|
||||
- Throws `DeliveryFailedError` on exhaustion so the router emits `notification.failed`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DeliveryRetry } from "@suaveplan/notification-channel";
|
||||
|
||||
const retry = new DeliveryRetry({ maxRetries: 3, baseDelayMs: 500 });
|
||||
retry.onDeadLetter((entry) => pagerDuty.alert(entry));
|
||||
|
||||
try {
|
||||
await retry.sendWithRetry(emailChannel, notification);
|
||||
} catch (err) {
|
||||
/* DeliveryFailedError */
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const retry = new DeliveryRetry(
|
||||
{ maxRetries: 5, baseDelayMs: 200, maxDelayMs: 10_000 },
|
||||
() => clock.now(),
|
||||
async (ms) => fakeTimers.tick(ms)
|
||||
);
|
||||
|
||||
retry.onDeadLetter(async (entry) => {
|
||||
await deadLetterQueue.push(entry);
|
||||
});
|
||||
|
||||
for (const n of notifications) {
|
||||
try {
|
||||
await retry.sendWithRetry(channel, n);
|
||||
} catch {
|
||||
/* already dead-lettered */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DeliveryRetry(config?, clock?, sleep?)` — constructor.
|
||||
- `RetryConfig` — `{ maxRetries, baseDelayMs, maxDelayMs? }`.
|
||||
- `DeadLetterEntry` — `{ notification, channel, attempts, lastError, failedAt }`.
|
||||
- `DeadLetterHandler`, `SleepFn` — callable types.
|
||||
- `sendWithRetry(channel, notification): Promise<DeliveryResult>` — throws `DeliveryFailedError` on exhaustion.
|
||||
- `onDeadLetter(handler)`, `getDeadLetters()`, `clearDeadLetters()`, `calculateDelay(attempt)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The retry counter is 0-indexed: `attempt === 0` is the first call (no delay), `attempt === 1..maxRetries` incur a backoff before the call. `calculateDelay` is public so test suites can pin the schedule against expected values. The injectable `SleepFn` defaults to `setTimeout` — production should keep the default; tests SHOULD inject a no-op sleeper so the suite finishes in milliseconds. The dead-letter queue is in-process — for production durability, register an `onDeadLetter` handler that forwards to a persistent queue. The throw on exhaustion uses `DeliveryFailedError` with a `cause: Error(lastError)` so the upstream `EventBus` listener can introspect the underlying error string.
|
||||
@@ -0,0 +1,89 @@
|
||||
# `notification-router` — Primary multi-channel router
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves the target channel (input override → routing rules → default), runs the `notification:routing` hook pipeline, resolves the body / subject (template render or raw), checks the channel is enabled and within rate limit, optionally dispatches to a registered `DeliveryChannel`, persists the notification record, and emits a lifecycle event (`notification.sent` / `notification.failed` / `notification.cancelled` / `notification.created`) on its internal `EventBus`. Every public boundary is Zod-validated.
|
||||
|
||||
## Features
|
||||
|
||||
- Channel resolution: input override → routing rules (priority and predicate filters) → default channel.
|
||||
- Template-based content with `{{var}}` substitution.
|
||||
- Per-channel 60-second window rate limiting (in-process — wire `ChannelRateLimiter` for richer policies).
|
||||
- Lifecycle events via `EventBus`: `notification.created`, `notification.sent`, `notification.failed`, `notification.cancelled`.
|
||||
- Optional in-pipeline `DeliveryChannel` dispatch.
|
||||
- OpenTelemetry meters on every send.
|
||||
- Zod-validated config at the constructor boundary.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { NotificationRouter } from "@suaveplan/notification-channel";
|
||||
|
||||
const router = new NotificationRouter({
|
||||
channels: [
|
||||
{ channel: "email", enabled: true },
|
||||
{ channel: "sms", enabled: true },
|
||||
],
|
||||
defaultChannel: "email",
|
||||
});
|
||||
|
||||
const result = await router.send({
|
||||
recipient: "user@example.com",
|
||||
body: "Welcome!",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
EmailChannel,
|
||||
NotificationRouter,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const email = new EmailChannel({ from: "noreply@x", sendFn: provider.send });
|
||||
|
||||
const router = new NotificationRouter({
|
||||
channels: [{ channel: "email", enabled: true, rateLimit: 100 }],
|
||||
templates: [
|
||||
{
|
||||
id: "welcome",
|
||||
name: "Welcome",
|
||||
channel: "email",
|
||||
subject: "Welcome {{name}}",
|
||||
bodyTemplate: "Hi {{name}}",
|
||||
defaultPriority: "normal",
|
||||
},
|
||||
],
|
||||
routingRules: [
|
||||
{ id: "urgent-to-sms", name: "u", channels: ["sms"], priority: "urgent" },
|
||||
],
|
||||
defaultChannel: "email",
|
||||
deliveryChannels: [email],
|
||||
});
|
||||
|
||||
router.on("notification.failed", (n) => audit.record(n));
|
||||
await router.send({
|
||||
recipient: "u@x",
|
||||
body: "",
|
||||
templateId: "welcome",
|
||||
templateData: { name: "Alice" },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NotificationRouter(config: NotificationRouterConfig)` — constructor.
|
||||
- `send(input)`, `enqueue(input)`, `sendBatch(inputs)`, `getNotification(id)`, `cancel(id)`, `getChannelStats()`.
|
||||
- `registerChannel(c)`, `registerTemplate(t)`, `addRoutingRule(r)`, `registerDeliveryChannel(dc)`.
|
||||
- `on(event, listener)` — subscribe to lifecycle events.
|
||||
- `NotificationRouterConfig` — `{ channels, templates?, routingRules?, defaultChannel, deliveryChannels?, clock? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal 60-second rate-limit window is a fixed bucket per channel — for sliding-window or per-user limits, layer `ChannelRateLimiter` outside the router. `cancel` consults `VALID_TRANSITIONS` so cancelling a `sent` notification rejects (cannot transition `sent → cancelled`). The router uses `EventBus<NotificationEventMap>` so consumers get typed `on(...)` and the underlying envelope semantics — interceptors, abort signals — that ship in `@suaveplan/events`. Construction Zod-parses the config; runtime calls trust the inputs because `send` runs on every notification and re-validating on every call is wasteful — consumers MUST validate `NotificationInput` themselves if they accept it from untrusted sources.
|
||||
@@ -0,0 +1,64 @@
|
||||
# `notification-scheduler` — Future-delivery scheduler
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. The scheduler does NOT call the router itself — callers register an `onDue(handler)` that receives the original `NotificationInput`, typically wiring it back into `NotificationRouter.send` or `enqueue`. The class exposes both manual `processDue()` (call from cron / queue worker) and `start()`/`stop()` for in-process polling.
|
||||
|
||||
## Features
|
||||
|
||||
- `schedule(input, deliverAt)` returns a stable id.
|
||||
- `cancel(id)`, `get(id)`, `listPending()`, `getDue(now?)` for inspection.
|
||||
- `processDue()` awaits the handler per entry and removes processed entries.
|
||||
- `start()`/`stop()` polling driver with idempotent semantics.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
- Throws `NotificationNotFoundError` for unknown ids on `cancel` / `get`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
NotificationRouter,
|
||||
NotificationScheduler,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
const router = new NotificationRouter({ channels, defaultChannel: "email" });
|
||||
const scheduler = new NotificationScheduler();
|
||||
scheduler.onDue(async (input) => {
|
||||
await router.send(input);
|
||||
});
|
||||
scheduler.schedule({ recipient: "u@x", body: "Reminder" }, new Date(Date.now() + 60_000));
|
||||
scheduler.start();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const scheduler = new NotificationScheduler(5_000, () => clock.now());
|
||||
scheduler.onDue(async (input) => {
|
||||
try {
|
||||
await router.send(input);
|
||||
} catch (err) {
|
||||
await failureQueue.push({ input, err });
|
||||
}
|
||||
});
|
||||
|
||||
// Hook into a cron worker instead of in-process polling:
|
||||
cron.schedule("* * * * *", () => {
|
||||
void scheduler.processDue();
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NotificationScheduler(checkIntervalMs?, clock?)` — constructor.
|
||||
- `schedule(input, deliverAt)`, `cancel(id)`, `get(id)`, `listPending()`, `getDue(now?)`, `processDue()`, `start()`, `stop()`, `size`.
|
||||
- `ScheduledNotification` — `{ id, input, scheduledFor, createdAt, cancelled }`.
|
||||
- `ScheduleHandler` — `(input: NotificationInput) => Promise<void>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`start()` is idempotent — calling twice has no effect; the second call is dropped before `setInterval` runs. `stop()` is idempotent — calling twice is safe and a leading call before `start()` is a no-op. `processDue()` is the single place that mutates the schedule map — both the polling driver and manual / cron callers use it, so behaviour is identical regardless of how processing is triggered. The scheduler does not persist across process restarts — production deployments should layer their own durable store atop `schedule`/`cancel` (e.g. snapshot `listPending()` to disk on shutdown, restore on boot via additional `schedule` calls).
|
||||
@@ -0,0 +1,49 @@
|
||||
# `schemas` — Zod schemas at every public boundary
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input before passing it into a constructor or public method. The `NotificationRouter` constructor calls `NotificationRouterConfigSchema.parse(...)` at the boundary; other classes leave validation as an opt-in for the caller because validating on every send is wasteful — the consumer chooses when to enforce.
|
||||
|
||||
## Features
|
||||
|
||||
- Schemas for every channel config, every public bundle, and every enum: `NotificationChannelSchema`, `NotificationPrioritySchema`, `NotificationStatusSchema`, `RateLimitRuleSchema`, `ChannelConfigSchema`, `RoutingRuleSchema`, `NotificationTemplateSchema`, `TemplateRegistrationSchema`, `NotificationInputSchema`, `NotificationRouterConfigSchema`, `EmailChannelConfigSchema`, `SmsChannelConfigSchema`, `VapidKeysSchema`, `PushChannelConfigSchema`, `WebhookChannelConfigSchema`, `DigestChannelConfigSchema`, `RetryConfigSchema`, `QuietHoursConfigSchema`, `StoredPreferencesSchema`, `PreferenceManagerOptionsSchema`.
|
||||
- Function-valued fields (`sendFn`, `getSubscriptions`, `fetchFn`, `condition`, `formatDigest`, `store`, `clock`) are typed as `z.unknown()` — Zod cannot introspect callables, and the TypeScript surface is the source of truth for shapes.
|
||||
- Boundary defaults are NOT injected — schemas validate shape only, defaults live in the consuming classes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { NotificationInputSchema } from "@suaveplan/notification-channel";
|
||||
|
||||
const input = NotificationInputSchema.parse(req.body);
|
||||
await router.send(input);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
NotificationRouterConfigSchema,
|
||||
StoredPreferencesSchema,
|
||||
} from "@suaveplan/notification-channel";
|
||||
|
||||
// Validate config loaded from a YAML file:
|
||||
const cfg = NotificationRouterConfigSchema.parse(yaml.parse(text));
|
||||
const router = new NotificationRouter(cfg);
|
||||
|
||||
// Validate stored prefs at the persistence boundary:
|
||||
const safe = StoredPreferencesSchema.parse(row);
|
||||
await store.set(safe.userId, safe);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Each named export is a `z.ZodType`. Use `.parse(value)` to validate-or-throw, `.safeParse(value)` to validate-or-result. The schemas describe shape only — runtime behaviour, defaults, and engine-pluggable callables remain owned by the consuming classes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`exactOptionalPropertyTypes: true` in `tsconfig.base.json` makes Zod's `.optional()` behave correctly: an explicit `undefined` value is rejected when the field is absent in the input record, but is accepted on output because the consumer might spread defaults. We use `z.record(z.string(), z.unknown())` for `metadata`-style fields because notification metadata is genuinely free-form. The `RoutingRule.condition` and every `sendFn` / `fetchFn` are `z.unknown()` so YAML-loaded configs (which cannot carry callables) parse cleanly — TypeScript will still complain at the call site if the values are not callable.
|
||||
@@ -0,0 +1,80 @@
|
||||
# `template-engine` — Template registry and renderer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered output. Templates carry a `defaultTemplate` (used when no channel variant matches) plus optional `channelTemplates` keyed by `NotificationChannel`, with `{{var}}` placeholders substituted from a flat `Record<string,string>`. Missing keys are left as-is so QA can spot them.
|
||||
|
||||
## Features
|
||||
|
||||
- Register / unregister / list templates by string id.
|
||||
- Per-channel variants (`channelTemplates.email`, `.sms`, etc.) with fallback to `defaultTemplate`.
|
||||
- `{{var}}` placeholder substitution against a flat string-valued record.
|
||||
- Throws `TemplateNotFoundError` for unknown ids — no silent empty renders.
|
||||
- Universal — no DOM/Node-specific APIs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TemplateEngine } from "@suaveplan/notification-channel";
|
||||
|
||||
const engine = new TemplateEngine();
|
||||
engine.register({
|
||||
id: "welcome",
|
||||
name: "Welcome",
|
||||
defaultTemplate: {
|
||||
subject: "Welcome {{name}}",
|
||||
bodyTemplate: "Hello {{name}}, welcome to {{app}}!",
|
||||
},
|
||||
});
|
||||
|
||||
const rendered = engine.render("welcome", { name: "Alice", app: "Suaveplan" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
engine.register({
|
||||
id: "order-status",
|
||||
name: "Order status",
|
||||
defaultTemplate: {
|
||||
subject: "Order {{orderId}} — {{status}}",
|
||||
bodyTemplate: "Order #{{orderId}}: {{status}}",
|
||||
},
|
||||
channelTemplates: {
|
||||
sms: { bodyTemplate: "Order {{orderId}}: {{status}}" },
|
||||
email: {
|
||||
subject: "Order {{orderId}} update",
|
||||
bodyTemplate: "<h1>Order {{orderId}}</h1><p>{{status}}</p>",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sms = engine.render(
|
||||
"order-status",
|
||||
{ orderId: "12345", status: "shipped" },
|
||||
"sms"
|
||||
);
|
||||
const email = engine.render(
|
||||
"order-status",
|
||||
{ orderId: "12345", status: "shipped" },
|
||||
"email"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TemplateEngine` class.
|
||||
- `register(t: TemplateRegistration): void`
|
||||
- `unregister(id: string): boolean`
|
||||
- `has(id: string): boolean`
|
||||
- `listIds(): string[]`
|
||||
- `render(id, data?, channel?): RenderedTemplate`
|
||||
- `TemplateRegistration`, `ChannelTemplateBody`, `ChannelTemplate`, `RenderedTemplate`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Substitution uses `String.prototype.split(...).join(...)` rather than `replace(/.../g, ...)` so dollar-escape sequences in the value (`$&`, `$1`) flow through verbatim. Subjects flow through the same substitution pass when set. Unknown placeholders are intentionally preserved — silently dropping them masks integration mismatches that QA would otherwise catch. The engine is intentionally simple: no conditionals, no loops, no template inheritance. Consumers needing those features should plug a real template language (Handlebars, Liquid, MJML) into a custom `TemplateEngine` decorator or render upstream.
|
||||
@@ -0,0 +1,87 @@
|
||||
# @suaveplan/slack-client - Typed deterministic Slack client — Slack Web API + incoming-webhook surface, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable Clock/Rng/Logger/FetchLike, stable defineCodes('SLACK', [...]) error registry. Universal: runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
Typed, deterministic Slack client — Web API + incoming-webhook surface — with Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable `Clock` / `Rng` / `LoggerLike` / `FetchLike`, and a stable `defineCodes("SLACK", […])` error registry.
|
||||
|
||||
Universal: runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [classify](./src/client/classify.md) — `classify.ts` exports two pure mapping functions that fold transport-level failure signals into the stable `SLACK_CODES…
|
||||
- [retry-after](./src/client/retry-after.md) — `retry-after.ts` exports a single pure function `parseRetryAfterMs(response, clock?)` that converts an HTTP `Retry-Afte…
|
||||
- [slack-api-client](./src/client/slack-api-client.md) — `SlackApiClient` is the typed transport for the Slack Web API. It exposes the six methods downstream packages rely on —…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/slack-client` package can throw. It regi…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/slack-client` package can throw. It regi…
|
||||
- [fakes](./src/fakes/fake-fetch.md) — `fake-fetch.ts` exports `FakeFetch`, the deterministic test double the package's own unit tests use to exercise every t…
|
||||
- [fake-fetch](./src/fakes/fake-fetch.md) — `fake-fetch.ts` exports `FakeFetch`, the deterministic test double the package's own unit tests use to exercise every t…
|
||||
- [otel](./src/otel/metrics.md) — `metrics.ts` registers four OpenTelemetry instruments under the meter name `@suaveplan/slack-client` and exports the he…
|
||||
- [metrics](./src/otel/metrics.md) — `metrics.ts` registers four OpenTelemetry instruments under the meter name `@suaveplan/slack-client` and exports the he…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exports the Zod schemas that validate every public option bag and every Slack Web API request payload that…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exports the Zod schemas that validate every public option bag and every Slack Web API request payload that…
|
||||
- [webhook](./src/webhook/slack-webhook.md) — `SlackWebhook` is the simpler half of the package — a typed wrapper around Slack's incoming-webhook URLs. Where `SlackA…
|
||||
- [slack-webhook](./src/webhook/slack-webhook.md) — `SlackWebhook` is the simpler half of the package — a typed wrapper around Slack's incoming-webhook URLs. Where `SlackA…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/slack-client
|
||||
```
|
||||
|
||||
## Two clients
|
||||
|
||||
- `SlackApiClient` — Slack Web API. Token-authenticated. Supports `sendMessage`, `updateMessage`, `deleteMessage`, `listChannels`, `getChannelInfo`, `listUsers`, `getStatus`.
|
||||
- `SlackWebhook` — Slack incoming webhooks. URL-authenticated. Supports `send` and `sendText`.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import { SlackApiClient, SlackWebhook } from "@suaveplan/slack-client";
|
||||
|
||||
const api = new SlackApiClient({ token: process.env["SLACK_TOKEN"]! });
|
||||
await api.sendMessage({ channel: "C0123", text: "Deploy succeeded." });
|
||||
|
||||
const hook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T/B/X",
|
||||
});
|
||||
await hook.sendText("Build broken on main.");
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes`](./src/error-codes.md) — `SLACK_CODES` registry + 8 typed error subclasses.
|
||||
- [`types/slack-client`](./src/types/slack-client.types.ts) — public shapes.
|
||||
- [`schemas/schemas`](./src/schemas/schemas.md) — Zod boundary schemas.
|
||||
- [`client/slack-api-client`](./src/client/slack-api-client.md) — Slack Web API client.
|
||||
- [`client/retry-after`](./src/client/retry-after.md) — `Retry-After` / `x-ratelimit-reset` parser.
|
||||
- [`client/classify`](./src/client/classify.md) — HTTP-status + Slack-error string classifier.
|
||||
- [`webhook/slack-webhook`](./src/webhook/slack-webhook.md) — incoming-webhook client.
|
||||
- [`otel/metrics`](./src/otel/metrics.md) — four OpenTelemetry instruments.
|
||||
- [`fakes/fake-fetch`](./src/fakes/fake-fetch.md) — deterministic `FetchLike` test double.
|
||||
|
||||
## Error codes
|
||||
|
||||
All eight reasons land under the `SLACK.*` namespace:
|
||||
|
||||
- `SLACK.API_ERROR` — generic non-ok response from the Slack API.
|
||||
- `SLACK.AUTH_ERROR` — token rejected (HTTP 401/403, `invalid_auth`, `token_revoked`, `account_inactive`).
|
||||
- `SLACK.RATE_LIMITED` — HTTP 429; `context.retryAfterMs` carries parsed delay.
|
||||
- `SLACK.CHANNEL_NOT_FOUND` — HTTP 404 or `channel_not_found` / `is_archived` / `not_in_channel`.
|
||||
- `SLACK.WORKSPACE_NOT_FOUND` — `team_not_found` / `no_workspace`.
|
||||
- `SLACK.CONFIG_MISSING` — empty/invalid token or webhook URL at construct time.
|
||||
- `SLACK.WEBHOOK_FAILED` — webhook POST returned non-2xx (non-auth).
|
||||
- `SLACK.TIMEOUT` — internal `timeoutMs` fired OR caller `AbortSignal` aborted OR network failure.
|
||||
|
||||
## OpenTelemetry
|
||||
|
||||
Four instruments under meter `@suaveplan/slack-client@0.1.0`:
|
||||
|
||||
- `slack.requests` (counter) — `{ method, outcome }`
|
||||
- `slack.errors` (counter) — `{ method, code }`
|
||||
- `slack.latency_ms` (histogram, unit `ms`) — `{ method, outcome }`
|
||||
- `slack.webhook_sends` (counter) — `{ outcome }`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# `client/classify` — HTTP status + Slack-error classifier
|
||||
|
||||
## Purpose
|
||||
|
||||
`classify.ts` exports two pure mapping functions that fold transport-level failure signals into the stable `SLACK_CODES` registry. `classifyHttpStatus` partitions every observed HTTP status code into one of `AUTH_ERROR`, `CHANNEL_NOT_FOUND`, `TIMEOUT`, `RATE_LIMITED`, or the catch-all `API_ERROR`. `classifySlackError` does the same for Slack's JSON-level error strings (`invalid_auth`, `channel_not_found`, `team_not_found`, etc.). Centralising the classification keeps the API client and the webhook client in lock-step and gives downstream retry wrappers a single source of truth for what the package considers a retryable failure.
|
||||
|
||||
## Features
|
||||
|
||||
- Total HTTP-status partition — every numeric status maps to exactly one code.
|
||||
- Total Slack-error partition — every JSON `error` string maps to exactly one code, with regex matchers grouping family members (`is_archived`/`not_in_channel` count as `CHANNEL_NOT_FOUND`).
|
||||
- Pure functions — no side effects, no module-scope state, safe to tree-shake.
|
||||
- Literal-typed return — `SlackErrorCode` discriminated union is preserved.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyHttpStatus,
|
||||
classifySlackError,
|
||||
} from "@suaveplan/slack-client/client/classify";
|
||||
|
||||
const fromStatus = classifyHttpStatus(429);
|
||||
// "SLACK.RATE_LIMITED"
|
||||
|
||||
const fromBody = classifySlackError("invalid_auth");
|
||||
// "SLACK.AUTH_ERROR"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyHttpStatus,
|
||||
classifySlackError,
|
||||
} from "@suaveplan/slack-client/client/classify";
|
||||
import { SLACK_CODES } from "@suaveplan/slack-client/error-codes";
|
||||
|
||||
function isRetryable(code: ReturnType<typeof classifyHttpStatus>): boolean {
|
||||
return (
|
||||
code === SLACK_CODES.RATE_LIMITED ||
|
||||
code === SLACK_CODES.TIMEOUT ||
|
||||
code === SLACK_CODES.API_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const retry = isRetryable(classifyHttpStatus(503));
|
||||
// true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `classifyHttpStatus(status: number): SlackErrorCode`
|
||||
- `classifySlackError(slackError: string): SlackErrorCode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Slack-error matchers use case-insensitive regular expressions because Slack's documentation is inconsistent on casing and the API has historically returned both `invalid_auth` and `Invalid_Auth` in different surfaces. The order of the checks is important — `invalid_auth` must match before any `not_*` matcher because `not_authed` is an auth error, not a channel error. The HTTP-status partition deliberately uses `===` comparisons rather than range checks so that adding a new status (e.g. 451) requires an explicit decision rather than silently joining the catch-all bucket.
|
||||
@@ -0,0 +1,52 @@
|
||||
# `client/retry-after` — Retry-After header parser
|
||||
|
||||
## Purpose
|
||||
|
||||
`retry-after.ts` exports a single pure function `parseRetryAfterMs(response, clock?)` that converts an HTTP `Retry-After` header into a millisecond delay suitable for backoff loops. Slack's rate limiter emits both the standard `Retry-After` header (RFC 7231) and a Slack-specific `x-ratelimit-reset` header in epoch-seconds form, sometimes ISO-8601, sometimes both — the parser handles every observed shape and degrades gracefully to `undefined` when nothing parseable is present. The function injects a `Clock` so tests can pin the wall-clock at a deterministic instant and assert against an exact millisecond delta.
|
||||
|
||||
## Features
|
||||
|
||||
- Delay-seconds form (`"30"`) — returns `seconds * 1000`.
|
||||
- HTTP-date form (`"Wed, 21 Oct 2026 07:28:00 GMT"`) — returns `parsed - clock()`.
|
||||
- `x-ratelimit-reset` fallback in epoch-seconds, epoch-milliseconds (auto-detected by magnitude), or ISO-8601.
|
||||
- `undefined` when no header is present OR all parse attempts fail.
|
||||
- Negative deltas are clamped to `0` (a past reset means retry immediately).
|
||||
- Pure function — no module-scope clock, no `Date.now()` baked in.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseRetryAfterMs } from "@suaveplan/slack-client/client/retry-after";
|
||||
|
||||
const delay = parseRetryAfterMs(response);
|
||||
if (delay !== undefined) {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseRetryAfterMs } from "@suaveplan/slack-client/client/retry-after";
|
||||
|
||||
// Pin the clock for a deterministic test
|
||||
const t0 = 1_000_000_000_000;
|
||||
const fakeResponse = new Response(null, {
|
||||
status: 429,
|
||||
headers: { "Retry-After": new Date(t0 + 60_000).toUTCString() },
|
||||
});
|
||||
const delay = parseRetryAfterMs(fakeResponse, () => t0);
|
||||
// delay ≈ 60000
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`parseRetryAfterMs(response: Response, clock?: Clock): number | undefined`
|
||||
|
||||
- `response` — any `Response` whose `headers` are inspected.
|
||||
- `clock` — wall-clock source defaulted to `() => Date.now()`.
|
||||
- Returns the milliseconds-to-wait, or `undefined` when no header is parseable.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The epoch detection uses the magnitude heuristic `resetNum < 1e12` to distinguish seconds from milliseconds — `1e12` corresponds to year 33658 in epoch-seconds and is therefore safely "in the future" for both interpretations. The function intentionally tests `Number.isFinite` first, before `Date.parse`, because a numeric `Retry-After` like `"0"` produces `Date.parse("0") = NaN` in some engines and `0` in others, and the seconds branch handles the universal case unambiguously.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `client/slack-api-client` — Slack Web API client
|
||||
|
||||
## Purpose
|
||||
|
||||
`SlackApiClient` is the typed transport for the Slack Web API. It exposes the six methods downstream packages rely on — `sendMessage`, `updateMessage`, `deleteMessage`, `listChannels`, `getChannelInfo`, `listUsers` — plus `getStatus` for retrieving the delivery report stored after a successful `sendMessage`. The client accepts an injectable transport bag (`fetch`, `clock`, `logger`) so production code can pass `globalThis.fetch` while tests inject a `FakeFetch`. Every public method is bounded by an internal timeout enforced via `AbortController`, supports a caller-supplied `AbortSignal`, classifies non-2xx responses through the shared HTTP / Slack-error matchers, and emits the four OTel instruments defined in `otel/metrics`.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod boundary validation in the constructor — empty `token` rejects with `SlackConfigMissingError` whose `cause` is the originating `ZodError`.
|
||||
- Eight typed error subclasses surfaced through `classify` + `#errorForCode`.
|
||||
- `Retry-After` and `x-ratelimit-reset` parsing on HTTP 429 — millisecond delta exposed via `error.context.retryAfterMs`.
|
||||
- snake_case → camelCase mapping for channel and user payloads with `undefined`-elision so the public shape stays compatible with `exactOptionalPropertyTypes`.
|
||||
- Internal `timeoutMs` (default 10 000 ms) AND caller `AbortSignal` both routed through one `AbortController`.
|
||||
- `slack.requests` / `slack.errors` / `slack.latency_ms` emitted from every code path.
|
||||
- Delivery-report cache (`Map<ts, SlackDeliveryReport>`) for `getStatus(ts)` lookups.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SlackApiClient } from "@suaveplan/slack-client";
|
||||
|
||||
const client = new SlackApiClient({ token: process.env["SLACK_TOKEN"]! });
|
||||
const res = await client.sendMessage({
|
||||
channel: "C0123456789",
|
||||
text: "Build is green :white_check_mark:",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackApiClient } from "@suaveplan/slack-client";
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({
|
||||
json: { ok: true, channel: "C1", ts: "1.1" },
|
||||
});
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const client = new SlackApiClient({
|
||||
token: "xoxb-test",
|
||||
fetch: fake.fetch,
|
||||
clock: () => now++,
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const res = await client.sendMessage({ channel: "C1", text: "hi" });
|
||||
const report = client.getStatus(res.ts!);
|
||||
// report.status === "delivered"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SlackApiClient(options: SlackApiClientOptions)`
|
||||
- `sendMessage(message: SlackMessage, signal?: AbortSignal): Promise<SlackResponse>`
|
||||
- `updateMessage(channel, ts, text, blocks?): Promise<SlackResponse>`
|
||||
- `deleteMessage(channel, ts): Promise<SlackResponse>`
|
||||
- `listChannels(limit?): Promise<SlackChannel[]>`
|
||||
- `getChannelInfo(channelId): Promise<SlackChannel>`
|
||||
- `listUsers(limit?): Promise<SlackUser[]>`
|
||||
- `getStatus(ts: string): SlackDeliveryReport | undefined`
|
||||
|
||||
Errors thrown: `SlackConfigMissingError`, `SlackChannelNotFoundError`, `SlackAuthError`, `SlackRateLimitedError`, `SlackTimeoutError`, `SlackWorkspaceNotFoundError`, `SlackApiError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The client uses private `#`-prefixed class fields (not TS `private`) so consumer tooling cannot accidentally reach into the instance through bracket access — the contract is enforced at the runtime level by the engine. The request method assembles one `AbortController` that listens to both the caller signal AND the internal timer, then aborts on whichever fires first; the listener is removed in `finish()` to avoid leaking attached handlers across requests. The `#errorForCode` switch is exhaustive over `SlackErrorCode` and defaults to `SlackApiError` for forward-compatibility when the registry expands.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `error-codes` — Slack error-code registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`error-codes.ts` is the single source of truth for every error the `@suaveplan/slack-client` package can throw. It registers a stable namespace `SLACK` with eight reason codes via `defineCodes("SLACK", [...])` from `@suaveplan/error`, and exposes a typed `SuaveplanError` subclass for each reason. The registry preserves the literal-typed union through the public surface so downstream consumers can `instanceof`-narrow on a specific subclass or match on the `.code` discriminant without unsafe casts. Side-effect import from `src/index.ts` ensures the codes are part of the package's public ABI even for consumers that pull a subpath export.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight stable reason codes: `API_ERROR`, `AUTH_ERROR`, `RATE_LIMITED`, `CHANNEL_NOT_FOUND`, `WORKSPACE_NOT_FOUND`, `CONFIG_MISSING`, `WEBHOOK_FAILED`, `TIMEOUT`.
|
||||
- One typed subclass per code, extending an abstract `SlackError` base.
|
||||
- Compile-time-frozen `SLACK_CODES` record with literal `"SLACK.REASON"` values.
|
||||
- Every constructor accepts the standard `SuaveplanErrorOptions` bag (`cause`, `context`, `traceId`, `clock`).
|
||||
- `SlackRateLimitedError` carries an additional `retryAfterMs` field merged into `context`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SLACK_CODES,
|
||||
SlackAuthError,
|
||||
type SlackErrorCode,
|
||||
} from "@suaveplan/slack-client/error-codes";
|
||||
|
||||
try {
|
||||
// ... call Slack ...
|
||||
} catch (err) {
|
||||
if (err instanceof SlackAuthError) {
|
||||
// err.code is literal-typed as "SLACK.AUTH_ERROR"
|
||||
const code: SlackErrorCode = err.code;
|
||||
void code;
|
||||
}
|
||||
}
|
||||
|
||||
void SLACK_CODES.AUTH_ERROR; // "SLACK.AUTH_ERROR"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackRateLimitedError } from "@suaveplan/slack-client/error-codes";
|
||||
|
||||
throw new SlackRateLimitedError({
|
||||
message: "slack.errors.rateLimited",
|
||||
retryAfterMs: 30_000,
|
||||
context: { url: "https://slack.com/api/chat.postMessage" },
|
||||
traceId: "trace-abc",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SLACK_CODES` — frozen record: `{ readonly [K in Reason]: "SLACK.${K}" }`.
|
||||
- `SlackErrorCode` — discriminated union of all eight literal codes.
|
||||
- `SlackError` — abstract base extending `SuaveplanError<SlackErrorCode>`.
|
||||
- `SlackApiError`, `SlackAuthError`, `SlackRateLimitedError`, `SlackChannelNotFoundError`, `SlackWorkspaceNotFoundError`, `SlackConfigMissingError`, `SlackWebhookFailedError`, `SlackTimeoutError` — concrete subclasses.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses `defineCodes` rather than the legacy `ErrorCodes.registerMany` runtime registry from the upstream library. `defineCodes` is purely type-level + a frozen object, so it has no module-load side effect beyond the freeze, and the literal-typed union survives tree-shaking. `pickOpts` discards undefined fields explicitly to remain compatible with `exactOptionalPropertyTypes: true` in the `tsconfig.base.json` — passing `{ traceId: undefined }` directly would violate that flag.
|
||||
@@ -0,0 +1,61 @@
|
||||
# `fakes/fake-fetch` — Deterministic FetchLike test double
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`fake-fetch.ts` exports `FakeFetch`, the deterministic test double the package's own unit tests use to exercise every transport-level scenario without monkey-patching `globalThis.fetch` and without `vi.mock`. Each `FakeFetch` instance carries a FIFO queue of scripted `FakeFetchResponse` objects and a `calls` array that records every invocation in order. The `fetch` property satisfies the `FetchLike` shape from `@suaveplan/types`, so any client that accepts a `FetchLike` (notably `SlackApiClient` and `SlackWebhook`) can be driven directly from a test without touching the global. Downstream packages can re-export the class for their own tests too.
|
||||
|
||||
## Features
|
||||
|
||||
- FIFO response queue — the first `push` is consumed by the first `fetch` call.
|
||||
- Captured-call recording — `calls[]` preserves every URL and `RequestInit` verbatim.
|
||||
- Three response modes — scripted (status + body), `pending: true` (never resolves), `reject: Error` (immediate rejection).
|
||||
- Abort propagation — a `pending` response respects the caller's `AbortSignal` and rejects with an `AbortError` whose `name === "AbortError"`.
|
||||
- Headers via the standard WHATWG `Headers` API — case-insensitive lookup, multi-value support.
|
||||
- Zero external deps.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1", ts: "1.1" } });
|
||||
|
||||
const res = await fake.fetch("https://slack.com/api/chat.postMessage", {
|
||||
method: "POST",
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
// body.ok === true
|
||||
// fake.calls[0].url === "https://slack.com/api/chat.postMessage"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
|
||||
const controller = new AbortController();
|
||||
const promise = fake.fetch("https://example", { signal: controller.signal });
|
||||
controller.abort();
|
||||
await expect(promise).rejects.toMatchObject({ name: "AbortError" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FakeFetch`
|
||||
- `push(response: FakeFetchResponse): void` — queue a scripted response.
|
||||
- `readonly fetch: FetchLike` — the callable to inject into the client under test.
|
||||
- `readonly calls: FakeFetchCall[]` — captured calls in FIFO order.
|
||||
- `interface FakeFetchResponse { status?: number; headers?: Record<string, string>; json?: unknown; text?: string; pending?: boolean; reject?: Error }`
|
||||
- `interface FakeFetchCall { url: string; init: RequestInit | undefined }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `fetch` member is declared as an arrow function so the `this` binding is preserved when callers destructure (`const { fetch } = new FakeFetch()`). The `pending` mode allocates a `Promise` whose only resolution path is via the caller's `AbortSignal`; this matches the abort semantics of real `globalThis.fetch` and lets us pin the in-flight-then-cancelled scenario without a real timer. The shim builds the response body through the standard `Response` constructor so callers see exactly the WHATWG shape (`.json()`, `.text()`, `.headers`) — no bespoke method set means the double is byte-compatible with `globalThis.fetch`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# `otel/metrics` — OpenTelemetry instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`metrics.ts` registers four OpenTelemetry instruments under the meter name `@suaveplan/slack-client` and exports the helper functions every production path uses to record observations: `recordRequest`, `recordError`, `recordWebhook`. The module intentionally retrieves the meter once at module load AND exposes `refreshSlackMeters()` so tests can rewire the meter against a real in-memory `MeterProvider` after configuring the global provider. This keeps the production hot path free of `MeterProvider` lookups while still permitting `getMetrics()` assertions in the test suite.
|
||||
|
||||
## Features
|
||||
|
||||
- Four instruments: `slack.requests` (Counter), `slack.errors` (Counter), `slack.latency_ms` (Histogram, unit `ms`), `slack.webhook_sends` (Counter).
|
||||
- Attributes — `{ method, outcome }` on requests, `{ method, code }` on errors, `{ outcome }` on webhook sends.
|
||||
- Module-load resolution against `metrics.getMeter("@suaveplan/slack-client", "0.1.0")`.
|
||||
- `refreshSlackMeters()` for tests to rebind after `metrics.setGlobalMeterProvider(...)`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordRequest } from "@suaveplan/slack-client/otel/metrics";
|
||||
|
||||
recordRequest("chat.postMessage", "ok", 142);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { refreshSlackMeters } from "@suaveplan/slack-client/otel/metrics";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const provider = new MeterProvider({
|
||||
readers: [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshSlackMeters();
|
||||
|
||||
// ... run code that records ...
|
||||
|
||||
await provider.forceFlush();
|
||||
const observed = exporter.getMetrics();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordRequest(method: string, outcome: "ok" | "error", latencyMs: number): void` — increments `slack.requests` and records `slack.latency_ms`.
|
||||
- `recordError(method: string, code: string): void` — increments `slack.errors`.
|
||||
- `recordWebhook(outcome: "ok" | "error"): void` — increments `slack.webhook_sends`.
|
||||
- `refreshSlackMeters(): void` — re-resolves every instrument against the current global meter provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are held in module-scope `let` bindings (not `const`) because `refreshSlackMeters()` rebinds them after a test wires a fresh `MeterProvider`. This is the standard pattern for packages that exercise OTel in tests via a real in-memory exporter — the alternative (proxying every call through a getter) adds a per-call indirection on the production hot path. Instrument names follow the `<domain>.<metric>` convention seen in the wider ecosystem; the histogram unit is set to `"ms"` so dashboards render correctly without a unit-conversion step.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `schemas` — Zod boundary validation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`schemas.ts` exports the Zod schemas that validate every public option bag and every Slack Web API request payload that crosses the package boundary. The schemas are the single source of truth for the runtime shape contract — they pair with the structural types in `types/slack-client.types.ts` so callers can rely on identical compile-time AND runtime guarantees. Schema-driven validation lets the constructors reject malformed input with a typed `SlackConfigMissingError` whose `cause` is the original `ZodError`, surfacing the offending field path through the standard Zod issue list.
|
||||
|
||||
## Features
|
||||
|
||||
- `SlackApiClientOptionsSchema` — validates `{ token, baseUrl?, defaultChannel?, timeoutMs? }`.
|
||||
- `SlackWebhookOptionsSchema` — validates `{ webhookUrl, timeoutMs? }`.
|
||||
- `SlackMessageSchema` — validates the `chat.postMessage` payload.
|
||||
- `SlackBlockSchema`, `SlackBlockElementSchema`, `SlackTextObjectSchema` — Block Kit boundary shapes.
|
||||
- All schemas are exported as values (not just types) so callers can re-use them at their own boundaries.
|
||||
- Strict-mode (`.strict()`) on Block Kit shapes; `.passthrough()` on top-level option bags so callers may forward arbitrary transport overrides (`fetch`, `clock`, `rng`, `logger`) which Zod cannot describe.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SlackMessageSchema } from "@suaveplan/slack-client/schemas/schemas";
|
||||
|
||||
const parsed = SlackMessageSchema.parse({
|
||||
channel: "C123",
|
||||
text: "hi",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackApiClientOptionsSchema } from "@suaveplan/slack-client/schemas/schemas";
|
||||
|
||||
const result = SlackApiClientOptionsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
console.warn(issue.path.join("."), issue.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SlackApiClientOptionsSchema: ZodObject` — primary client options.
|
||||
- `SlackWebhookOptionsSchema: ZodObject` — webhook options.
|
||||
- `SlackMessageSchema: ZodObject` — full message payload.
|
||||
- `SlackBlockSchema: ZodObject` — single block.
|
||||
- `SlackBlockElementSchema: ZodType` — nested block element.
|
||||
- `SlackTextObjectSchema: ZodObject` — Block Kit text composition object.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SlackBlockElementSchema` is annotated with an explicit `ZodType<...>` shape because TypeScript cannot infer the recursive cross-reference between blocks and elements through Zod's chained builders. The top-level option schemas use `.passthrough()` rather than `.strict()` because the transport bag (`fetch`, `clock`, `rng`, `logger`) is structural and falls outside Zod's serialisable surface — strict mode would reject these legitimate option keys.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `webhook/slack-webhook` — Slack incoming-webhook client
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SlackWebhook` is the simpler half of the package — a typed wrapper around Slack's incoming-webhook URLs. Where `SlackApiClient` requires an OAuth token and supports the full Web API, the webhook client takes only the URL and POSTs `{ text, blocks? }` payloads. It exists because many ops surfaces (CI bots, alerting, build notifications) only need fire-and-forget delivery and the webhook URL is the lower-friction credential — no token rotation, no scoped permissions. The client returns a `SlackDeliveryReport` on success and surfaces typed errors on every failure path so retry middleware can pattern-match on `.code`.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod boundary validation in the constructor — invalid `webhookUrl` rejects with `SlackConfigMissingError`.
|
||||
- Three failure paths — network/timeout (`SlackTimeoutError`), HTTP 401/403 (`SlackAuthError`), other 4xx/5xx (`SlackWebhookFailedError`).
|
||||
- Injectable `Clock` for deterministic message-id timestamps.
|
||||
- Optional `Rng` injection — when supplied, the message-id suffix uses `rng.nextUint32().toString(36)` for uniqueness across rapidly-issued sends; otherwise falls back to the monotonic counter.
|
||||
- `slack.webhook_sends` and `slack.errors` OTel instruments emitted on every path.
|
||||
- Caller-supplied `AbortSignal` AND internal `timeoutMs` (default 10 000 ms) both honoured.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SlackWebhook } from "@suaveplan/slack-client";
|
||||
|
||||
const webhook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T0/B0/X",
|
||||
});
|
||||
|
||||
const report = await webhook.sendText("Deploy complete.");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackWebhook } from "@suaveplan/slack-client";
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
|
||||
let counter = 0;
|
||||
const webhook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T/B/X",
|
||||
fetch: fake.fetch,
|
||||
clock: () => 1_700_000_000_000 + counter++,
|
||||
rng: { nextUint32: () => 42, nextFloat: () => 0.42 },
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const report = await webhook.send({
|
||||
text: "Block kit",
|
||||
blocks: [{ type: "section", text: { type: "mrkdwn", text: "*Hi*" } }],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SlackWebhook(options: SlackWebhookOptions)`
|
||||
- `send(message: { text: string; blocks?: SlackBlock[] }, signal?: AbortSignal): Promise<SlackDeliveryReport>`
|
||||
- `sendText(text: string, signal?: AbortSignal): Promise<SlackDeliveryReport>`
|
||||
|
||||
Errors thrown: `SlackConfigMissingError`, `SlackTimeoutError`, `SlackAuthError`, `SlackWebhookFailedError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The message-id format is `webhook-<ms>-<counter>-<rngOrCounter>` so even rapid bursts on the same clock tick produce distinct ids. The counter is monotone within a single instance; injecting an `Rng` adds cross-instance uniqueness without coupling to `crypto.randomUUID` (which is universally available but adds an unwanted dependency on the runtime's CSPRNG availability in test environments). The constructor explicitly binds `globalThis.fetch` to `globalThis` when used as default — without the bind, `fetch.call(undefined, ...)` triggers an "Illegal invocation" `TypeError` in some browser builds.
|
||||
@@ -0,0 +1,117 @@
|
||||
# @suaveplan/sms-providers - Universal SMS provider implementations for @suaveplan/sms. Real HTTP clients for Twilio (Basic auth + form-encoded + X-Twilio-Signature HMAC-SHA1), Vonage (HMAC-SHA256 webhook), MessageBird (AccessKey + JWT HS256 webhook), Plivo (Basic auth + X-Plivo-Signature-V3 HMAC-SHA256), and AWS SNS (inline SigV4 + RSA-SHA1 SNS notification verifier). Ships ProviderRegistry, httpRequest over injectable FetchLike, composeAbortSignal, SMS_PROVIDERS_CODES + SmsProvidersError, per-provider mapStatus, parseInbound, parseOptOutKeyword, and five OTel instruments. No provider SDK deps; every HTTP call flows through an injectable FetchLike seam.
|
||||
|
||||
> Real provider clients for `@suaveplan/sms` — Twilio, Vonage, MessageBird, Plivo, AWS SNS. Universal (Bun, Node, browsers, Workers, Deno) over an injectable `FetchLike` seam; no provider SDKs in the dep tree.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [aws-sns](./src/aws-sns/aws-sns.md) — Real AWS SNS SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs `Action=Publish` form bodies to the r…
|
||||
- [aws-sns](./src/aws-sns/aws-sns.md) — Real AWS SNS SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs `Action=Publish` form bodies to the r…
|
||||
- [sigv4](./src/aws-sns/sigv4.md) — Inline AWS Signature V4 signer used by `AwsSnsProvider`. Replaces the `@aws-sdk/signature-v4` dependency tree (15+ MB)…
|
||||
- [crypto](./src/crypto/default-hmac.md) — Owns the package's default `HmacSign` implementation (routed through `globalThis.crypto.subtle`) and the small set of u…
|
||||
- [default-hmac](./src/crypto/default-hmac.md) — Owns the package's default `HmacSign` implementation (routed through `globalThis.crypto.subtle`) and the small set of u…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error-code registry `SMS_PROVIDERS_CODES` and the `SmsProvidersError` discriminated subc…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error-code registry `SMS_PROVIDERS_CODES` and the `SmsProvidersError` discriminated subc…
|
||||
- [http](./src/http/http.md) — Universal HTTP helper that every concrete provider client routes through. Wraps an injectable `FetchLike` seam, se…
|
||||
- [http](./src/http/http.md) — Universal HTTP helper that every concrete provider client routes through. Wraps an injectable `FetchLike` seam, se…
|
||||
- [keywords](./src/keywords/keywords.md) — Universal classifier mapping an inbound SMS body to the canonical `OptOutAction` enum used by `@suaveplan/sms`'s `OptOu…
|
||||
- [keywords](./src/keywords/keywords.md) — Universal classifier mapping an inbound SMS body to the canonical `OptOutAction` enum used by `@suaveplan/sms`'s `OptOu…
|
||||
- [messagebird](./src/messagebird/messagebird.md) — Real MessageBird SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/messages` with `Authorizatio…
|
||||
- [messagebird](./src/messagebird/messagebird.md) — Real MessageBird SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/messages` with `Authorizatio…
|
||||
- [otel](./src/otel/otel.md) — Owns the package's OpenTelemetry instrument set. Five instruments cover the public-surface operations every provider cl…
|
||||
- [otel](./src/otel/otel.md) — Owns the package's OpenTelemetry instrument set. Five instruments cover the public-surface operations every provider cl…
|
||||
- [plivo](./src/plivo/plivo.md) — Real Plivo SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/v1/Account/{AuthId}/Message/` auth…
|
||||
- [plivo](./src/plivo/plivo.md) — Real Plivo SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/v1/Account/{AuthId}/Message/` auth…
|
||||
- [registry](./src/registry/registry.md) — `ProviderRegistry` is a process-local, synchronous index of `SmsProvider` instances keyed by `provider.name`. Sibling p…
|
||||
- [registry](./src/registry/registry.md) — `ProviderRegistry` is a process-local, synchronous index of `SmsProvider` instances keyed by `provider.name`. Sibling p…
|
||||
- [twilio](./src/twilio/twilio.md) — Real Twilio SMS provider implementing the `SmsProvider` contract from `@suaveplan/sms`. Issues authenticated, form-enco…
|
||||
- [twilio](./src/twilio/twilio.md) — Real Twilio SMS provider implementing the `SmsProvider` contract from `@suaveplan/sms`. Issues authenticated, form-enco…
|
||||
- [types](./src/types/provider.md) — Defines the shared structural types every concrete provider class consumes: `FetchLike` (the injectable HTTP seam), `Hm…
|
||||
- [provider](./src/types/provider.md) — Defines the shared structural types every concrete provider class consumes: `FetchLike` (the injectable HTTP seam), `Hm…
|
||||
- [vonage](./src/vonage/vonage.md) — Real Vonage (formerly Nexmo) SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/sms/json` with `…
|
||||
- [vonage](./src/vonage/vonage.md) — Real Vonage (formerly Nexmo) SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/sms/json` with `…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Five real provider clients**, every one implementing the `SmsProvider` contract from `@suaveplan/sms`:
|
||||
- `TwilioProvider` — `POST /Messages.json`, HTTP Basic auth, MMS `MediaUrl[]`, X-Twilio-Signature HMAC-SHA1 webhook verifier.
|
||||
- `VonageProvider` — `POST /sms/json`, body auth, auto `type=unicode`, HMAC-SHA256 webhook verifier.
|
||||
- `MessageBirdProvider` — `AccessKey` auth, JSON body, JWT HS256 webhook verifier with `iat`/`exp`/`nbf` enforcement + optional `payload_hash` binding.
|
||||
- `PlivoProvider` — Basic auth, JSON body, X-Plivo-Signature-V3 HMAC-SHA256 verifier.
|
||||
- `AwsSnsProvider` — inline AWS SigV4-signed `Action=Publish`, RSA-SHA1 SNS notification verifier with injectable callback.
|
||||
- **`ProviderRegistry`** indexes provider instances by name; strict register/lookup, frozen `list()` snapshot.
|
||||
- **`SmsProvidersError`** discriminated subclass of `SuaveplanError` with eight namespaced codes (`SMS_PROVIDERS.HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`, `CONFIG_MISSING`, `SIGNATURE_INVALID`, `INVALID_NUMBER`, `PROVIDER_NOT_REGISTERED`, `PROVIDER_ALREADY_REGISTERED`).
|
||||
- **`parseOptOutKeyword`** universal CTIA/TCPA opt-in/opt-out/help keyword classifier.
|
||||
- **Five OTel instruments** — `send_total`, `webhook_total`, `signature_failures_total`, `send_duration_ms`, `webhook_verify_duration_ms`.
|
||||
- **Real `node:http` round-trip tests** per provider — no mocks; tests bind a server on a random port and inject a `FetchLike` pointing at it.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
AwsSnsProvider,
|
||||
MessageBirdProvider,
|
||||
PlivoProvider,
|
||||
ProviderRegistry,
|
||||
TwilioProvider,
|
||||
VonageProvider,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(new TwilioProvider({ accountSid: "AC…", authToken: "…" }));
|
||||
registry.register(new VonageProvider({ apiKey: "k", apiSecret: "s" }));
|
||||
registry.register(new MessageBirdProvider({ accessKey: "K" }));
|
||||
registry.register(new PlivoProvider({ authId: "A", authToken: "T" }));
|
||||
registry.register(
|
||||
new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
})
|
||||
);
|
||||
|
||||
const twilio = registry.get("twilio");
|
||||
const report = await twilio.send({
|
||||
to: "+15551234567",
|
||||
from: "+15550000000",
|
||||
body: "Hello",
|
||||
});
|
||||
console.log(report.messageId, report.status);
|
||||
```
|
||||
|
||||
## Webhook verification
|
||||
|
||||
Every provider exposes a signature verifier matching its documented scheme:
|
||||
|
||||
```ts
|
||||
// Twilio HMAC-SHA1 over url + sorted form
|
||||
await twilio.verifyWebhookSignature(callbackUrl, params, signature);
|
||||
|
||||
// Vonage HMAC-SHA256 hex over sorted &key=value (excluding sig)
|
||||
await vonage.verifyWebhookSignature(params, sigHex, signatureSecret);
|
||||
|
||||
// MessageBird JWT HS256 with iat/exp/nbf window + payload_hash binding
|
||||
await mb.verifyWebhookSignature(jwt, signingKey, { nowMs, bodyHashHex });
|
||||
|
||||
// Plivo HMAC-SHA256 base64 over url + nonce + sorted key=value,...
|
||||
await plivo.verifyWebhookSignature(url, nonce, params, signature);
|
||||
|
||||
// AWS SNS RSA-SHA1 — pass an injectable verifier callback
|
||||
await sns.verifyNotification(payload, async (pem, msg, sigB64) => {
|
||||
/* createVerify("RSA-SHA1")…verify(...) */
|
||||
return true;
|
||||
});
|
||||
```
|
||||
|
||||
## Tier
|
||||
|
||||
`src/` ships zero `node:*` / `bun:*` / platform imports. Every HTTP call routes through an injectable `FetchLike`; every HMAC routes through an injectable `HmacSign` (defaulting to `globalThis.crypto.subtle`). Tests use real `node:http` + `node:crypto` from under `tests/setup/` and inside `*.test.ts` files only.
|
||||
|
||||
## Pre-publish gate
|
||||
|
||||
```bash
|
||||
bunx turbo lint typecheck test build --filter=@suaveplan/sms-providers
|
||||
bunx turbo test:coverage --filter=@suaveplan/sms-providers # 100/100/100/100
|
||||
```
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# AWS SNS — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real AWS SNS SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs `Action=Publish` form bodies to the regional SNS endpoint (`https://sns.<region>.amazonaws.com/`) signed with inline AWS Signature V4 (see `./sigv4`); SMS-specific message attributes (`AWS.SNS.SMS.SMSType` and `AWS.SNS.SMS.SenderID`) are forwarded when supplied on the provider config. Inbound SNS HTTP/S notifications — subscription confirmation, delivery callbacks, unsubscribe confirmation — are validated through `verifyNotification` which delegates the RSA-SHA1 verification step to an injectable callback so this package never imports `node:crypto`.
|
||||
|
||||
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
|
||||
|
||||
## Features
|
||||
|
||||
- Inline AWS Signature V4 signing (no `@aws-sdk` dep tree).
|
||||
- `AWS.SNS.SMS.SMSType` defaults to `Transactional`; override via `config.smsType: "Promotional"`.
|
||||
- `AWS.SNS.SMS.SenderID` forwarded from the message `from` or the config `senderId`.
|
||||
- Optional `sessionToken` for STS-issued credentials (`X-Amz-Security-Token`).
|
||||
- Status mapping covers `pending` / `success` / `failure` / `optout`.
|
||||
- `verifyNotification(payload, rsaVerify)` validates the documented SNS signing chain — fetches the signing cert URL through the injectable fetch, builds the canonical string-to-sign (different field order for `Notification` vs `SubscriptionConfirmation`/`UnsubscribeConfirmation`), and delegates the RSA verification to the supplied callback.
|
||||
- `parseInbound` discriminates between delivery-receipt notifications (Subject = `"AWS SMS Delivery Receipt"`) and inbound mobile-originated SMS payloads; recognises CTIA opt-out keywords on the inbound body.
|
||||
- OTel — counter + histogram on every send + verify.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AwsSnsProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const sns = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
senderId: "SUAVEPLAN",
|
||||
});
|
||||
const r = await sns.send({ to: "+15551234567", body: "Hello" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AwsSnsProvider } from "@suaveplan/sms-providers";
|
||||
import { createVerify } from "node:crypto";
|
||||
|
||||
const sns = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
sessionToken: "FwoGZXIvYXdz…",
|
||||
smsType: "Transactional",
|
||||
});
|
||||
|
||||
// Node-side RSA-SHA1 verifier callback.
|
||||
const rsaVerify = async (pem: string, msg: string, sigB64: string) =>
|
||||
createVerify("RSA-SHA1").update(msg).end().verify(pem, sigB64, "base64");
|
||||
|
||||
const ok = await sns.verifyNotification(
|
||||
/* payload */ {
|
||||
Type: "Notification",
|
||||
MessageId: "abc",
|
||||
TopicArn: "arn:aws:sns:…",
|
||||
Subject: "AWS SMS Delivery Receipt",
|
||||
Message: JSON.stringify({ status: "SUCCESS" }),
|
||||
Timestamp: new Date().toISOString(),
|
||||
Signature: "<base64>",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL:
|
||||
"https://sns.us-east-1.amazonaws.com/SimpleNotificationService-XXX.pem",
|
||||
},
|
||||
rsaVerify
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `AwsSnsProvider` | class | Implements `SmsProvider`. |
|
||||
| `send(message, options?)` | method | SigV4-signed POST `Action=Publish`. |
|
||||
| `getStatus(id)` | method | Returns a synthetic `queued` report (SNS does not expose per-message status). |
|
||||
| `mapStatus(raw)` | method | Canonical mapping. |
|
||||
| `verifyNotification(payload, rsaVerify)` | method | Delegates RSA-SHA1 verification to the supplied callback. |
|
||||
| `parseInbound(payload)` | method | Discriminates into `InboundSmsEvent`. |
|
||||
| `buildSnsStringToSign(payload)` | helper | Exported for advanced use. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getStatus` deliberately returns a synthetic `queued` report rather than synthesise a fake `delivered` — SNS reports SMS lifecycle exclusively via the configured delivery-status SNS topic (typically wired into CloudWatch Logs in production), and lying about provider behaviour would mislead consumers. The recommended production pipeline is to subscribe to the delivery-status topic, route the notifications back through the provider's `parseInbound`, and feed the resulting `delivery_receipt` events to `@suaveplan/sms`'s `DeliveryTracker`. The `senderId` config overrides the per-message `from`; this matches the AWS console UX where the account-level Sender ID is the production default.
|
||||
@@ -0,0 +1,80 @@
|
||||
# SigV4 — `@suaveplan/sms-providers`
|
||||
|
||||
## Purpose
|
||||
|
||||
Inline AWS Signature V4 signer used by `AwsSnsProvider`. Replaces the `@aws-sdk/signature-v4` dependency tree (15+ MB) with an under-80-LOC implementation of the canonical-request → string-to-sign → signing-key → final-signature chain. HMAC computations are delegated to the injectable `HmacSign` seam from `@suaveplan/sms-providers`, so the signer stays universal and tests can substitute a deterministic adapter.
|
||||
|
||||
The signer returns a fresh headers object containing the input headers plus `Host`, `X-Amz-Date`, `X-Amz-Content-Sha256`, the optional `X-Amz-Security-Token` (when a session token is supplied), and the `Authorization` header in the documented form `AWS4-HMAC-SHA256 Credential=…, SignedHeaders=…, Signature=<64 hex>`.
|
||||
|
||||
## Features
|
||||
|
||||
- 64-character lowercase-hex signature per the AWS spec.
|
||||
- Canonical request built from method + path + canonical headers (lower-cased keys, trimmed values) + signed-headers list + body-sha256 hex.
|
||||
- `dateStamp` (`YYYYMMDD`) and `amzDate` (`YYYYMMDDTHHMMSSZ`) derived from a caller-supplied `Date` so tests can pin the clock and produce stable signatures.
|
||||
- Signing key chain: `kDate → kRegion → kService → kSigning` via HMAC-SHA256 over `AWS4<secret>`, `region`, `service`, `aws4_request`.
|
||||
- Body SHA-256 hashing routed through `globalThis.crypto.subtle.digest("SHA-256", …)` (universal: Bun, browsers, Workers, Deno, Node 19+).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defaultHmac } from "@suaveplan/sms-providers";
|
||||
import { signRequest, sha256OfUtf8 } from "@suaveplan/sms-providers/aws-sns/sigv4";
|
||||
|
||||
const body = "Action=Publish&Message=hi";
|
||||
const out = await signRequest({
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
region: "us-east-1",
|
||||
service: "sns",
|
||||
method: "POST",
|
||||
host: "sns.us-east-1.amazonaws.com",
|
||||
path: "/",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
bodySha256Hex: await sha256OfUtf8(body),
|
||||
now: new Date(),
|
||||
hmac: defaultHmac,
|
||||
});
|
||||
// out.headers.Authorization === "AWS4-HMAC-SHA256 Credential=AKIA…/<dateStamp>/us-east-1/sns/aws4_request, SignedHeaders=…, Signature=<64 hex>"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { signRequest, sha256OfUtf8 } from "@suaveplan/sms-providers/aws-sns/sigv4";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// Drive the signer with a Node-side HMAC adapter and a pinned date so
|
||||
// the resulting Authorization header is byte-stable between test runs.
|
||||
const hmac = async (algo: "SHA-1" | "SHA-256", key: Uint8Array, msg: Uint8Array) =>
|
||||
new Uint8Array(
|
||||
createHmac(algo === "SHA-1" ? "sha1" : "sha256", Buffer.from(key))
|
||||
.update(Buffer.from(msg))
|
||||
.digest()
|
||||
);
|
||||
const out = await signRequest({
|
||||
accessKeyId: "AKIDEXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
region: "us-east-1",
|
||||
service: "sns",
|
||||
method: "POST",
|
||||
host: "sns.us-east-1.amazonaws.com",
|
||||
path: "/",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
bodySha256Hex: await sha256OfUtf8("Action=Publish"),
|
||||
now: new Date("2026-04-24T10:00:00Z"),
|
||||
hmac,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `signRequest(input)` | function | Returns `{ headers }` with the SigV4 signature applied. |
|
||||
| `sha256OfUtf8(s)` | function | SHA-256-of-UTF-8 helper returning lowercase hex. |
|
||||
| `SignRequestInput` | interface | Required inputs to the signer. |
|
||||
| `SignRequestOutput` | interface | Returned headers object. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonical-request newline before the body-sha256 is intentional per the AWS docs (the canonical request ends with `\n<body-sha256>`, no trailing newline). The signed-headers list is computed by lower-casing every input key, sorting, and joining with `;` — this matches the canonical-request header rendering exactly.
|
||||
@@ -0,0 +1,74 @@
|
||||
# crypto — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the package's default `HmacSign` implementation (routed through `globalThis.crypto.subtle`) and the small set of universal codec helpers — `toHex` / `fromHex` / `toBase64` / `fromBase64` / `utf8` / `constantTimeEqual` — that every provider's signature verifier shares. The default HMAC implementation is available in Bun, browsers, Workers, Deno, and Node 19+; callers on older runtimes inject a `node:crypto`-backed adapter on the provider's `hmac` config field.
|
||||
|
||||
Keeping the codec helpers in one place stops every provider from rolling its own hex / base64 routine (which empirically all end up subtly different — different elision sentinels, different uppercasing, different `Buffer` assumptions).
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultHmac` — universal `HmacSign` driven by `globalThis.crypto.subtle`. Supports `SHA-1` (Twilio + AWS SNS verifier) and `SHA-256` (Vonage + MessageBird + Plivo + AWS SigV4).
|
||||
- `toHex` / `fromHex` — lowercase hex round-trip.
|
||||
- `toBase64` / `fromBase64` — standard Base-64 (no URL-safe substitutions; CTIA / SNS / Twilio all expect canonical Base-64).
|
||||
- `utf8(s)` — convenience wrapper for `new TextEncoder().encode(s)`.
|
||||
- `constantTimeEqual(a, b)` — byte-array comparison that is XOR-accumulated to avoid early-exit timing attacks on signature verification.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
constantTimeEqual,
|
||||
defaultHmac,
|
||||
toBase64,
|
||||
utf8,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
const sig = await defaultHmac("SHA-1", utf8("authToken"), utf8("payload"));
|
||||
const ok = constantTimeEqual(sig, expectedSig);
|
||||
console.log(toBase64(sig));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultHmac,
|
||||
toHex,
|
||||
utf8,
|
||||
type HmacSign,
|
||||
} from "@suaveplan/sms-providers";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// Node 18 fallback when subtle.sign is not exposed.
|
||||
const nodeHmac: HmacSign = async (algo, key, msg) =>
|
||||
new Uint8Array(
|
||||
createHmac(algo === "SHA-1" ? "sha1" : "sha256", Buffer.from(key))
|
||||
.update(Buffer.from(msg))
|
||||
.digest()
|
||||
);
|
||||
|
||||
const hmac: HmacSign = typeof globalThis.crypto?.subtle?.sign === "function"
|
||||
? defaultHmac
|
||||
: nodeHmac;
|
||||
|
||||
const sig = toHex(await hmac("SHA-256", utf8("secret"), utf8("payload")));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `defaultHmac` | const | `HmacSign` over `globalThis.crypto.subtle`. |
|
||||
| `toHex(bytes)` / `fromHex(s)` | functions | Lowercase hex codec. |
|
||||
| `toBase64(bytes)` / `fromBase64(s)` | functions | Standard Base-64 codec. |
|
||||
| `utf8(s)` | function | `TextEncoder().encode(s)`. |
|
||||
| `constantTimeEqual(a, b)` | function | XOR-accumulated comparison. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`toHex` builds the string with `String#padStart(2, "0")` per byte rather than the bigint-shift trick to keep the implementation legible. `constantTimeEqual` never short-circuits on length mismatch *during* iteration — the early `length` check is unavoidable but does not leak content. Subtle's `importKey` is called inside `defaultHmac` rather than cached because production callers typically supply a fresh `key` per call.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Error codes — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable, namespaced error-code registry `SMS_PROVIDERS_CODES` and the `SmsProvidersError` discriminated subclass used by every public surface of `@suaveplan/sms-providers`. Every failure path the package can take — a non-2xx HTTP response from a provider, an upstream request timeout, a JSON parse failure on a response body, a missing configuration value, a webhook signature mismatch, an invalid E.164 number, a registry name collision, a missing registry lookup — raises one of these eight codes so downstream loggers, problem+json responders, retry wrappers, and analytics pipelines see a consistent shape.
|
||||
|
||||
Codes are minted through `@suaveplan/error`'s `defineCodes("SMS_PROVIDERS", […])` so they sit in a non-colliding namespace (`SMS_PROVIDERS.*`) distinct from `@suaveplan/sms`'s `SMS.*` codes. The `SmsProvidersError` class extends `SuaveplanError<SmsProvidersErrorCode>` so `instanceof SmsProvidersError` narrows `error.code` to the union; optional `provider`, `httpStatus`, `url`, and `bodyExcerpt` discriminants are carried so consumers can branch without re-parsing the message.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight codes covering every public-surface failure path.
|
||||
- Six factory helpers (`httpError`, `timeoutError`, `parseError`, `configMissing`, `signatureInvalid`, `invalidNumber`, `providerNotRegistered`, `providerAlreadyRegistered`).
|
||||
- Response bodies are truncated to 256 chars on the `bodyExcerpt` discriminant — bounded log volume regardless of payload size.
|
||||
- `cause` is preserved for any wrapped exception (timeout, parse failure) so root-cause traversal still works.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SmsProvidersError,
|
||||
SMS_PROVIDERS_CODES,
|
||||
httpError,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
try {
|
||||
/* call provider */
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof SmsProvidersError &&
|
||||
err.code === SMS_PROVIDERS_CODES.HTTP_ERROR
|
||||
) {
|
||||
console.warn(`HTTP ${err.httpStatus} from ${err.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
const e = httpError("twilio", 422, "https://api.twilio.com/x", "{}", "invalid");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
SmsProvidersError,
|
||||
SMS_PROVIDERS_CODES,
|
||||
signatureInvalid,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
// A custom webhook router that maps `SMS_PROVIDERS.SIGNATURE_INVALID` to a 401.
|
||||
export function verifyOrThrow(
|
||||
provider: string,
|
||||
ok: boolean,
|
||||
reason: string
|
||||
): void {
|
||||
if (!ok) {
|
||||
throw signatureInvalid(provider, reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Branch on every code for a problem+json renderer.
|
||||
function statusFor(err: SmsProvidersError): number {
|
||||
switch (err.code) {
|
||||
case SMS_PROVIDERS_CODES.SIGNATURE_INVALID:
|
||||
return 401;
|
||||
case SMS_PROVIDERS_CODES.CONFIG_MISSING:
|
||||
return 500;
|
||||
case SMS_PROVIDERS_CODES.INVALID_NUMBER:
|
||||
return 400;
|
||||
case SMS_PROVIDERS_CODES.PROVIDER_NOT_REGISTERED:
|
||||
return 404;
|
||||
case SMS_PROVIDERS_CODES.PROVIDER_ALREADY_REGISTERED:
|
||||
return 409;
|
||||
case SMS_PROVIDERS_CODES.TIMEOUT:
|
||||
case SMS_PROVIDERS_CODES.HTTP_ERROR:
|
||||
case SMS_PROVIDERS_CODES.PARSE_ERROR:
|
||||
return err.httpStatus ?? 502;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `SMS_PROVIDERS_CODES` | const record | Frozen map produced by `defineCodes("SMS_PROVIDERS", […])` |
|
||||
| `SmsProvidersErrorCode` | type | Union of every code value |
|
||||
| `SmsProvidersError` | class | Extends `SuaveplanError` with optional `provider` / `httpStatus` / `url` / `bodyExcerpt` |
|
||||
| `httpError(provider, status, url, body, message?)` | factory | `HTTP_ERROR` |
|
||||
| `timeoutError(provider, url, timeoutMs, cause?)` | factory | `TIMEOUT` |
|
||||
| `parseError(provider, url, body, cause)` | factory | `PARSE_ERROR` |
|
||||
| `configMissing(provider, key)` | factory | `CONFIG_MISSING` |
|
||||
| `signatureInvalid(provider, reason)` | factory | `SIGNATURE_INVALID` |
|
||||
| `invalidNumber(provider, number)` | factory | `INVALID_NUMBER` |
|
||||
| `providerNotRegistered(name)` | factory | `PROVIDER_NOT_REGISTERED` |
|
||||
| `providerAlreadyRegistered(name)` | factory | `PROVIDER_ALREADY_REGISTERED` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`bodyExcerpt` truncation keeps the elision sentinel `…` (U+2026) — distinguishable from a literal three-dot ASCII triplet so log scrapers can tell when truncation fired. The factories never construct errors from raw `Error`; they always go through `new SmsProvidersError(...)` so the prototype chain stays intact under transpiler-emitted `__extends` shims.
|
||||
@@ -0,0 +1,71 @@
|
||||
# http — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Universal HTTP helper that every concrete provider client routes through. Wraps an injectable `FetchLike` seam, serialises JSON or form bodies, enforces a per-call timeout via `AbortSignal`, composes the timeout signal with a caller-supplied `signal` so external cancellation also tears the request down, and translates every failure mode into the typed `SmsProvidersError` hierarchy.
|
||||
|
||||
The function lets every provider class stay free of platform-specific HTTP code (no `node-fetch`, no `axios`, no SDK fetch runtimes). Tests inject a recording `FetchLike` (or one that proxies through a real `node:http` server on a random port); production runtime resolves `globalThis.fetch.bind(globalThis)`.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `FetchLike` defaulting to `globalThis.fetch.bind(globalThis)` so the same code runs on Bun, Node, browsers, Workers, and Deno.
|
||||
- Per-call timeout via an internal `AbortController`; composed with `caller.signal` through `composeAbortSignal`.
|
||||
- `body: string` is sent verbatim with `Content-Type: application/x-www-form-urlencoded` (Twilio/Vonage/Plivo/SNS form bodies); any other shape is JSON-stringified with `Content-Type: application/json`. Callers may override either header in `headers`.
|
||||
- JSON detection is `Content-Type`-driven, with a `{`/`[` start-byte fallback so providers that omit the header still parse.
|
||||
- 2xx → `{ status, data }`; non-2xx → `SmsProvidersError(HTTP_ERROR)` with the body excerpted; timeout abort → `TIMEOUT`; parse failure → `PARSE_ERROR`; underlying fetch rejection → `HTTP_ERROR` (status 0).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { httpRequest } from "@suaveplan/sms-providers";
|
||||
|
||||
const { status, data } = await httpRequest<{ sid: string }>({
|
||||
method: "POST",
|
||||
url: "https://api.twilio.com/2010-04-01/Accounts/AC/Messages.json",
|
||||
headers: { Authorization: "Basic …" },
|
||||
body: "To=%2B1&From=%2B1&Body=hi",
|
||||
provider: "twilio",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { composeAbortSignal, httpRequest } from "@suaveplan/sms-providers";
|
||||
|
||||
const userAbort = new AbortController();
|
||||
setTimeout(() => userAbort.abort(new Error("cancelled by user")), 100);
|
||||
|
||||
try {
|
||||
await httpRequest({
|
||||
method: "GET",
|
||||
url: "https://api.example.com/slow",
|
||||
timeoutMs: 30_000,
|
||||
signal: userAbort.signal,
|
||||
provider: "example",
|
||||
});
|
||||
} catch (err) {
|
||||
// SmsProvidersError(TIMEOUT) or HTTP_ERROR(status:0) depending on
|
||||
// which signal fired first.
|
||||
}
|
||||
|
||||
// Compose your own signals when assembling a multi-step provider call.
|
||||
const internal = new AbortController().signal;
|
||||
const composed = composeAbortSignal(internal, userAbort.signal);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `httpRequest<T>(options)` | function | Returns `Promise<ProviderResponse<T>>`; throws `SmsProvidersError` on failure. |
|
||||
| `composeAbortSignal(internal, external?)` | function | Returns a signal that aborts when either input aborts. |
|
||||
| `DEFAULT_TIMEOUT_MS` | private | 30 000 ms. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`composeAbortSignal` re-uses the caller's signal directly when `external` is `undefined`, avoiding a wrapper allocation on the hot path. The timeout abort is differentiated from caller-driven aborts by inspecting `timeoutSignal.aborted` after a thrown `AbortError` — a separate `AbortController` per request guarantees the test is reliable even when the caller's signal also fires.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Keywords — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Universal classifier mapping an inbound SMS body to the canonical `OptOutAction` enum used by `@suaveplan/sms`'s `OptOutRegistry`. CTIA's Short Code Monitoring Handbook (and the parallel TCPA / GDPR opt-out frameworks) mandate that outbound SMS programs recognise STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT as opt-out, START / YES / UNSTOP as opt-in, and HELP / INFO as help-info requests, in every market. Every provider's `parseInbound` runs the raw body through this classifier so the higher-level service layer never has to re-implement the vocabulary.
|
||||
|
||||
## Features
|
||||
|
||||
- Canonical six-keyword opt-out set: `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`.
|
||||
- Canonical three-keyword opt-in set: `START`, `YES`, `UNSTOP`.
|
||||
- Canonical two-keyword help set: `HELP`, `INFO`.
|
||||
- Recognition is case-insensitive and ignores surrounding whitespace — `" Stop "`, `"STOP\n"`, `"stop"` all match.
|
||||
- Multi-word bodies do not match (the canonical CTIA contract is single-keyword); they fall through to `"none"`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseOptOutKeyword } from "@suaveplan/sms-providers";
|
||||
|
||||
const action = parseOptOutKeyword(" Stop ");
|
||||
// action === "opt_out"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseOptOutKeyword,
|
||||
type OptOutAction,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
function handleInbound(from: string, body: string): void {
|
||||
const action: OptOutAction = parseOptOutKeyword(body);
|
||||
switch (action) {
|
||||
case "opt_out":
|
||||
// optOutRegistry.recordOptOut({ phoneNumber: from, optedOutAt: new Date() });
|
||||
return;
|
||||
case "opt_in":
|
||||
// optOutRegistry.recordOptIn({ phoneNumber: from });
|
||||
return;
|
||||
case "help":
|
||||
// outbound auto-reply with HELP text required by CTIA
|
||||
return;
|
||||
case "none":
|
||||
// forward to the conversational handler
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `OptOutAction` | union | `"opt_out" \| "opt_in" \| "help" \| "none"`. |
|
||||
| `parseOptOutKeyword(body)` | function | Returns the classification. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The keyword sets are frozen `Set<string>` instances at module load so lookup is O(1). Trim + upper-case the input once and compare against all three sets in sequence — short-circuiting on the first match means an opt-out check beats opt-in beats help (no overlap exists in the canonical vocabulary; sequencing is for stability rather than correctness).
|
||||
@@ -0,0 +1,58 @@
|
||||
# MessageBird — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real MessageBird SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/messages` with `Authorization: AccessKey <key>`; status fetched via `GET /messages/{id}`. Inbound webhook signature verification follows the MessageBird Signed Webhooks v2 (`Messagebird-Signature-Jwt`) spec — a JWT HS256 carrying `iat`/`exp`/`nbf`/`payload_hash` claims; the verifier checks the timestamp window (default ±5 minutes), re-MACs the canonical `header.payload`, and constant-time compares the base64url-decoded signature.
|
||||
|
||||
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
|
||||
|
||||
## Features
|
||||
|
||||
- `AccessKey` auth header per MessageBird's documented scheme.
|
||||
- JSON body with `recipients[]`, `originator`, `body`.
|
||||
- Status mapping covers `scheduled` / `sent` / `buffered` / `delivered` / `expired` / `delivery_failed`.
|
||||
- JWT HS256 webhook verifier with `iat` / `exp` / `nbf` window enforcement and optional `payload_hash` cross-check.
|
||||
- Injectable `nowMs` + `toleranceMs` so tests pin the clock and the production deployment can widen the freshness window if needed.
|
||||
- `parseInbound` discriminates between inbound messages and DLRs, recognising the standard CTIA opt-out keywords.
|
||||
- OTel — `send_total` + `send_duration_ms` + `signature_failures_total` + `webhook_verify_duration_ms`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MessageBirdProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const mb = new MessageBirdProvider({ accessKey: "test_…" });
|
||||
await mb.send({ to: "+15551234567", from: "Suaveplan", body: "hi" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MessageBirdProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const mb = new MessageBirdProvider({ accessKey: "live_…" });
|
||||
const ok = await mb.verifyWebhookSignature(
|
||||
"<header>.<payload>.<sig>",
|
||||
"shared-signing-key",
|
||||
{ bodyHashHex: "<sha256-hex>", nowMs: Date.now(), toleranceMs: 60_000 }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `MessageBirdProvider` | class | Implements `SmsProvider`. |
|
||||
| `send(message, options?)` | method | `POST /messages`, returns `SmsDeliveryReport`. |
|
||||
| `getStatus(id)` | method | `GET /messages/{id}`. |
|
||||
| `mapStatus(raw)` | method | Canonical mapping. |
|
||||
| `verifyWebhookSignature(token, signingKey, options?)` | method | JWT HS256 verifier; throws `SIGNATURE_INVALID` on failure. |
|
||||
| `parseInbound(body)` | method | Discriminates into `InboundSmsEvent`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The verifier handles both standard Base-64 and base64url (`-`/`_` → `+`/`/`) inputs because MessageBird's JWT uses the URL-safe variant. `payload_hash` is checked only when both the JWT carries it and the caller supplies a `bodyHashHex`; missing on either side is treated as "skip" so callers can disable the body binding when their proxy strips bodies.
|
||||
@@ -0,0 +1,84 @@
|
||||
# OTel — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the package's OpenTelemetry instrument set. Five instruments cover the public-surface operations every provider class performs: outbound send (count + duration), inbound webhook events (count + verification duration), and signature-failure counter. The module exposes typed `recordSend`/`recordWebhook`/`recordSignatureFailure`/`recordSendDuration`/`recordWebhookVerifyDuration` helpers so providers stay free of inline OTel boilerplate, and a `refreshSmsProvidersMeters()` helper that re-resolves every instrument against the current global `MeterProvider` — tests install a fresh in-memory reader, call `refresh`, and then drive the helpers to assert on the captured metric points.
|
||||
|
||||
## Features
|
||||
|
||||
- Exactly five instruments — three counters (`send_total`, `webhook_total`, `signature_failures_total`) and two histograms (`send_duration_ms`, `webhook_verify_duration_ms`).
|
||||
- Every counter carries a `provider` label so multi-provider deployments split cleanly in Grafana / Honeycomb.
|
||||
- `outcome` (`"ok"` / `"error"`) on `send_total` and `webhook_total` lets dashboards compute success rate as `1 - error / total`.
|
||||
- `webhook_total` adds an `event` label (`message_received` | `delivery_receipt` | `opt_in` | `opt_out` | `help`).
|
||||
- Histograms record in `ms`.
|
||||
- `refreshSmsProvidersMeters()` is the test seam — module-load resolves the global meter once, this resets it.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordSend,
|
||||
recordSendDuration,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
/* call provider */
|
||||
recordSend("twilio", "ok");
|
||||
} catch {
|
||||
recordSend("twilio", "error");
|
||||
throw;
|
||||
} finally {
|
||||
recordSendDuration("twilio", Date.now() - t0);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordSignatureFailure,
|
||||
recordWebhook,
|
||||
recordWebhookVerifyDuration,
|
||||
refreshSmsProvidersMeters,
|
||||
} from "@suaveplan/sms-providers";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
// Test-only: swap the global meter for a recording one.
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshSmsProvidersMeters();
|
||||
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
// verifyWebhookSignature(...)
|
||||
} catch (err) {
|
||||
recordSignatureFailure("plivo");
|
||||
throw err;
|
||||
} finally {
|
||||
recordWebhookVerifyDuration("plivo", performance.now() - t0);
|
||||
}
|
||||
|
||||
recordWebhook("vonage", "delivery_receipt", "ok");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `recordSend(provider, outcome)` | function | Counter `sms_providers.send_total`. |
|
||||
| `recordWebhook(provider, event, outcome)` | function | Counter `sms_providers.webhook_total`. |
|
||||
| `recordSignatureFailure(provider)` | function | Counter `sms_providers.signature_failures_total`. |
|
||||
| `recordSendDuration(provider, ms)` | function | Histogram `sms_providers.send_duration_ms`. |
|
||||
| `recordWebhookVerifyDuration(provider, ms)` | function | Histogram `sms_providers.webhook_verify_duration_ms`. |
|
||||
| `refreshSmsProvidersMeters()` | function | Re-resolves every instrument against the current global meter. |
|
||||
| `SendOutcome` / `WebhookOutcome` | type | `"ok" \| "error"`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Instruments are held as `let`-bound module-locals so `refreshSmsProvidersMeters()` can reassign them after `metrics.setGlobalMeterProvider(...)` — re-resolving is necessary because OTel's `Meter` proxy snapshots the active provider at meter-creation time. The instrument names follow the OTel naming convention (`<domain>.<metric>` with `_total` suffix on monotonic counters).
|
||||
@@ -0,0 +1,66 @@
|
||||
# Plivo — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real Plivo SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/v1/Account/{AuthId}/Message/` authenticated via HTTP Basic (`AuthId:AuthToken`); JSON body. Status fetched via `GET .../Message/{uuid}/`. Inbound webhook signature verification follows Plivo's documented `X-Plivo-Signature-V3` scheme — HMAC-SHA256 over the canonical signing string `<url><nonce><sorted form-fields joined as key=value, comma-separated>` with the auth token as the key, Base-64 encoded.
|
||||
|
||||
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
|
||||
|
||||
## Features
|
||||
|
||||
- HTTP Basic auth with the canonical `AuthId:AuthToken` pair.
|
||||
- JSON request body — `src`, `dst`, `text` (and `media_urls`/`type=mms` when MMS).
|
||||
- `getStatus(uuid)` round-trips the documented `.../Message/{uuid}/` resource.
|
||||
- Status mapping covers `queued` / `sent` / `delivered` / `undelivered` / `failed` / `rejected`.
|
||||
- `verifyWebhookSignature(url, nonce, params, sig)` implements the X-Plivo-Signature-V3 canonical string scheme.
|
||||
- `parseInbound` discriminates between inbound messages, delivery receipts, and opt-out/opt-in/help keywords.
|
||||
- OTel — counter + histogram on every send + verify.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PlivoProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const plivo = new PlivoProvider({
|
||||
authId: "MA…",
|
||||
authToken: "…",
|
||||
});
|
||||
const r = await plivo.send({
|
||||
to: "+15551234567",
|
||||
from: "+15559999999",
|
||||
body: "hi",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { PlivoProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const plivo = new PlivoProvider({ authId: "MA…", authToken: "…" });
|
||||
const ok = await plivo.verifyWebhookSignature(
|
||||
"https://your.app/webhooks/plivo",
|
||||
"nonce-from-X-Plivo-Signature-Ma-Nonce-Header",
|
||||
{ From: "+1", To: "+2", Text: "hi", MessageUUID: "uuid" },
|
||||
"<base64 signature>"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `PlivoProvider` | class | Implements `SmsProvider`. |
|
||||
| `send(message, options?)` | method | POST `.../Message/`; returns `SmsDeliveryReport`. |
|
||||
| `getStatus(uuid)` | method | `GET .../Message/{uuid}/`. |
|
||||
| `mapStatus(raw)` | method | Canonical mapping. |
|
||||
| `verifyWebhookSignature(url, nonce, params, sig)` | method | HMAC-SHA256-Base-64 verifier; throws `SIGNATURE_INVALID` on mismatch. |
|
||||
| `parseInbound(params)` | method | Discriminates into `InboundSmsEvent`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`send` defaults the initial status to `"queued"` because Plivo's create response only returns the message UUID; the actual carrier-side state is later reported on the inbound DLR webhook. MMS support flips `type: "mms"` and forwards `mediaUrls` as `media_urls[]` per the documented Plivo convention.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Registry — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ProviderRegistry` is a process-local, synchronous index of `SmsProvider` instances keyed by `provider.name`. Sibling provider classes (`TwilioProvider`, `VonageProvider`, `MessageBirdProvider`, `PlivoProvider`, `AwsSnsProvider`) contribute instances; consumers — typically the `@suaveplan/sms` `SmsService` or a higher-level routing layer such as the campaigns API plugin — register the ones they intend to route to and look them up by string name.
|
||||
|
||||
The registry intentionally has no persistence, no auto-discovery, and no async lifecycle. Those concerns belong to higher-level orchestrators. The registry's contract is small enough to fit in a single file and complete enough that the strict register/lookup semantics (throwing on duplicate names + throwing on missing names) prevent the two most common configuration mistakes silently corrupting routing.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict `register` — throws `SmsProvidersError(PROVIDER_ALREADY_REGISTERED)` on duplicate names.
|
||||
- Strict `get` — throws `SmsProvidersError(PROVIDER_NOT_REGISTERED)` on absent names.
|
||||
- Idempotent `unregister(name)` — returns `true` when a provider was removed, `false` otherwise.
|
||||
- `has(name)` for soft lookup before a `get`.
|
||||
- `list()` returns a frozen, insertion-ordered snapshot — callers cannot mutate the registry through the returned array.
|
||||
- `size` reports the current cardinality.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ProviderRegistry, TwilioProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(new TwilioProvider({
|
||||
accountSid: "ACxxxxx",
|
||||
authToken: "xxxxx",
|
||||
}));
|
||||
|
||||
const twilio = registry.get("twilio");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AwsSnsProvider,
|
||||
MessageBirdProvider,
|
||||
PlivoProvider,
|
||||
ProviderRegistry,
|
||||
TwilioProvider,
|
||||
VonageProvider,
|
||||
} from "@suaveplan/sms-providers";
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
for (const p of [
|
||||
new TwilioProvider({ accountSid: "AC", authToken: "t" }),
|
||||
new VonageProvider({ apiKey: "k", apiSecret: "s" }),
|
||||
new MessageBirdProvider({ accessKey: "K" }),
|
||||
new PlivoProvider({ authId: "A", authToken: "T" }),
|
||||
new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
}),
|
||||
]) {
|
||||
registry.register(p);
|
||||
}
|
||||
|
||||
function chooseProvider(prefix: string): string {
|
||||
if (prefix.startsWith("+1")) {
|
||||
return "twilio";
|
||||
}
|
||||
if (prefix.startsWith("+44")) {
|
||||
return "vonage";
|
||||
}
|
||||
return "aws-sns";
|
||||
}
|
||||
|
||||
const provider = registry.get(chooseProvider("+15551234567"));
|
||||
await provider.send({ to: "+15551234567", body: "hi", from: "+15550000000" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `ProviderRegistry` | class | Synchronous Map-backed registry. |
|
||||
| `register(p)` | method | Throws on duplicate `p.name`. |
|
||||
| `unregister(name)` | method | Returns `true` if removed. |
|
||||
| `get(name)` | method | Throws on missing `name`. |
|
||||
| `has(name)` | method | Boolean. |
|
||||
| `list()` | method | Frozen, insertion-ordered `readonly SmsProvider[]`. |
|
||||
| `size` | getter | `number`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry holds a `Map<string, SmsProvider>` (insertion-ordered by spec). `list()` freezes the returned array so a caller mutating it cannot retroactively remove a provider; the underlying `Map` retains insertion order even after `unregister(name)`.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Twilio — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real Twilio SMS provider implementing the `SmsProvider` contract from `@suaveplan/sms`. Issues authenticated, form-encoded HTTP requests against Twilio's documented Messages API (`POST /2010-04-01/Accounts/{AccountSid}/Messages.json`), normalises Twilio's status vocabulary into the canonical `SmsDeliveryStatus`, fetches delivery status via the corresponding `GET`, and verifies inbound webhooks per Twilio's `X-Twilio-Signature` HMAC-SHA1 scheme. MMS `MediaUrl[]` is forwarded as repeated `MediaUrl` form parameters per Twilio's wire convention.
|
||||
|
||||
The class holds no in-flight state: every `send` issues exactly one HTTP request through the injectable `FetchLike` seam. Tests bind a `node:http` server on a random port and inject a `fetch` adapter pointing at it; production resolves `globalThis.fetch.bind(globalThis)`.
|
||||
|
||||
## Features
|
||||
|
||||
- HTTP Basic auth (`Authorization: Basic base64(AccountSid:AuthToken)`).
|
||||
- Form-encoded body via `URLSearchParams` — Twilio rejects JSON bodies.
|
||||
- MMS `MediaUrl[]` forwarded as repeated `MediaUrl` params.
|
||||
- Canonical status mapping (`queued`, `sending`, `sent`, `delivered`, `undelivered`, `failed`, `canceled`, `receiving`, `received`, `accepted`, `scheduled`).
|
||||
- `getStatus(sid)` round-trips the same auth + URL convention.
|
||||
- `verifyWebhookSignature(url, params, signature)` implements the documented HMAC-SHA1-over-sorted-form scheme; mismatched signatures throw `SmsProvidersError(SIGNATURE_INVALID)` and increment `sms_providers.signature_failures_total{provider="twilio"}`.
|
||||
- `parseInbound(params)` discriminates between message bodies, delivery receipts, and opt-in/opt-out/help keywords using the canonical `parseOptOutKeyword` classifier.
|
||||
- OTel — every `send` is counted (`send_total`) and timed (`send_duration_ms`); every signature verify is timed (`webhook_verify_duration_ms`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TwilioProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const twilio = new TwilioProvider({
|
||||
accountSid: "AC…",
|
||||
authToken: "…",
|
||||
});
|
||||
const report = await twilio.send({
|
||||
to: "+15551234567",
|
||||
from: "+15559999999",
|
||||
body: "Hello",
|
||||
});
|
||||
console.log(report.messageId, report.status);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TwilioProvider, type FetchLike } from "@suaveplan/sms-providers";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// Stand up a real HTTP server, point Twilio's FetchLike at it.
|
||||
const server = createServer((req, res) => {
|
||||
res.writeHead(201, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ sid: "SMxxx", status: "queued", to: "+1" }));
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const { port } = server.address() as { port: number };
|
||||
const fetchImpl: FetchLike = async (url, init) =>
|
||||
globalThis.fetch(url.replace("https://api.twilio.com", `http://127.0.0.1:${port}`), init);
|
||||
|
||||
const twilio = new TwilioProvider({
|
||||
accountSid: "AC1",
|
||||
authToken: "tok",
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
const r = await twilio.send({
|
||||
to: "+15551234567",
|
||||
from: "+15550000000",
|
||||
body: "Hi",
|
||||
mediaUrls: ["https://cdn/example.png"],
|
||||
} as Parameters<typeof twilio.send>[0]);
|
||||
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `TwilioProvider` | class | Implements `SmsProvider` from `@suaveplan/sms`. |
|
||||
| `send(message, options?)` | method | Issues the POST and returns an `SmsDeliveryReport`. |
|
||||
| `getStatus(sid)` | method | Round-trips a `GET` for the message resource. |
|
||||
| `mapStatus(raw)` | method | Maps a Twilio status string to `SmsDeliveryStatus`. |
|
||||
| `verifyWebhookSignature(url, params, sig)` | method | HMAC-SHA1 over `url + sortedKey1 + value1 + …`; throws `SIGNATURE_INVALID` on mismatch. |
|
||||
| `parseInbound(params)` | method | Discriminates into the `InboundSmsEvent` union. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `baseUrl` strips any trailing `/` so callers may pass either form. `accountSid` and `authToken` are validated at construction time so credential omissions surface immediately rather than on first call. MMS `mediaUrls` are read off the canonical `OutboundMessage` shape — `SmsMessage & OutboundExtras` — so the call site can pass `mediaUrls` without breaking the `SmsProvider#send` signature.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Types — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the shared structural types every concrete provider class consumes: `FetchLike` (the injectable HTTP seam), `HmacSign` (the injectable HMAC seam), `HttpOptions` + `ProviderResponse<T>` (the wire shape of every request and response that flows through the package's `httpRequest` helper), the per-provider config interfaces, the `InboundSmsEvent` discriminated union covering the four canonical inbound webhook events, and the `OutboundMessage` composition extending the canonical `SmsMessage` from `@suaveplan/sms` with provider extras such as MMS `mediaUrls`.
|
||||
|
||||
Every type here is intentionally types-only at runtime — the file ships zero JavaScript output. Provider classes import these types via the package barrel so downstream consumers can also build their own provider adapters against the same shape without depending on any of the concrete provider files.
|
||||
|
||||
## Features
|
||||
|
||||
- `FetchLike` mirrors the global `fetch` signature so any provider works in Bun, Node, browsers, Workers, and Deno without a platform polyfill.
|
||||
- `HmacSign` exposes the HMAC primitive used by every signature verifier without locking the package to `node:crypto` or `WebCrypto`; tests inject a `node:crypto`-backed adapter, production code on Bun/Workers resolves the default `globalThis.crypto.subtle`-backed adapter.
|
||||
- Per-provider configs (`TwilioConfig`, `VonageConfig`, `MessageBirdConfig`, `PlivoConfig`, `AwsSnsConfig`) extend `ProviderCommonOptions` so testing seams (`fetch`, `hmac`, `signal`, `timeoutMs`, `baseUrl`, `clock`) are uniform.
|
||||
- `InboundSmsEvent` is a discriminated union — `message_received | delivery_receipt | opt_out | opt_in | help` — so consumers can switch on `event.type` without re-validating fields.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { TwilioConfig, FetchLike } from "@suaveplan/sms-providers";
|
||||
|
||||
const fetchImpl: FetchLike = (url, init) => globalThis.fetch(url, init);
|
||||
const config: TwilioConfig = {
|
||||
accountSid: "ACxxxxx",
|
||||
authToken: "xxxxx",
|
||||
fetch: fetchImpl,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AwsSnsConfig,
|
||||
HmacSign,
|
||||
InboundSmsEvent,
|
||||
} from "@suaveplan/sms-providers";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
const hmac: HmacSign = async (algo, key, msg) =>
|
||||
new Uint8Array(
|
||||
createHmac(algo === "SHA-1" ? "sha1" : "sha256", Buffer.from(key))
|
||||
.update(Buffer.from(msg))
|
||||
.digest()
|
||||
);
|
||||
|
||||
const cfg: AwsSnsConfig = {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
hmac,
|
||||
smsType: "Transactional",
|
||||
};
|
||||
|
||||
function handle(evt: InboundSmsEvent): void {
|
||||
switch (evt.type) {
|
||||
case "opt_out":
|
||||
return;
|
||||
case "delivery_receipt":
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `FetchLike` | type | `(url, init?) => Promise<Response>` |
|
||||
| `HmacSign` | type | `(alg, key, msg) => Promise<Uint8Array>` |
|
||||
| `HttpOptions` | interface | Request shape consumed by `httpRequest` |
|
||||
| `ProviderResponse<T>` | interface | `{ status, data }` |
|
||||
| `ProviderCommonOptions` | interface | Shared seams + overrides |
|
||||
| `TwilioConfig` / `VonageConfig` / `MessageBirdConfig` / `PlivoConfig` / `AwsSnsConfig` | interface | Per-provider configs |
|
||||
| `InboundSmsEvent` | union | Discriminated webhook event |
|
||||
| `OutboundMessage` | alias | `SmsMessage & OutboundExtras` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The file exports types only — there is no runtime emission. Build tooling excludes the whole `types/` directory's index from coverage thresholds (per `vitest.config.ts`'s `src/**/index.ts` exclusion). The package barrel re-exports every type by name (rule 10: no `export *`).
|
||||
@@ -0,0 +1,71 @@
|
||||
# Vonage — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real Vonage (formerly Nexmo) SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/sms/json` with `api_key` + `api_secret` carried in the form body (the documented default authentication mode), auto-selecting `type=unicode` when the body contains a non-ASCII codepoint and `type=text` otherwise. Sender (`from`) supports alphanumeric IDs (up to 11 chars), short codes, and long codes — Vonage accepts the raw string and we pass it through verbatim. Status is fetched via `GET /search/message`. Webhook signature verification follows Vonage's documented HMAC-SHA256 scheme over the sorted form fields excluding the `sig` parameter.
|
||||
|
||||
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
|
||||
|
||||
## Features
|
||||
|
||||
- Form-body auth (`api_key`, `api_secret`) — matches Vonage's documented default.
|
||||
- Auto `type=unicode` vs `type=text` based on whether the body contains a non-ASCII codepoint.
|
||||
- Sender field forwarded verbatim (alpha / short code / long code all supported).
|
||||
- `getStatus(id)` round-trips `GET /search/message?id=…` with the same auth pattern.
|
||||
- `mapStatus` covers `submitted` / `buffered` / `accepted` / `delivered` / `expired` / `failed` / `rejected`.
|
||||
- `verifyWebhookSignature(params, signature, signatureSecret)` — HMAC-SHA256 over `&key=value&…` for sorted keys (excluding `sig`); lowercase-hex comparison in constant time.
|
||||
- `parseInbound(params)` discriminates between inbound messages and DLR (delivery-receipt) payloads.
|
||||
- OTel — counter + histogram for every send + verify.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { VonageProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const vonage = new VonageProvider({
|
||||
apiKey: "vonage_api_key",
|
||||
apiSecret: "vonage_api_secret",
|
||||
});
|
||||
const r = await vonage.send({
|
||||
to: "447700900000",
|
||||
from: "Suaveplan",
|
||||
body: "Hello",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { VonageProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const vonage = new VonageProvider({
|
||||
apiKey: "k",
|
||||
apiSecret: "s",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const ok = await vonage.verifyWebhookSignature(
|
||||
{ messageId: "0A0000001", text: "hi", sig: "<hex>" },
|
||||
"<hex>",
|
||||
"signatureSecretSharedWithVonage"
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `VonageProvider` | class | Implements `SmsProvider`. |
|
||||
| `send(message, options?)` | method | POST `/sms/json`, returns `SmsDeliveryReport`. |
|
||||
| `getStatus(id)` | method | `GET /search/message?id=…`. |
|
||||
| `mapStatus(raw)` | method | Canonical mapping. |
|
||||
| `verifyWebhookSignature(params, sig, signatureSecret)` | method | HMAC-SHA256 hex verifier; throws `SIGNATURE_INVALID` on mismatch. |
|
||||
| `parseInbound(params)` | method | Discriminates into `InboundSmsEvent`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Vonage strips the leading `+` from E.164 `to` numbers on submission, so the implementation `replace(/^\+/, "")` once before form-encoding. The signature payload is the documented `&{k}={v}&…` shape (note the leading `&`), built from a stable lexicographic key sort with `sig` excluded.
|
||||
@@ -0,0 +1,133 @@
|
||||
# @suaveplan/sms - Universal SMS core: provider-agnostic send pipeline with E.164 validation, Zod boundaries, template rendering, opt-out registry, token-bucket rate limiter, exponential-backoff retry, message scheduler, bounded delivery-tracker LRU cache, typed EventBus, legacy on/off mirror, in-memory test provider, four OTel instruments, thirteen SMS.* error codes via defineCodes, and back-compat for upstream SmsError + SMS_ERROR_METADATA.
|
||||
|
||||
Universal SMS core: provider-agnostic send pipeline with strict
|
||||
E.164 validation, Zod boundaries, template rendering, opt-out
|
||||
registry, token-bucket rate limiter, exponential-backoff retry with
|
||||
abort propagation, message scheduling, bounded LRU delivery tracker,
|
||||
typed `EventBus` + legacy `on/off/emit` mirror, an in-memory test
|
||||
provider, four OpenTelemetry instruments, thirteen `SMS.*` error
|
||||
codes via `defineCodes`, and full back-compat for the upstream
|
||||
`SmsError` class and `SMS_ERROR_METADATA` table.
|
||||
|
||||
This package is the SMS core. Concrete provider integrations
|
||||
(Twilio, Vonage, AWS SNS, etc.) ship in `@suaveplan/sms-providers`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [delivery-tracker](./src/delivery-tracker/delivery-tracker.md) — `delivery-tracker` is the bounded LRU cache of `SmsDeliveryReport` records that `SmsService` consults for delivery stat…
|
||||
- [delivery-tracker](./src/delivery-tracker/delivery-tracker.md) — `delivery-tracker` is the bounded LRU cache of `SmsDeliveryReport` records that `SmsService` consults for delivery stat…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the canonical `SMS.*` registry for `@suaveplan/sms`. Every error surfaced by the package —…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the canonical `SMS.*` registry for `@suaveplan/sms`. Every error surfaced by the package —…
|
||||
- [in-memory-provider](./src/in-memory-provider/in-memory-provider.md) — `InMemorySmsProvider` is the production-quality in-process `SmsProvider` implementation that ships with `@suaveplan/sms…
|
||||
- [in-memory-provider](./src/in-memory-provider/in-memory-provider.md) — `InMemorySmsProvider` is the production-quality in-process `SmsProvider` implementation that ships with `@suaveplan/sms…
|
||||
- [legacy-errors](./src/legacy-errors/legacy-errors.md) — `legacy-errors` preserves the upstream `@suaveplan/sms` v1 public surface — the `SmsError` class, the `SmsErrorCode` st…
|
||||
- [legacy-errors](./src/legacy-errors/legacy-errors.md) — `legacy-errors` preserves the upstream `@suaveplan/sms` v1 public surface — the `SmsError` class, the `SmsErrorCode` st…
|
||||
- [legacy-metadata](./src/legacy-metadata/legacy-metadata.md) — `legacy-metadata` preserves the upstream `SMS_ERROR_METADATA` table — the per-code mapping of `httpStatus`, `severity`,…
|
||||
- [legacy-metadata](./src/legacy-metadata/legacy-metadata.md) — `legacy-metadata` preserves the upstream `SMS_ERROR_METADATA` table — the per-code mapping of `httpStatus`, `severity`,…
|
||||
- [opt-out](./src/opt-out/opt-out.md) — `opt-out` is the in-memory opt-out registry that `SmsService` consults before every outbound message. Recipients on the…
|
||||
- [opt-out](./src/opt-out/opt-out.md) — `opt-out` is the in-memory opt-out registry that `SmsService` consults before every outbound message. Recipients on the…
|
||||
- [otel](./src/otel/metrics.md) — `otel/metrics` is the OpenTelemetry instrumentation layer for `@suaveplan/sms`. It owns four meter instruments — three…
|
||||
- [metrics](./src/otel/metrics.md) — `otel/metrics` is the OpenTelemetry instrumentation layer for `@suaveplan/sms`. It owns four meter instruments — three…
|
||||
- [rate-limiter](./src/rate-limiter/rate-limiter.md) — `rate-limiter` is the token-bucket throttle that `SmsService` consults before every outbound send. The limiter is keyed…
|
||||
- [rate-limiter](./src/rate-limiter/rate-limiter.md) — `rate-limiter` is the token-bucket throttle that `SmsService` consults before every outbound send. The limiter is keyed…
|
||||
- [retry](./src/retry/retry.md) — `retry` is the exponential-backoff retry policy used by `SmsService.send` to absorb transient provider failures. The po…
|
||||
- [retry](./src/retry/retry.md) — `retry` is the exponential-backoff retry policy used by `SmsService.send` to absorb transient provider failures. The po…
|
||||
- [routes](./src/routes/problem-json.md) — `routes/problem-json` is the RFC 7807 `application/problem+json` response helper for hosts wiring `@suaveplan/sms` erro…
|
||||
- [problem-json](./src/routes/problem-json.md) — `routes/problem-json` is the RFC 7807 `application/problem+json` response helper for hosts wiring `@suaveplan/sms` erro…
|
||||
- [scheduler](./src/scheduler/scheduler.md) — `scheduler` provides `MessageScheduler` — the minimal timer seam that `SmsService.scheduleAt` uses to fire a delayed de…
|
||||
- [scheduler](./src/scheduler/scheduler.md) — `scheduler` provides `MessageScheduler` — the minimal timer seam that `SmsService.scheduleAt` uses to fire a delayed de…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas` is the Zod boundary for every public input that `@suaveplan/sms` exposes. The schemas mirror the public type…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas` is the Zod boundary for every public input that `@suaveplan/sms` exposes. The schemas mirror the public type…
|
||||
- [bus-map](./src/service/bus-map.md) — `bus-map` declares `SmsBusMap` — the single-payload topic contract used by `SmsService.bus` (an `EventBus<SmsBusMap>`).…
|
||||
- [legacy-emitter](./src/service/legacy-emitter.md) — `LegacyEmitter` is the back-compat shim that preserves the upstream `@suaveplan/sms` v1 `on/off/emit` listener API on t…
|
||||
- [sms-service](./src/service/sms-service.md) — `SmsService` is the provider-agnostic orchestrator that ties together every primitive in `@suaveplan/sms`. A single `se…
|
||||
- [templates](./src/templates/templates.md) — `templates` is the in-process template registry and renderer used by `SmsService` to expand `{{variable}}` placeholders…
|
||||
- [templates](./src/templates/templates.md) — `templates` is the in-process template registry and renderer used by `SmsService` to expand `{{variable}}` placeholders…
|
||||
- [validation](./src/validation/phone.md) — `validation/phone` is the strict-E.164 phone number validator used by every public input boundary in `@suaveplan/sms`.…
|
||||
- [phone](./src/validation/phone.md) — `validation/phone` is the strict-E.164 phone number validator used by every public input boundary in `@suaveplan/sms`.…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Highlights
|
||||
|
||||
- **`SmsService`** — orchestrates the full send pipeline: abort
|
||||
check → provider check → E.164 validation → opt-out check →
|
||||
rate-limit → template render → retry → provider send → delivery
|
||||
tracker ingest → OTel + bus emit.
|
||||
- **`InMemorySmsProvider`** — production-quality in-process
|
||||
`SmsProvider` for tests and demos; records every sent message,
|
||||
supports configurable failure predicates and status transitions.
|
||||
- **`TemplateRegistry`** + `renderTemplate` — `{{var}}` substitution
|
||||
with template-level and call-level `defaults` maps.
|
||||
- **`OptOutRegistry`** — channel-scoped opt-out / opt-in with bulk
|
||||
import/export.
|
||||
- **`RateLimiter`** — token bucket per `(channel, recipient)` key
|
||||
with injectable `Clock`.
|
||||
- **`RetryPolicy`** — exponential backoff with full jitter,
|
||||
AbortSignal-aware, injectable `Timers` seam.
|
||||
- **`MessageScheduler`** — `scheduleAt(date, fn)` with cancel
|
||||
handle, injectable timers + clock.
|
||||
- **`DeliveryTracker`** — bounded LRU receipt cache with provider
|
||||
fall-back via `lookupOrFetch`.
|
||||
- **Typed `EventBus`** on `service.bus` plus the upstream
|
||||
`on/off/emit` API mirroring against it.
|
||||
- **Four OTel instruments**: `sms.sent_total`, `sms.failed_total`,
|
||||
`sms.retry_total`, `sms.send_duration_ms`.
|
||||
- **Thirteen `SMS.*` error codes** via `defineCodes("SMS", [...])`
|
||||
plus the upstream `SmsError` legacy class preserved verbatim for
|
||||
back-compat.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/sms
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
InMemorySmsProvider,
|
||||
SmsService,
|
||||
} from "@suaveplan/sms";
|
||||
|
||||
const provider = new InMemorySmsProvider();
|
||||
const service = new SmsService({ provider });
|
||||
|
||||
service.registerTemplate({
|
||||
id: "welcome",
|
||||
body: "Hi {{name}}!",
|
||||
variables: ["name"],
|
||||
});
|
||||
|
||||
service.bus.on("sms.sent", (report) => {
|
||||
console.log("delivered", report.messageId, "→", report.to);
|
||||
});
|
||||
|
||||
await service.send({
|
||||
to: "+15551234567",
|
||||
body: "ignored when templateId is set",
|
||||
templateId: "welcome",
|
||||
templateVars: { name: "Alice" },
|
||||
});
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `defineCodes("SMS", [...])`, leaf classes
|
||||
- [`legacy-errors/legacy-errors.md`](./src/legacy-errors/legacy-errors.md) — upstream `SmsError` back-compat
|
||||
- [`legacy-metadata/legacy-metadata.md`](./src/legacy-metadata/legacy-metadata.md) — `SMS_ERROR_METADATA` table
|
||||
- [`schemas/schemas.md`](./src/schemas/schemas.md) — Zod boundary parsers
|
||||
- [`validation/phone.md`](./src/validation/phone.md) — strict E.164 validator
|
||||
- [`templates/templates.md`](./src/templates/templates.md) — `TemplateRegistry`, `renderTemplate`
|
||||
- [`opt-out/opt-out.md`](./src/opt-out/opt-out.md) — `OptOutRegistry`
|
||||
- [`rate-limiter/rate-limiter.md`](./src/rate-limiter/rate-limiter.md) — token-bucket limiter
|
||||
- [`retry/retry.md`](./src/retry/retry.md) — `RetryPolicy`
|
||||
- [`scheduler/scheduler.md`](./src/scheduler/scheduler.md) — `MessageScheduler`
|
||||
- [`delivery-tracker/delivery-tracker.md`](./src/delivery-tracker/delivery-tracker.md) — bounded LRU receipt cache
|
||||
- [`in-memory-provider/in-memory-provider.md`](./src/in-memory-provider/in-memory-provider.md) — `InMemorySmsProvider`
|
||||
- [`service/sms-service.md`](./src/service/sms-service.md) — orchestrator
|
||||
- [`service/legacy-emitter.md`](./src/service/legacy-emitter.md) — legacy `on/off/emit` mirror
|
||||
- [`routes/problem-json.md`](./src/routes/problem-json.md) — RFC 7807 Hono helper
|
||||
- [`otel/metrics.md`](./src/otel/metrics.md) — OpenTelemetry instruments
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# `delivery-tracker`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`delivery-tracker` is the bounded LRU cache of
|
||||
`SmsDeliveryReport` records that `SmsService` consults for
|
||||
delivery status. Receipts may arrive via webhook ingest
|
||||
(`tracker.ingest(report)`) or be lazily fetched from the
|
||||
configured provider via `tracker.lookupOrFetch(messageId,
|
||||
provider)`. The cache is bounded so a long-running service does
|
||||
not retain every receipt indefinitely.
|
||||
|
||||
When no provider is supplied and the cache misses,
|
||||
`lookupOrFetch` throws `SmsReceiptNotFoundError` (code
|
||||
`SMS.RECEIPT_NOT_FOUND`) so callers can distinguish a miss from
|
||||
a remote provider error.
|
||||
|
||||
## Features
|
||||
|
||||
- LRU cache keyed by `messageId`, bounded by `maxEntries`
|
||||
(default 1024).
|
||||
- `ingest(report)` — record / refresh a receipt; oldest entry
|
||||
evicts on overflow.
|
||||
- `getStatus(messageId)` — synchronous cache read; refreshes
|
||||
LRU order on hit.
|
||||
- `lookupOrFetch(messageId, provider?)` — cache-first, falls
|
||||
back to `provider.getStatus`; result is cached on success.
|
||||
- `size()` and `clear()` for observation / reset.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DeliveryTracker } from "@suaveplan/sms/delivery-tracker";
|
||||
|
||||
const tracker = new DeliveryTracker();
|
||||
tracker.ingest({
|
||||
messageId: "m1",
|
||||
to: "+15551234567",
|
||||
status: "delivered",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(tracker.getStatus("m1"));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { DeliveryTracker } from "@suaveplan/sms/delivery-tracker";
|
||||
import { InMemorySmsProvider } from "@suaveplan/sms/in-memory-provider";
|
||||
|
||||
const tracker = new DeliveryTracker({ maxEntries: 4 });
|
||||
const provider = new InMemorySmsProvider();
|
||||
|
||||
// Lazy fetch — first call hits the provider, second hits cache.
|
||||
const fromProvider = await tracker.lookupOrFetch("m1", provider);
|
||||
const fromCache = await tracker.lookupOrFetch("m1", provider);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class DeliveryTracker`
|
||||
- `constructor(options?: DeliveryTrackerOptions)`
|
||||
- `ingest(report: SmsDeliveryReport): void`
|
||||
- `getStatus(messageId: string): SmsDeliveryReport | undefined`
|
||||
- `lookupOrFetch(messageId: string, provider?: SmsProvider):
|
||||
Promise<SmsDeliveryReport>`
|
||||
- `size(): number`
|
||||
- `clear(): void`
|
||||
- `interface DeliveryTrackerOptions`
|
||||
- `maxEntries?: number` (default 1024, must be ≥ 1)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation uses a plain `Map` and exploits JavaScript's
|
||||
insertion-ordered iteration to maintain LRU order: on each hit
|
||||
we `delete` then `set` the entry, pushing it to the back. On
|
||||
`ingest`, an existing entry is deleted before re-insertion to
|
||||
keep order canonical. Eviction reads the first key in the
|
||||
iterator (the oldest entry) and deletes it. `lookupOrFetch`
|
||||
catches no provider errors — they propagate to the caller so a
|
||||
network failure on receipt fetch isn't silently swallowed.
|
||||
@@ -0,0 +1,110 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The error-codes module owns the canonical `SMS.*` registry for
|
||||
`@suaveplan/sms`. Every error surfaced by the package — invalid
|
||||
phone number, missing provider, exhausted retries, opted-out
|
||||
recipient, rate limit, scheduled-message cancellation, abort —
|
||||
carries one of the thirteen stable codes registered here. This
|
||||
keeps downstream consumers (loggers, problem+json responders,
|
||||
retry wrappers, dashboards) free from re-implementing the
|
||||
classifier.
|
||||
|
||||
The thirteen codes are declared with the
|
||||
`defineCodes("SMS", [...])` helper, which produces a frozen
|
||||
`Namespace.Reason` string union. Each code has a dedicated
|
||||
leaf-error class extending the package-local abstract
|
||||
`BaseSmsError` (itself a thin `SuaveplanError<SmsCode>`
|
||||
subclass), so consumers can `instanceof` the class as well as
|
||||
match on `.code`.
|
||||
|
||||
## Features
|
||||
|
||||
- Thirteen `SMS.*` codes: `INVALID_NUMBER`, `INVALID_CONFIG`,
|
||||
`PROVIDER_NOT_CONFIGURED`, `PROVIDER_ERROR`, `SEND_FAILED`,
|
||||
`TEMPLATE_NOT_FOUND`, `RECIPIENT_OPTED_OUT`, `RATE_LIMITED`,
|
||||
`FORBIDDEN`, `CONFIG_MISSING`, `SCHEDULE_CANCELLED`, `ABORTED`,
|
||||
`RECEIPT_NOT_FOUND`.
|
||||
- One leaf class per code, all extending `BaseSmsError`.
|
||||
- `BaseOpts`-typed constructors that accept `message`,
|
||||
`context`, `cause`, `traceId`, with `pickOpts` stripping
|
||||
undefined keys so the `exactOptionalPropertyTypes`
|
||||
contract holds.
|
||||
- `SmsInvalidConfigError` merges Zod issues into
|
||||
`context.issues`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SMS_CODES,
|
||||
SmsRateLimitedError,
|
||||
} from "@suaveplan/sms/error-codes";
|
||||
|
||||
try {
|
||||
throw new SmsRateLimitedError({
|
||||
context: { phoneNumber: "+15551234567" },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof SmsRateLimitedError) {
|
||||
console.log(err.code === SMS_CODES.RATE_LIMITED);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
BaseSmsError,
|
||||
SmsInvalidConfigError,
|
||||
} from "@suaveplan/sms/error-codes";
|
||||
|
||||
function classifyAndLog(err: unknown): void {
|
||||
if (err instanceof SmsInvalidConfigError) {
|
||||
for (const issue of (err.context?.issues ?? []) as ReadonlyArray<
|
||||
Record<string, unknown>
|
||||
>) {
|
||||
console.error("zod issue", issue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (err instanceof BaseSmsError) {
|
||||
console.error(`${err.code}: ${err.message}`, err.context ?? {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SMS_CODES: Readonly<Record<Reason, "SMS.Reason">>` — frozen
|
||||
registry returned from `defineCodes("SMS", [...])`.
|
||||
- `SmsCode = (typeof SMS_CODES)[keyof typeof SMS_CODES]` — union
|
||||
of every fully-qualified code.
|
||||
- `abstract class BaseSmsError extends SuaveplanError<SmsCode>` —
|
||||
base class for every typed leaf error.
|
||||
- Leaf classes (each takes a `BaseOpts` argument and forwards a
|
||||
default messageKey for the i18n resolver):
|
||||
`SmsInvalidNumberError`, `SmsInvalidConfigError`,
|
||||
`SmsProviderNotConfiguredError`, `SmsProviderError`,
|
||||
`SmsSendFailedError`, `SmsTemplateNotFoundError`,
|
||||
`SmsRecipientOptedOutError`, `SmsRateLimitedError`,
|
||||
`SmsForbiddenError`, `SmsConfigMissingError`,
|
||||
`SmsScheduleCancelledError`, `SmsAbortedError`,
|
||||
`SmsReceiptNotFoundError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The leaf-class pattern (one class per code) was chosen over a
|
||||
single generic class so callers can `instanceof Xxx` to discriminate
|
||||
without inspecting `.code`. The `pickOpts` helper guards against
|
||||
the `exactOptionalPropertyTypes` contract — copying a
|
||||
`cause: undefined` into the super options table would fail to
|
||||
typecheck against `SuaveplanErrorOptions`. Codes are reserved at
|
||||
module load via `defineCodes` (which returns a frozen object) so
|
||||
the registry cannot drift across module reloads.
|
||||
@@ -0,0 +1,101 @@
|
||||
# `in-memory-provider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`InMemorySmsProvider` is the production-quality in-process
|
||||
`SmsProvider` implementation that ships with `@suaveplan/sms`.
|
||||
It satisfies the abstract `SmsProvider` contract — `name`,
|
||||
`send`, `getStatus` — by recording every message into an
|
||||
in-memory log and returning configurable delivery receipts. It
|
||||
is the canonical test double for integration tests, demo apps,
|
||||
and host applications that want to exercise the send pipeline
|
||||
without a real SMS carrier.
|
||||
|
||||
This module replaces the upstream pattern of ad-hoc anonymous
|
||||
mocks. Every test in the package uses this provider — there are
|
||||
no `vi.fn()` stubs of the `SmsProvider` contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the full `SmsProvider` contract (`name`, `send`,
|
||||
`getStatus`).
|
||||
- `sent: SmsMessage[]` — log of every outgoing message, in
|
||||
order.
|
||||
- `reports: SmsDeliveryReport[]` — parallel log of every receipt
|
||||
returned.
|
||||
- Configurable `defaultStatus` (`queued` / `sent` / `delivered`
|
||||
/ `failed` / `undelivered`) returned by `send`.
|
||||
- `setStatus(messageId, status)` — transitions a previously-sent
|
||||
message's status for `getStatus` callers.
|
||||
- `failOn(predicate, error?)` — configure a per-message failure
|
||||
predicate so tests simulate provider rejections without
|
||||
swapping the implementation.
|
||||
- Injectable `Clock` for deterministic receipt timestamps.
|
||||
- Injectable `IdGenerator` for predictable message IDs.
|
||||
- AbortSignal-aware — a pre-aborted signal rejects with
|
||||
`Error("aborted")` so the upstream retry can wrap it.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InMemorySmsProvider } from "@suaveplan/sms/in-memory-provider";
|
||||
|
||||
const provider = new InMemorySmsProvider();
|
||||
const report = await provider.send({ to: "+15551234567", body: "hi" });
|
||||
console.log(provider.sent[0]?.body); // "hi"
|
||||
console.log(report.status); // "sent"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { InMemorySmsProvider } from "@suaveplan/sms/in-memory-provider";
|
||||
|
||||
let n = 0;
|
||||
const provider = new InMemorySmsProvider({
|
||||
name: "twilio-fake",
|
||||
defaultStatus: "queued",
|
||||
idGenerator: () => `seed_${n++}`,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
provider.failOn(
|
||||
(msg) => msg.to === "+15550000000",
|
||||
new Error("blocked-by-carrier")
|
||||
);
|
||||
|
||||
const r = await provider.send({ to: "+15551234567", body: "ping" });
|
||||
provider.setStatus(r.messageId, "delivered");
|
||||
console.log((await provider.getStatus(r.messageId)).status); // "delivered"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class InMemorySmsProvider implements SmsProvider`
|
||||
- `readonly name: string`
|
||||
- `readonly sent: SmsMessage[]`
|
||||
- `readonly reports: SmsDeliveryReport[]`
|
||||
- `send(message, options?): Promise<SmsDeliveryReport>`
|
||||
- `getStatus(messageId): Promise<SmsDeliveryReport>`
|
||||
- `setStatus(messageId, status): void` (throws on unknown id)
|
||||
- `failOn(predicate, error?): void`
|
||||
- `clearFailures(): void`
|
||||
- `clear(): void`
|
||||
- `interface InMemorySmsProviderOptions`
|
||||
- `name?`, `defaultStatus?`, `idGenerator?`, `clock?`
|
||||
- `type SmsFailurePredicate = (m: SmsMessage) => boolean`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The provider stores two parallel arrays — `sent` (the incoming
|
||||
`SmsMessage`) and `reports` (the outgoing receipts) — so test
|
||||
assertions on the rendered body work even when the service has
|
||||
substituted template variables. The default `idGenerator`
|
||||
combines a monotonic counter and the clock so collisions are
|
||||
impossible across instances. AbortSignal rejection uses a plain
|
||||
`Error("aborted")`; `SmsService.send` wraps it into
|
||||
`SmsAbortedError` after rate-limiter / retry layers run.
|
||||
@@ -0,0 +1,93 @@
|
||||
# `legacy-errors`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`legacy-errors` preserves the upstream `@suaveplan/sms` v1
|
||||
public surface — the `SmsError` class, the `SmsErrorCode`
|
||||
string-union, the five static factory methods, the
|
||||
`SmsErrorContext` shape, the `SmsErrorOptions` shape, and the
|
||||
`isSmsError` type guard — so existing callers compile against
|
||||
the package with zero source changes. Internally the
|
||||
class extends `SuaveplanError` so it integrates with the
|
||||
error stack (cause walking, OTel span events,
|
||||
redaction, the i18n resolver hook).
|
||||
|
||||
## Features
|
||||
|
||||
- Upstream-compatible `SmsErrorCode` union with the five legacy
|
||||
string codes (`SMS_INVALID_PHONE_NUMBER`, `SMS_SEND_FAILED`,
|
||||
`SMS_TEMPLATE_NOT_FOUND`, `SMS_RECIPIENT_OPTED_OUT`,
|
||||
`SMS_PROVIDER_NOT_CONFIGURED`).
|
||||
- `SmsError` class extending `SuaveplanError<SmsErrorCode>`
|
||||
so it picks up cause walking, OTel adapter, the i18n
|
||||
resolver, and `WireError` serialization.
|
||||
- Five static factory methods, each populating `context` with
|
||||
the upstream field shape (`phoneNumber`, `messageId`,
|
||||
`templateId`).
|
||||
- `isSmsError` instance check.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SmsError } from "@suaveplan/sms/legacy-errors";
|
||||
|
||||
try {
|
||||
throw SmsError.invalidPhoneNumber("not-a-number");
|
||||
} catch (err) {
|
||||
if (SmsError.isSmsError(err)) {
|
||||
console.log(err.code, err.context?.phoneNumber);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SmsError } from "@suaveplan/sms/legacy-errors";
|
||||
|
||||
async function sendOnce(): Promise<void> {
|
||||
try {
|
||||
// ... call provider ...
|
||||
throw new Error("network");
|
||||
} catch (cause) {
|
||||
throw SmsError.sendFailed("msg_42", cause as Error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sendOnce();
|
||||
} catch (err) {
|
||||
if (SmsError.isSmsError(err) && err.code === "SMS_SEND_FAILED") {
|
||||
console.error("send failed:", err.context?.messageId, err.cause);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SmsErrorCode` — union of the five legacy string codes.
|
||||
- `interface SmsErrorContext` — `phoneNumber?`, `templateId?`,
|
||||
`providerId?`, `messageId?`, plus an open `Record<string, unknown>`
|
||||
index signature.
|
||||
- `interface SmsErrorOptions` — `message`, `code`, `context?`,
|
||||
`cause?`.
|
||||
- `class SmsError extends SuaveplanError<SmsErrorCode>` — public
|
||||
constructor accepting `SmsErrorOptions`.
|
||||
- Static factories: `invalidPhoneNumber(phoneNumber)`,
|
||||
`sendFailed(messageId, cause?)`, `templateNotFound(templateId)`,
|
||||
`recipientOptedOut(phoneNumber)`, `providerNotConfigured()`.
|
||||
- Static type guard: `isSmsError(value): value is SmsError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`@suaveplan/error` has a `protected` constructor on
|
||||
`SuaveplanError`; subclasses must declare a `public` constructor
|
||||
that calls `super(code, message, options)`. The legacy class
|
||||
preserves the upstream argument order (`{message, code, ...}`)
|
||||
so existing call sites remain identical, then forwards to the
|
||||
standard super constructor. `sendFailed` includes a conditional
|
||||
add of `cause` to satisfy `exactOptionalPropertyTypes`.
|
||||
@@ -0,0 +1,85 @@
|
||||
# `legacy-metadata`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`legacy-metadata` preserves the upstream `SMS_ERROR_METADATA`
|
||||
table — the per-code mapping of `httpStatus`, `severity`, retry
|
||||
hint, `userMessageKey`, `category`, and `description` — so
|
||||
existing callers that probe the table to drive their own
|
||||
problem+json responses, retry policies, or i18n catalogues
|
||||
continue to work. The upstream package registered the table on a
|
||||
process-wide `ErrorCodes` singleton; this package carries no such
|
||||
singleton, so `registerSmsErrorCodes()` is reframed as a
|
||||
no-op-friendly getter that returns the frozen table. Callers can
|
||||
plug the returned table into whatever registry their host wires
|
||||
up.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `SMS_ERROR_METADATA` mapping with the six upstream
|
||||
entries (`SMS_SEND_FAILED`, `SMS_PROVIDER_ERROR`,
|
||||
`SMS_INVALID_NUMBER`, `SMS_RATE_LIMITED`, `SMS_CONFIG_MISSING`,
|
||||
`SMS_FORBIDDEN`).
|
||||
- `SmsCodeMetadata` / `SmsCodeSeverity` / `SmsRetryHint` shapes
|
||||
exposed as type contracts.
|
||||
- `registerSmsErrorCodes()` idempotent helper returning the
|
||||
table.
|
||||
- Eagerly evaluates on import to mirror the upstream load-time
|
||||
side effect.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SMS_ERROR_METADATA } from "@suaveplan/sms/legacy-metadata";
|
||||
|
||||
const meta = SMS_ERROR_METADATA.SMS_RATE_LIMITED;
|
||||
console.log(meta.httpStatus); // 429
|
||||
console.log(meta.retry?.maxAttempts); // 5
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerSmsErrorCodes,
|
||||
type SmsCodeMetadata,
|
||||
} from "@suaveplan/sms/legacy-metadata";
|
||||
|
||||
// Host wires the package metadata into its own registry
|
||||
const table = registerSmsErrorCodes();
|
||||
for (const [code, meta] of Object.entries(table) as [
|
||||
string,
|
||||
SmsCodeMetadata
|
||||
][]) {
|
||||
myHostRegistry.upsert(code, meta);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SmsCodeSeverity = "info" | "warning" | "error" | "fatal"`.
|
||||
- `interface SmsRetryHint` — `retryable: boolean`,
|
||||
`strategy?: "linear" | "exponential"`, `maxAttempts?: number`.
|
||||
- `interface SmsCodeMetadata` — `httpStatus`, `severity`,
|
||||
`retry?`, `retryable?`, `userMessageKey`, `category`,
|
||||
`description`.
|
||||
- `type SmsRegisteredErrorCode` — the six upstream string codes.
|
||||
- `const SMS_ERROR_METADATA: Readonly<Record<SmsRegisteredErrorCode,
|
||||
SmsCodeMetadata>>` — frozen mapping.
|
||||
- `function registerSmsErrorCodes(): Readonly<Record<SmsRegisteredErrorCode,
|
||||
SmsCodeMetadata>>` — returns the table; idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The table is frozen via `Object.freeze` so neither the
|
||||
top-level dictionary nor the eager-registration step can mutate
|
||||
entries downstream. The function returns the same table by
|
||||
reference — callers needing a defensive copy should clone
|
||||
explicitly. The eager call at module load mirrors the upstream
|
||||
`registerSmsErrorCodes()` side effect that ran at v1 import
|
||||
time, so a consumer that previously relied on the function being
|
||||
called for them still sees the same behaviour .
|
||||
@@ -0,0 +1,87 @@
|
||||
# `opt-out`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`opt-out` is the in-memory opt-out registry that `SmsService`
|
||||
consults before every outbound message. Recipients on the
|
||||
registry receive no further messages — `service.send` throws
|
||||
`SmsRecipientOptedOutError` (code `SMS.RECIPIENT_OPTED_OUT`).
|
||||
Entries can be scoped to a channel so transactional messages
|
||||
remain deliverable when only marketing has been opted out by the
|
||||
host application.
|
||||
|
||||
The registry is intentionally pluggable but ships as an in-process
|
||||
`Map` so unit tests and small deployments need no external state;
|
||||
production hosts that share opt-out across processes can wrap
|
||||
`OptOutRegistry` in their own persistence layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `add(phoneNumber, channel?)` — records the opt-out with a
|
||||
timestamp from an injectable `Clock`.
|
||||
- `remove(phoneNumber)` — opt-in helper.
|
||||
- `has(phoneNumber)` and `get(phoneNumber)` — predicate +
|
||||
detail accessors.
|
||||
- `list()` snapshot, `size()`, and `clear()` for bulk operations.
|
||||
- `importEntries(entries)` — bulk import with overwrite-by-id
|
||||
semantics for syncing from an external source.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { OptOutRegistry } from "@suaveplan/sms/opt-out";
|
||||
|
||||
const reg = new OptOutRegistry();
|
||||
reg.add("+15551234567", "marketing");
|
||||
console.log(reg.has("+15551234567")); // true
|
||||
reg.remove("+15551234567");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { OptOutRegistry } from "@suaveplan/sms/opt-out";
|
||||
|
||||
// Inject a deterministic clock for tests; importEntries
|
||||
// reseeds the registry from a downstream cache.
|
||||
const reg = new OptOutRegistry({ clock: () => 1_700_000_000_000 });
|
||||
reg.importEntries([
|
||||
{
|
||||
phoneNumber: "+15550000001",
|
||||
optedOutAt: new Date(1_700_000_000_000),
|
||||
channel: "marketing",
|
||||
},
|
||||
{
|
||||
phoneNumber: "+15550000002",
|
||||
optedOutAt: new Date(1_700_000_000_000),
|
||||
},
|
||||
]);
|
||||
console.log(reg.size()); // 2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class OptOutRegistry`
|
||||
- `constructor(options?: { clock?: Clock })`
|
||||
- `add(phoneNumber: string, channel?: string): OptOutEntry`
|
||||
- `remove(phoneNumber: string): boolean`
|
||||
- `has(phoneNumber: string): boolean`
|
||||
- `get(phoneNumber: string): OptOutEntry | undefined`
|
||||
- `list(): ReadonlyArray<OptOutEntry>`
|
||||
- `importEntries(entries: ReadonlyArray<OptOutEntry>): void`
|
||||
- `clear(): void`
|
||||
- `size(): number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry stores `OptOutEntry` records keyed by the verbatim
|
||||
phone number — no normalisation. Callers SHOULD pass E.164
|
||||
strings; mismatched formats will fail to match. `add` overwrites
|
||||
existing entries by `phoneNumber`, so a caller switching the
|
||||
`channel` on an existing opt-out simply re-adds. The `clock`
|
||||
defaults to `Date.now`, deterministic only when explicitly
|
||||
injected.
|
||||
@@ -0,0 +1,98 @@
|
||||
# `otel/metrics`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`otel/metrics` is the OpenTelemetry instrumentation layer for
|
||||
`@suaveplan/sms`. It owns four meter instruments — three
|
||||
counters and a histogram — that `SmsService` populates on every
|
||||
send, retry, and failure. Wiring observability at the package
|
||||
level keeps consumers free from re-defining instrument names and
|
||||
units, and gives downstream dashboards a stable contract to plot
|
||||
against.
|
||||
|
||||
The module reads the global `MeterProvider` from
|
||||
`@opentelemetry/api`. Tests that wire an in-memory exporter call
|
||||
`refreshSmsMeters()` after installing the exporter so subsequent
|
||||
`record*` calls land on the test provider rather than the
|
||||
load-time meter.
|
||||
|
||||
## Features
|
||||
|
||||
- `sms.sent_total{channel, from_me}` counter — successful sends.
|
||||
- `sms.failed_total{code}` counter — failed sends, tagged by
|
||||
error code.
|
||||
- `sms.retry_total{attempt}` counter — retry attempts, tagged
|
||||
by zero-based attempt index.
|
||||
- `sms.send_duration_ms{outcome}` histogram — pipeline duration
|
||||
in milliseconds, outcome ∈ {`ok`, `error`, `aborted`}.
|
||||
- `refreshSmsMeters()` re-resolves every instrument against the
|
||||
current `MeterProvider` for test wiring.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordSmsSent,
|
||||
recordSmsFailed,
|
||||
recordSmsRetry,
|
||||
recordSmsSendDuration,
|
||||
} from "@suaveplan/sms/otel";
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
// ... send ...
|
||||
recordSmsSent("marketing", false);
|
||||
recordSmsSendDuration(Date.now() - start, "ok");
|
||||
} catch (err) {
|
||||
recordSmsFailed("SMS.SEND_FAILED");
|
||||
recordSmsSendDuration(Date.now() - start, "error");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
ConsoleMetricExporter,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { refreshSmsMeters } from "@suaveplan/sms/otel";
|
||||
|
||||
const provider = new MeterProvider({
|
||||
readers: [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter: new ConsoleMetricExporter(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshSmsMeters(); // rebind every instrument against the new provider
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function recordSmsSent(channel: string, fromMe: boolean): void`
|
||||
- `function recordSmsFailed(code: string): void`
|
||||
- `function recordSmsRetry(attempt: number): void`
|
||||
- `function recordSmsSendDuration(durationMs: number, outcome:
|
||||
"ok" | "error" | "aborted"): void`
|
||||
- `function refreshSmsMeters(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Instruments are stored at module scope so the hot send path
|
||||
performs zero allocation per record call. The default no-op
|
||||
meter provided by `@opentelemetry/api` accepts every call
|
||||
silently, so packages that never install a `MeterProvider` pay
|
||||
nothing. Calling `refreshSmsMeters()` re-creates each instrument
|
||||
against the current global — necessary because instrument
|
||||
identity is bound to the meter instance, not the API surface.
|
||||
The attribute keys (`channel`, `from_me`, `code`, `attempt`,
|
||||
`outcome`) are part of the public observability contract; renames
|
||||
require a major version bump.
|
||||
@@ -0,0 +1,92 @@
|
||||
# `rate-limiter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`rate-limiter` is the token-bucket throttle that `SmsService`
|
||||
consults before every outbound send. The limiter is keyed by an
|
||||
arbitrary string (the service uses `${channel}:${recipient}` so
|
||||
marketing and transactional channels share no quota) and refills
|
||||
continuously against an injectable `Clock`, making it
|
||||
deterministic in tests and fair under bursty load.
|
||||
|
||||
The default settings — capacity `60`, refill `1` token per second
|
||||
— tolerate a 60-message burst followed by a steady stream and
|
||||
cover most transactional SMS workloads; high-throughput hosts can
|
||||
raise the bucket capacity or the refill rate via the constructor.
|
||||
|
||||
## Features
|
||||
|
||||
- Token-bucket algorithm with continuous refill against an
|
||||
injectable `Clock`.
|
||||
- Per-key buckets — independent quotas across channels /
|
||||
recipients.
|
||||
- `tryConsume(key)` returns a boolean so callers branch on
|
||||
rejection without exception cost.
|
||||
- `peek(key)` for observability — returns the current token
|
||||
count after applying the refill.
|
||||
- `clear()` to reset every bucket (useful for tests).
|
||||
- Constructor-time validation of `capacity > 0` and
|
||||
`refillPerSecond > 0`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RateLimiter } from "@suaveplan/sms/rate-limiter";
|
||||
|
||||
const limiter = new RateLimiter({ capacity: 10, refillPerSecond: 1 });
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
if (!limiter.tryConsume("+15551234567")) {
|
||||
console.log("rate limited");
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { RateLimiter } from "@suaveplan/sms/rate-limiter";
|
||||
|
||||
let now = 1_000;
|
||||
const limiter = new RateLimiter({
|
||||
capacity: 3,
|
||||
refillPerSecond: 1,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
limiter.tryConsume("k"); // true
|
||||
limiter.tryConsume("k"); // true
|
||||
limiter.tryConsume("k"); // true
|
||||
limiter.tryConsume("k"); // false — empty
|
||||
now += 1_000;
|
||||
limiter.tryConsume("k"); // true again after refill
|
||||
console.log(limiter.peek("k")); // 0
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class RateLimiter`
|
||||
- `constructor(options?: RateLimiterOptions)`
|
||||
- `tryConsume(key: string): boolean`
|
||||
- `peek(key: string): number`
|
||||
- `clear(): void`
|
||||
- `interface RateLimiterOptions`
|
||||
- `capacity?: number` (default 60)
|
||||
- `refillPerSecond?: number` (default 1)
|
||||
- `clock?: Clock` (default `Date.now`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The refill is fractional — `(elapsedMs / 1000) * refillPerSecond`
|
||||
— so the limiter does not snap-fill on whole seconds. Tokens are
|
||||
clamped to `capacity` after refill, so a long pause does not let
|
||||
the bucket accumulate more than the cap. The `clock` defaults to
|
||||
`Date.now` and is invoked once per `tryConsume` / `peek` so wall-
|
||||
clock drift is observable but does not corrupt state. Constructor
|
||||
validation throws plain `Error` (not `SmsInvalidConfigError`)
|
||||
because the limiter is a leaf primitive — callers compose Zod
|
||||
validation higher in the stack via `SmsService`.
|
||||
@@ -0,0 +1,93 @@
|
||||
# `retry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`retry` is the exponential-backoff retry policy used by
|
||||
`SmsService.send` to absorb transient provider failures. The
|
||||
policy retries a thrown attempt up to `maxAttempts` times with
|
||||
exponential backoff (`initialDelayMs * 2^attempt`, capped at
|
||||
`maxDelayMs`) and full jitter, awaits each backoff against an
|
||||
injectable `Timers` seam so tests can drive the policy without
|
||||
real wall-clock waits, and honours an `AbortSignal` so cancelled
|
||||
operations don't sit through a multi-second backoff.
|
||||
|
||||
## Features
|
||||
|
||||
- Exponential backoff with full-jitter delay computation.
|
||||
- Injectable `TimersLike` seam — defaults to a
|
||||
`globalThis.setTimeout` adapter; tests inject a controlled
|
||||
timer.
|
||||
- Injectable `random` source — defaults to `Math.random`; tests
|
||||
pin it to a fixed value for deterministic jitter.
|
||||
- AbortSignal-aware — a pre-aborted signal halts before the
|
||||
first attempt; an abort that lands during a backoff cancels
|
||||
the timer and rejects with `SmsAbortedError`.
|
||||
- Per-attempt `onRetry` hook receives a `SmsRetryEvent` so
|
||||
callers can record per-attempt OTel counters / bus events.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RetryPolicy } from "@suaveplan/sms/retry";
|
||||
|
||||
const policy = new RetryPolicy({ maxAttempts: 3 });
|
||||
const value = await policy.execute(async () => callRemoteService());
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { RetryPolicy } from "@suaveplan/sms/retry";
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 2_000,
|
||||
});
|
||||
|
||||
const result = await policy.execute(
|
||||
async (attempt) => {
|
||||
if (attempt < 2) throw new Error("flaky");
|
||||
return "ok";
|
||||
},
|
||||
{
|
||||
signal: ctrl.signal,
|
||||
onRetry: (event) => {
|
||||
console.log("retry", event.attempt, event.error.message);
|
||||
},
|
||||
to: "+15551234567",
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class RetryPolicy`
|
||||
- `constructor(options?: RetryPolicyOptions)`
|
||||
- `execute<T>(fn: (attempt: number) => Promise<T>, options?: {
|
||||
signal?: AbortSignal; onRetry?: RetryHook; to?: string;
|
||||
}): Promise<T>`
|
||||
- `type RetryHook = (event: SmsRetryEvent) => void`.
|
||||
- `interface RetryPolicyOptions`
|
||||
- `maxAttempts?: number` (default 3)
|
||||
- `initialDelayMs?: number` (default 50)
|
||||
- `maxDelayMs?: number` (default 5000)
|
||||
- `timers?: TimersLike`
|
||||
- `random?: () => number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The backoff uses full jitter (`floor(random() * capped)`) which
|
||||
empirically produces lower contention than equal jitter for SMS
|
||||
provider rate-limit scenarios. The `onRetry` hook receives the
|
||||
ZERO-based attempt index for the attempt that just failed, so
|
||||
the first retry fires `attempt = 0`. The delay loop guards
|
||||
against abort via an `AbortSignal.addEventListener("abort", …,
|
||||
{ once: true })` listener that clears the pending timer and
|
||||
rejects synchronously, ensuring an aborted retry never wakes up
|
||||
to perform the next attempt.
|
||||
@@ -0,0 +1,86 @@
|
||||
# `routes/problem-json`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`routes/problem-json` is the RFC 7807 `application/problem+json`
|
||||
response helper for hosts wiring `@suaveplan/sms` errors onto a
|
||||
Hono-shaped router. It serialises any `SuaveplanError` (current
|
||||
or legacy), or unknown throwable, into a problem-details
|
||||
document on the supplied context-like surface and returns the
|
||||
resulting `Response` so callers can `return` it directly from a
|
||||
Hono handler.
|
||||
|
||||
The helper preserves the upstream registered `httpStatus` from
|
||||
`SMS_ERROR_METADATA` when the error's `code` is one of the six
|
||||
legacy keys; for `SMS.*` codes the helper maps to
|
||||
a sensible status (`SMS.RATE_LIMITED` → 429, `SMS.INVALID_NUMBER`
|
||||
→ 400, `SMS.ABORTED` → 499, …); for unknown errors the helper
|
||||
falls back to 500.
|
||||
|
||||
## Features
|
||||
|
||||
- `PROBLEM_JSON_CONTENT_TYPE` — the RFC 7807 media type.
|
||||
- `respondProblemJson(c, err)` — context-agnostic helper that
|
||||
takes any Hono-like surface (anything with `header(name,
|
||||
value)` and `body(data, status)`).
|
||||
- Maps `SuaveplanError.code` → HTTP status via the legacy
|
||||
table, then the `SMS.*` switch, then a 500 fallback.
|
||||
- Propagates `SuaveplanError.context` into the problem-details
|
||||
document so callers can surface Zod issues, opt-out targets,
|
||||
etc.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { Hono } from "hono";
|
||||
import { respondProblemJson } from "@suaveplan/sms/routes/problem-json";
|
||||
|
||||
const app = new Hono();
|
||||
app.post("/sms", async (c) => {
|
||||
try {
|
||||
// ... service.send ...
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
return respondProblemJson(c, err);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SmsRateLimitedError } from "@suaveplan/sms/error-codes";
|
||||
import { respondProblemJson } from "@suaveplan/sms/routes/problem-json";
|
||||
|
||||
const res = respondProblemJson(c, new SmsRateLimitedError({
|
||||
context: { phoneNumber: "+15551234567", key: "marketing:+15551234567" },
|
||||
}));
|
||||
console.log(res.status); // 429
|
||||
const body = await res.json();
|
||||
console.log(body.context); // { phoneNumber: "...", key: "..." }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const PROBLEM_JSON_CONTENT_TYPE = "application/problem+json"`.
|
||||
- `interface ProblemDetails` — RFC 7807 shape with `title`,
|
||||
`status`, optional `type`/`detail`/`instance`/`code`, plus an
|
||||
open index signature.
|
||||
- `interface HonoLike` — `header(name, value): void`,
|
||||
`body(data, status): Response`.
|
||||
- `function respondProblemJson(c: HonoLike, err: unknown): Response`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helper is intentionally context-agnostic — it accepts any
|
||||
object satisfying the minimal `HonoLike` surface so test
|
||||
harnesses can pass a stub. The status-mapping cascade
|
||||
prioritises the upstream `SMS_ERROR_METADATA` table so a legacy
|
||||
caller continues to see the same status codes after migration.
|
||||
Unknown error throwables fall back to 500 with the error
|
||||
message as the title, or `"Internal Server Error"` when the
|
||||
message is empty, preserving the upstream behaviour.
|
||||
@@ -0,0 +1,97 @@
|
||||
# `scheduler`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`scheduler` provides `MessageScheduler` — the minimal timer
|
||||
seam that `SmsService.scheduleAt` uses to fire a delayed
|
||||
delivery. The class wraps an injectable `TimersLike` so tests
|
||||
drive it deterministically, computes the delay from an
|
||||
injectable `Clock`, and returns a `ScheduleHandle` with
|
||||
`cancel()` so a caller can abort a scheduled message before the
|
||||
timer fires.
|
||||
|
||||
The scheduler is intentionally small: it does not own retry,
|
||||
rate-limit, opt-out, or template logic — those layers live in
|
||||
`SmsService` so a scheduled message goes through the full
|
||||
pipeline when the timer fires.
|
||||
|
||||
## Features
|
||||
|
||||
- `scheduleAt(date, fn)` — schedules `fn` to run at the target
|
||||
instant.
|
||||
- Returns a `ScheduleHandle` with `cancel()` (idempotent) and
|
||||
the original `sendAt` field for observation.
|
||||
- Injectable `TimersLike` seam — defaults to a
|
||||
`globalThis.setTimeout` / `clearTimeout` adapter.
|
||||
- Injectable `Clock` — defaults to `Date.now`.
|
||||
- Past dates collapse to a zero-delay timer, matching the
|
||||
expectation that "send it now (or as soon as possible)".
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MessageScheduler } from "@suaveplan/sms/scheduler";
|
||||
|
||||
const scheduler = new MessageScheduler();
|
||||
const handle = scheduler.scheduleAt(
|
||||
new Date(Date.now() + 60_000),
|
||||
() => console.log("fire!")
|
||||
);
|
||||
handle.cancel(); // call before firing to abort
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MessageScheduler } from "@suaveplan/sms/scheduler";
|
||||
|
||||
// Controlled timers + clock for deterministic tests.
|
||||
const pending: Array<{ cb: () => void; delayMs: number }> = [];
|
||||
const scheduler = new MessageScheduler({
|
||||
timers: {
|
||||
setTimer(cb, delayMs) {
|
||||
pending.push({ cb, delayMs });
|
||||
return pending.length;
|
||||
},
|
||||
clearTimer() {
|
||||
/* no-op for tests */
|
||||
},
|
||||
},
|
||||
clock: () => 1_000,
|
||||
});
|
||||
|
||||
const handle = scheduler.scheduleAt(new Date(2_000), () => {
|
||||
/* runs at virtual t=2000 */
|
||||
});
|
||||
|
||||
// Manually fire the queued callback to simulate the timer
|
||||
pending[0]?.cb();
|
||||
handle.cancel(); // safe to call after fire
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class MessageScheduler`
|
||||
- `constructor(options?: MessageSchedulerOptions)`
|
||||
- `scheduleAt(sendAt: Date, fn: () => void): ScheduleHandle`
|
||||
- `interface ScheduleHandle`
|
||||
- `cancel(): void` — idempotent
|
||||
- `readonly sendAt: Date`
|
||||
- `interface MessageSchedulerOptions`
|
||||
- `timers?: TimersLike`
|
||||
- `clock?: Clock`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The scheduler tracks a `cancelled` flag inside its timer
|
||||
callback so a cancellation that races the timer (i.e. the
|
||||
caller calls `cancel` after the timer has already been pulled
|
||||
but before the callback runs) is still honoured. Double-cancel
|
||||
is a no-op. The platform adapter casts the `setTimeout` return
|
||||
to `number` for cross-runtime compatibility — Node returns a
|
||||
`Timeout` object, browsers/Bun return a number; both shapes are
|
||||
safe to forward back to `clearTimeout`.
|
||||
@@ -0,0 +1,101 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`schemas` is the Zod boundary for every public input that
|
||||
`@suaveplan/sms` exposes. The schemas mirror the public type
|
||||
contracts (`SmsMessage`, `SmsTemplate`, `SmsDeliveryReport`,
|
||||
`SmsDeliveryStatus`) verbatim, and the `parseSmsMessage` /
|
||||
`parseSmsTemplate` / `parseSmsDeliveryReport` parsers throw a
|
||||
`SmsInvalidConfigError` (code `SMS.INVALID_CONFIG`) with the Zod
|
||||
issues attached as `context.issues` on rejection.
|
||||
|
||||
The schemas use strict `.object()` shapes so unknown keys are
|
||||
rejected — callers cannot accidentally smuggle private fields
|
||||
through the public API.
|
||||
|
||||
## Features
|
||||
|
||||
- `SmsMessageSchema` — strict shape with E.164 `to`, non-empty
|
||||
`body`, and optional `from`, `templateId`, `templateVars`,
|
||||
`metadata`, `channel`.
|
||||
- `SmsTemplateSchema` — strict shape with non-empty `id`,
|
||||
non-empty `body`, `variables` array, optional `defaults` map.
|
||||
- `SmsDeliveryStatusSchema` — union of the five status literals.
|
||||
- `SmsDeliveryReportSchema` — strict shape for receipts.
|
||||
- `parseSmsMessage`, `parseSmsTemplate`,
|
||||
`parseSmsDeliveryReport` — throw `SmsInvalidConfigError` on
|
||||
rejection with the Zod issues attached.
|
||||
- `stripUndefined` helper strips undefined keys before returning
|
||||
so the result complies with the
|
||||
`exactOptionalPropertyTypes` contract.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseSmsMessage } from "@suaveplan/sms/schemas";
|
||||
|
||||
const msg = parseSmsMessage({
|
||||
to: "+15551234567",
|
||||
body: "Hello",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
SmsMessageSchema,
|
||||
parseSmsTemplate,
|
||||
} from "@suaveplan/sms/schemas";
|
||||
import { SmsInvalidConfigError } from "@suaveplan/sms/error-codes";
|
||||
|
||||
try {
|
||||
const template = parseSmsTemplate({
|
||||
id: "welcome",
|
||||
body: "Hi {{name}}",
|
||||
variables: ["name"],
|
||||
});
|
||||
console.log(template.id);
|
||||
} catch (err) {
|
||||
if (err instanceof SmsInvalidConfigError) {
|
||||
for (const issue of (err.context?.issues ?? []) as ReadonlyArray<
|
||||
Record<string, unknown>
|
||||
>) {
|
||||
console.error(issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Or use the raw schema for partial validation pipelines:
|
||||
SmsMessageSchema.safeParse(input);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SmsMessageSchema: ZodObject` — mirrors `SmsMessage`.
|
||||
- `SmsTemplateSchema: ZodObject` — mirrors `SmsTemplate`.
|
||||
- `SmsDeliveryStatusSchema: ZodUnion` — five string literals.
|
||||
- `SmsDeliveryReportSchema: ZodObject` — mirrors
|
||||
`SmsDeliveryReport`.
|
||||
- `parseSmsMessage(input: unknown): SmsMessage` — throws
|
||||
`SmsInvalidConfigError`.
|
||||
- `parseSmsTemplate(input: unknown): SmsTemplate` — throws
|
||||
`SmsInvalidConfigError`.
|
||||
- `parseSmsDeliveryReport(input: unknown): SmsDeliveryReport` —
|
||||
throws `SmsInvalidConfigError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default-export form `import z from "zod"` is the only
|
||||
Bun-safe form (per the workspace's `feedback_zod_import`
|
||||
note); the named import variant breaks under Bun's module
|
||||
loader. Each parser returns the result of `stripUndefined` so
|
||||
the value matches the public type shape (which uses
|
||||
`exactOptionalPropertyTypes`). Zod's `.strict()` rejects unknown
|
||||
keys, so consumers cannot pass private fields through the public
|
||||
boundary.
|
||||
@@ -0,0 +1,71 @@
|
||||
# `service/bus-map`
|
||||
|
||||
## Purpose
|
||||
|
||||
`bus-map` declares `SmsBusMap` — the single-payload topic
|
||||
contract used by `SmsService.bus` (an `EventBus<SmsBusMap>`).
|
||||
The map is split out from the service implementation so the
|
||||
legacy emitter and the service can both import the contract
|
||||
without a circular dependency, and so downstream consumers can
|
||||
type their own `EventBus`-shaped bridges against the same
|
||||
contract.
|
||||
|
||||
`SmsBusMap` covers the eight live topics emitted by
|
||||
`SmsService`: `sms.sent`, `sms.delivered`, `sms.failed`,
|
||||
`sms.opted_out`, `sms.opted_in`, `sms.scheduled`, `sms.retry`,
|
||||
`sms.rate_limited`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-payload `EventBus` contract — one value per topic
|
||||
(versus the legacy `SmsEventMap` tuple form which mirrors the
|
||||
upstream `on/off/emit` shape).
|
||||
- Open `Record<string, unknown>` base so consumers can extend
|
||||
the map with custom topics in their own packages.
|
||||
- One-to-one mapping with the public type contracts in
|
||||
`src/types/sms.types.ts`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { SmsBusMap } from "@suaveplan/sms/service";
|
||||
|
||||
const bus = new EventBus<SmsBusMap>();
|
||||
bus.on("sms.sent", (report) => console.log("sent", report.messageId));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { SmsBusMap } from "@suaveplan/sms/service";
|
||||
|
||||
// Extend the map with a host-specific topic to share one bus.
|
||||
interface HostBusMap extends SmsBusMap {
|
||||
"host.audit": { actor: string; action: string };
|
||||
}
|
||||
|
||||
const bus = new EventBus<HostBusMap>();
|
||||
bus.on("sms.failed", (report) => audit({ messageId: report.messageId }));
|
||||
bus.on("host.audit", (event) => console.log(event.actor, event.action));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SmsBusMap extends Record<string, unknown>` with
|
||||
keys: `sms.sent`, `sms.delivered`, `sms.failed`, `sms.opted_out`,
|
||||
`sms.opted_in`, `sms.scheduled`, `sms.retry`, `sms.rate_limited`,
|
||||
each mapped to the matching public type
|
||||
(`SmsDeliveryReport`, `OptOutEntry`, `{phoneNumber: string}`,
|
||||
`SmsScheduledEvent`, `SmsRetryEvent`, `SmsRateLimitedEvent`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The map is a `type-only` export — no runtime symbol — so it
|
||||
adds zero bundle size. The decision to use single-payload
|
||||
topics on the bus (rather than the legacy tuple form) follows
|
||||
`@suaveplan/events` convention; the tuple form is
|
||||
preserved exclusively for the upstream `on/off/emit` surface
|
||||
and is mediated by `LegacyEmitter.mirror`. Consumers building
|
||||
new code should target the bus directly.
|
||||
@@ -0,0 +1,88 @@
|
||||
# `service/legacy-emitter`
|
||||
|
||||
## Purpose
|
||||
|
||||
`LegacyEmitter` is the back-compat shim that preserves the
|
||||
upstream `@suaveplan/sms` v1 `on/off/emit` listener API on top
|
||||
of the `EventBus<SmsBusMap>`. Every legacy listener
|
||||
registered through `service.on(...)` continues to receive the
|
||||
tuple-spread argument shape expected by upstream callers, while
|
||||
the same payload flows through the typed bus so current
|
||||
consumers can opt in to the richer `EventBus` API. This avoids
|
||||
a breaking change for callers of `@suaveplan/sms` v1.
|
||||
|
||||
The emitter lives in its own module so the `SmsService` class
|
||||
stays focused on pipeline orchestration; the legacy adapter
|
||||
adds zero cost for callers who only use the typed bus.
|
||||
|
||||
## Features
|
||||
|
||||
- `on<K>(type, listener)` — register a tuple-arg legacy listener.
|
||||
- `off<K>(type, listener)` — deregister a legacy listener.
|
||||
- `emit<K>(type, ...args)` — invoke every legacy listener and,
|
||||
for known bus topics, mirror the first arg onto the bus.
|
||||
- `mirror<K>(topic, payload)` — emit a single-payload bus event
|
||||
and mirror it onto the legacy listeners (used by `SmsService`
|
||||
for its native emits).
|
||||
- Topic gate (`BUS_TOPICS`) discriminates legacy-only topics
|
||||
from typed bus topics so non-mapped topics do not pollute the
|
||||
typed bus.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { LegacyEmitter } from "@suaveplan/sms/service";
|
||||
import type { SmsBusMap } from "@suaveplan/sms/service";
|
||||
|
||||
const bus = new EventBus<SmsBusMap>();
|
||||
const emitter = new LegacyEmitter(bus);
|
||||
emitter.on("sms.opted_in", (entry) => console.log(entry.phoneNumber));
|
||||
emitter.emit("sms.opted_in", { phoneNumber: "+15551234567" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { LegacyEmitter } from "@suaveplan/sms/service";
|
||||
import type { SmsBusMap } from "@suaveplan/sms/service";
|
||||
|
||||
const bus = new EventBus<SmsBusMap>();
|
||||
const emitter = new LegacyEmitter(bus);
|
||||
|
||||
// Bridge from a typed bus subscriber to a legacy-listener-flavoured
|
||||
// downstream consumer.
|
||||
bus.on("sms.sent", (report) => console.log("bus", report.messageId));
|
||||
emitter.on("sms.sent", (report) =>
|
||||
console.log("legacy", report.messageId)
|
||||
);
|
||||
emitter.mirror("sms.sent", {
|
||||
messageId: "m1",
|
||||
to: "+15551234567",
|
||||
status: "sent",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
// Both subscribers receive the same payload.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class LegacyEmitter`
|
||||
- `constructor(bus: EventBus<SmsBusMap>)`
|
||||
- `on<K extends keyof SmsEventMap>(type, listener): void`
|
||||
- `off<K extends keyof SmsEventMap>(type, listener): void`
|
||||
- `emit<K extends keyof SmsEventMap>(type, ...args): void`
|
||||
- `mirror<K extends keyof SmsBusMap>(topic, payload): void`
|
||||
- `type SmsLegacyListener<K extends keyof SmsEventMap> = (...args:
|
||||
SmsEventMap[K]) => void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class snapshots the legacy listener array (`[...list]`)
|
||||
before iterating so a listener that calls `emitter.off(...)` on
|
||||
itself mid-emit does not skip subsequent listeners. The `BUS_TOPICS`
|
||||
constant is the single source of truth for the topic gate — adding
|
||||
a new bus topic requires also extending the legacy emitter, but
|
||||
that's the explicit contract: the bus is typed and the legacy
|
||||
mirror must be opt-in.
|
||||
@@ -0,0 +1,119 @@
|
||||
# `service/sms-service`
|
||||
|
||||
## Purpose
|
||||
|
||||
`SmsService` is the provider-agnostic orchestrator that ties
|
||||
together every primitive in `@suaveplan/sms`. A single
|
||||
`service.send` call walks the abort check, the provider check,
|
||||
the E.164 validation, the opt-out check, the rate limiter, the
|
||||
template renderer, the retry policy, the provider send, the
|
||||
delivery-tracker ingest, the OTel record helpers, and the event
|
||||
bus emit. It is the surface upstream consumers used to call
|
||||
`new SmsService()` against; preserves the verbatim
|
||||
upstream API plus an enhanced bus surface and an injectable
|
||||
options bundle.
|
||||
|
||||
## Features
|
||||
|
||||
- `bus: EventBus<SmsBusMap>` — typed single-payload event bus.
|
||||
- Legacy `on`, `off`, `emit` API mirrors the bus (preserved for
|
||||
upstream callers).
|
||||
- `send(message, options?)` — full pipeline with abort,
|
||||
validation, opt-out, rate-limit, template render, retry,
|
||||
provider send, receipt ingest, OTel emit.
|
||||
- `scheduleAt(date, message)` — fire-and-forget scheduled
|
||||
delivery with a cancel handle; on cancel emits `sms.failed`
|
||||
with `errorCode: SMS.SCHEDULE_CANCELLED`.
|
||||
- `getStatus(messageId)` — cache-first lookup through the
|
||||
delivery tracker, falling back to `provider.getStatus`.
|
||||
- `optOut` / `optIn` / `isOptedOut` — opt-out registry helpers
|
||||
emitting the matching bus event.
|
||||
- `markDelivered` / `markFailed` — external webhook receipts
|
||||
that ingest into the tracker and emit the matching event.
|
||||
- `setProvider` / `getProviderName` — runtime provider swap.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InMemorySmsProvider, SmsService } from "@suaveplan/sms";
|
||||
|
||||
const provider = new InMemorySmsProvider();
|
||||
const service = new SmsService({ provider });
|
||||
const report = await service.send({
|
||||
to: "+15551234567",
|
||||
body: "Hello",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { InMemorySmsProvider, SmsService } from "@suaveplan/sms";
|
||||
|
||||
const provider = new InMemorySmsProvider();
|
||||
const service = new SmsService({
|
||||
provider,
|
||||
rateLimiter: { capacity: 10, refillPerSecond: 1 },
|
||||
retry: { maxAttempts: 5, initialDelayMs: 200 },
|
||||
deliveryTracker: { maxEntries: 256 },
|
||||
});
|
||||
|
||||
service.registerTemplate({
|
||||
id: "welcome",
|
||||
body: "Hi {{name}}, your code is {{code}}",
|
||||
variables: ["name", "code"],
|
||||
});
|
||||
|
||||
service.bus.on("sms.retry", (e) =>
|
||||
console.warn("retry", e.attempt, e.error.message)
|
||||
);
|
||||
service.bus.on("sms.failed", (r) =>
|
||||
console.error("failed", r.messageId, r.errorCode)
|
||||
);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
await service.send(
|
||||
{
|
||||
to: "+15551234567",
|
||||
body: "ignored",
|
||||
templateId: "welcome",
|
||||
templateVars: { name: "Alice", code: "8472" },
|
||||
channel: "transactional",
|
||||
},
|
||||
{ signal: ctrl.signal }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SmsService`
|
||||
- `constructor(options?: SmsServiceOptions)`
|
||||
- Public fields: `bus`, `templates`, `optOuts`, `rateLimiter`,
|
||||
`retry`, `scheduler`, `deliveryTracker`.
|
||||
- `setProvider`, `getProviderName`, `registerTemplate`,
|
||||
`getTemplate`.
|
||||
- `send(message, options?: { signal?: AbortSignal }):
|
||||
Promise<SmsDeliveryReport>`.
|
||||
- `scheduleAt(sendAt: Date, message: SmsMessage):
|
||||
ScheduleHandle`.
|
||||
- `getStatus(messageId: string): Promise<SmsDeliveryReport>`.
|
||||
- `optOut`, `optIn`, `isOptedOut`, `markDelivered`,
|
||||
`markFailed`.
|
||||
- Legacy `on`, `off`, `emit` mirroring the bus.
|
||||
- Static `extractFailure(error: unknown): { code: string;
|
||||
message: string }`.
|
||||
- `interface SmsSendOptions` — `signal?: AbortSignal`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The pipeline orders abort → provider → validation → opt-out →
|
||||
rate-limit → template → retry deliberately: cheap checks fail
|
||||
fast before the limiter consumes a token; the template is only
|
||||
rendered when the rate-limiter has admitted the message, so a
|
||||
back-pressured caller doesn't burn template render cycles. The
|
||||
service spreads `options.signal` into the retry options
|
||||
conditionally so an absent signal does not satisfy
|
||||
`exactOptionalPropertyTypes` with `undefined`. The legacy
|
||||
`emit` re-uses the bus topic check so non-bus-mapped legacy
|
||||
topics continue to fire pure-legacy listeners without polluting
|
||||
the typed bus.
|
||||
@@ -0,0 +1,99 @@
|
||||
# `templates`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`templates` is the in-process template registry and renderer used
|
||||
by `SmsService` to expand `{{variable}}` placeholders against
|
||||
per-call variable maps. The renderer is intentionally narrow —
|
||||
substitution only, no conditional logic, no loops — because SMS
|
||||
bodies are short and predictable; richer templating belongs in a
|
||||
caller-side pre-processing pass that produces the
|
||||
`SmsMessage.body` string.
|
||||
|
||||
The registry holds `SmsTemplate` rows indexed by `id` and exposes
|
||||
the small API surface (register, get, require, has, unregister,
|
||||
list, render, clear) that `SmsService` and consumer code use.
|
||||
|
||||
## Features
|
||||
|
||||
- `TemplateRegistry` class for in-memory storage.
|
||||
- `renderTemplate(template, vars, defaults?)` — substitutes
|
||||
every declared `{{var}}` with a value sourced from the `vars`
|
||||
map, then the call-level `defaults`, then the template-level
|
||||
`template.defaults`, then the empty string.
|
||||
- Undeclared placeholders are left in place so callers can layer
|
||||
their own renderer on top.
|
||||
- Regex escaping protects against variable names containing
|
||||
regex meta-characters.
|
||||
- `require(id)` throws `SmsTemplateNotFoundError`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
TemplateRegistry,
|
||||
renderTemplate,
|
||||
} from "@suaveplan/sms/templates";
|
||||
|
||||
const reg = new TemplateRegistry();
|
||||
reg.register({
|
||||
id: "welcome",
|
||||
body: "Hi {{name}}!",
|
||||
variables: ["name"],
|
||||
});
|
||||
const out = reg.render("welcome", { name: "Alice" });
|
||||
console.log(out); // "Hi Alice!"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { renderTemplate } from "@suaveplan/sms/templates";
|
||||
|
||||
const tpl = {
|
||||
id: "alert",
|
||||
body: "Alert {{level}}: {{message}} from {{system}}",
|
||||
variables: ["level", "message", "system"],
|
||||
defaults: { system: "monitoring" },
|
||||
};
|
||||
|
||||
// Per-call defaults override template defaults; missing keys
|
||||
// fall back to the empty string so the body is never broken.
|
||||
const body = renderTemplate(
|
||||
tpl,
|
||||
{ level: "WARN", message: "high latency" },
|
||||
{ system: "checkout-api" }
|
||||
);
|
||||
// "Alert WARN: high latency from checkout-api"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `renderTemplate(template: SmsTemplate, vars: Record<string,
|
||||
string>, defaults?: Record<string, string>): string` — pure
|
||||
function.
|
||||
- `class TemplateRegistry`
|
||||
- `register(template: SmsTemplate): void`
|
||||
- `get(id: string): SmsTemplate | undefined`
|
||||
- `require(id: string): SmsTemplate` — throws
|
||||
`SmsTemplateNotFoundError`
|
||||
- `has(id: string): boolean`
|
||||
- `unregister(id: string): boolean`
|
||||
- `list(): ReadonlyArray<SmsTemplate>`
|
||||
- `render(id: string, vars, defaults?): string`
|
||||
- `clear(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Variable lookup order — `vars[v]` → `defaults[v]` →
|
||||
`template.defaults?.[v]` → `""` — places per-call values
|
||||
strictest at the top so a caller can always override. The
|
||||
substitution uses an escaped global regex so a variable name
|
||||
like `na.me` matches literally rather than as a regex
|
||||
metacharacter. Templates are stored by reference, so external
|
||||
mutation of a registered template will leak into render results —
|
||||
callers wanting an isolated copy should clone before passing in.
|
||||
@@ -0,0 +1,114 @@
|
||||
# `types/sms.types`
|
||||
|
||||
## Purpose
|
||||
|
||||
`types/sms.types` is the type-only foundation for
|
||||
`@suaveplan/sms`. It declares every public contract that the
|
||||
service, the providers, and the consumers exchange — the
|
||||
message shape, the receipt shape, the delivery status union,
|
||||
the provider interface, the opt-out entry, the template, the
|
||||
service options, the scheduled-message envelope, and the event
|
||||
payloads. Keeping the types in a single dependency-free module
|
||||
prevents circular imports between the implementation modules.
|
||||
|
||||
The shapes mirror the upstream `@suaveplan/sms` v1 contracts
|
||||
verbatim — same field names, same optionality — so existing
|
||||
callers can migrate with zero source changes. The
|
||||
file additionally introduces contracts
|
||||
(`RateLimiterOptions`, `RetryPolicyOptions`,
|
||||
`MessageSchedulerOptions`, `DeliveryTrackerOptions`,
|
||||
`SmsServiceOptions`, `ScheduleHandle`, `SmsRetryEvent`,
|
||||
`SmsRateLimitedEvent`, `SmsScheduledEvent`) that surface the
|
||||
new capabilities.
|
||||
|
||||
## Features
|
||||
|
||||
- `SmsDeliveryStatus` union, `SmsMessage`, `SmsDeliveryReport`.
|
||||
- `SmsProvider` abstract interface with `name`, `send`, and
|
||||
`getStatus`.
|
||||
- `OptOutEntry` and `SmsTemplate` for registries.
|
||||
- `SmsEventMap` tuple-form contract for the upstream
|
||||
`on/off/emit` API.
|
||||
- `RateLimiterOptions`, `RetryPolicyOptions`,
|
||||
`MessageSchedulerOptions`, `DeliveryTrackerOptions`,
|
||||
`SmsServiceOptions` for primitive configuration.
|
||||
- `ScheduleHandle`, `ScheduledMessage`, `SmsRetryEvent`,
|
||||
`SmsRateLimitedEvent`, `SmsScheduledEvent` for the
|
||||
additions.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { SmsMessage, SmsProvider } from "@suaveplan/sms/types";
|
||||
|
||||
const message: SmsMessage = { to: "+15551234567", body: "hi" };
|
||||
|
||||
class MyProvider implements SmsProvider {
|
||||
public readonly name = "my-provider";
|
||||
async send(msg: SmsMessage) {
|
||||
// ... call API ...
|
||||
return {
|
||||
messageId: "m1",
|
||||
to: msg.to,
|
||||
status: "sent" as const,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
async getStatus(messageId: string) {
|
||||
return {
|
||||
messageId,
|
||||
to: "+15551234567",
|
||||
status: "delivered" as const,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ScheduleHandle,
|
||||
SmsEventMap,
|
||||
SmsServiceOptions,
|
||||
} from "@suaveplan/sms/types";
|
||||
|
||||
function buildOptions(): SmsServiceOptions {
|
||||
return {
|
||||
retry: { maxAttempts: 5, initialDelayMs: 100 },
|
||||
rateLimiter: { capacity: 30, refillPerSecond: 2 },
|
||||
deliveryTracker: { maxEntries: 512 },
|
||||
};
|
||||
}
|
||||
|
||||
type SentArgs = SmsEventMap["sms.sent"]; // [SmsDeliveryReport]
|
||||
|
||||
function trackHandle(handle: ScheduleHandle): void {
|
||||
console.log("scheduled for", handle.sendAt.toISOString());
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Union: `SmsDeliveryStatus`.
|
||||
- Interfaces: `SmsMessage`, `SmsSendCallOptions`,
|
||||
`SmsDeliveryReport`, `SmsProvider`, `OptOutEntry`,
|
||||
`SmsTemplate`, `SmsRetryEvent`, `SmsRateLimitedEvent`,
|
||||
`SmsScheduledEvent`, `SmsEventMap`, `RateLimiterOptions`,
|
||||
`RetryPolicyOptions`, `MessageSchedulerOptions`,
|
||||
`DeliveryTrackerOptions`, `SmsServiceOptions`,
|
||||
`ScheduleHandle`, `ScheduledMessage`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module is type-only — every export is a `type` or
|
||||
`interface`, no runtime symbol — so it adds zero bundle size and
|
||||
can be imported by any other module without inducing a runtime
|
||||
dependency cycle. Optional fields use `?` (not `| undefined`)
|
||||
so the file complies with the `exactOptionalPropertyTypes`
|
||||
contract. The `SmsEventMap` retains the tuple form
|
||||
(`"sms.sent": [SmsDeliveryReport]`) verbatim from upstream so
|
||||
existing tuple-spread listeners keep compiling; the single-payload
|
||||
form used by the typed bus lives in
|
||||
`src/service/bus-map.ts` as `SmsBusMap`.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `validation/phone`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`validation/phone` is the strict-E.164 phone number validator
|
||||
used by every public input boundary in `@suaveplan/sms`. The
|
||||
validator implements the ITU-T E.164 grammar verbatim — a single
|
||||
leading `+`, a first digit in `[1-9]`, then one to fourteen
|
||||
trailing decimal digits — and exposes both the regex constant
|
||||
and the predicate so callers can compose either against their
|
||||
own pipelines.
|
||||
|
||||
The module is intentionally tiny: a single regex and a single
|
||||
boolean function. Centralising the rule here keeps the spec a
|
||||
single source of truth — the service, the Zod schemas, and the
|
||||
test fixtures all use the same predicate.
|
||||
|
||||
## Features
|
||||
|
||||
- `E164_REGEX` — exported regex constant for downstream
|
||||
composition.
|
||||
- `isValidE164(value)` — boolean predicate, returns `true` only
|
||||
for syntactically valid E.164 numbers.
|
||||
- Strict format: rejects bare digits without leading `+`,
|
||||
rejects `+` alone, rejects `+0…`, rejects letters, rejects
|
||||
numbers longer than 15 total digits.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { isValidE164 } from "@suaveplan/sms/validation/phone";
|
||||
|
||||
isValidE164("+15551234567"); // true
|
||||
isValidE164("15551234567"); // false (no leading +)
|
||||
isValidE164("+0123456789"); // false (first digit is 0)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { E164_REGEX } from "@suaveplan/sms/validation/phone";
|
||||
import z from "zod";
|
||||
|
||||
const PhoneSchema = z
|
||||
.string()
|
||||
.regex(E164_REGEX, "must be an E.164 phone number")
|
||||
.transform((value) => value.toUpperCase());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const E164_REGEX: RegExp` — `^\+[1-9]\d{1,14}$` (frozen by
|
||||
TypeScript's `const` semantics).
|
||||
- `function isValidE164(value: string): boolean` — returns
|
||||
`E164_REGEX.test(value)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The validator does NOT consult a country-code prefix table: it
|
||||
checks only the syntactic shape required by E.164. Higher-layer
|
||||
validation (carrier routing, prefix-to-country mapping,
|
||||
toll-free / premium-rate filters) lives in
|
||||
`@suaveplan/sms-providers` per-provider adapters because the rule
|
||||
set varies by carrier. Keeping the syntactic predicate cheap
|
||||
means a hot path through `SmsService.send` can call
|
||||
`isValidE164` once per send without measurable overhead.
|
||||
@@ -0,0 +1,92 @@
|
||||
# @suaveplan/telegram-client - Telegram Bot API client — send/edit/delete messages, media, inline + custom keyboards, typed errors, injectable fetch + clock, OTel instrumented
|
||||
|
||||
Telegram Bot API client: send/edit/delete messages, six media variants,
|
||||
inline + custom keyboards, typed errors, injectable `fetch` + `clock`, and
|
||||
OpenTelemetry counters/histograms on every code path. Runtime-agnostic
|
||||
(works in Bun, Node, Deno, browsers).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [telegram-bot-client](./src/core/telegram-bot-client/telegram-bot-client.md) — `TelegramBotClient` is the public, runtime-agnostic Telegram Bot API client. It wraps the handful of Bot API endpoints…
|
||||
- [telegram-bot-client](./src/core/telegram-bot-client/telegram-bot-client.md) — `TelegramBotClient` is the public, runtime-agnostic Telegram Bot API client. It wraps the handful of Bot API endpoints…
|
||||
- [errors](./src/errors/telegram-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `TelegramClientError` class used by every runtime fail…
|
||||
- [telegram-client-error](./src/errors/telegram-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `TelegramClientError` class used by every runtime fail…
|
||||
- [types](./src/types/telegram-bot-config.md) — Defines the Zod schema that validates `TelegramBotConfig` at the public construction boundary of `TelegramBotClient`. T…
|
||||
- [telegram-bot-config](./src/types/telegram-bot-config.md) — Defines the Zod schema that validates `TelegramBotConfig` at the public construction boundary of `TelegramBotClient`. T…
|
||||
- [build-bodies](./src/utils/build-bodies.md) — Pure, side-effect-free helpers that translate the camelCase public message / reply-markup shape used by callers into th…
|
||||
- [classify-error](./src/utils/classify-error.md) — Pure helpers that turn an outgoing-protocol failure (HTTP status + Telegram `description` + parsed body) into a stable…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/telegram-client
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createTelegramBotClient } from "@suaveplan/telegram-client";
|
||||
|
||||
const bot = createTelegramBotClient({ token: process.env.TELEGRAM_TOKEN! });
|
||||
|
||||
const sent = await bot.sendMessage({
|
||||
chatId: 12345,
|
||||
text: "Hello",
|
||||
parseMode: "HTML",
|
||||
});
|
||||
|
||||
console.log(sent.messageId);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| Client | [src/core/telegram-bot-client/telegram-bot-client.md](src/core/telegram-bot-client/telegram-bot-client.md) | `TelegramBotClient` class, factory, OTel metrics |
|
||||
| Body Builders | [src/utils/build-bodies.md](src/utils/build-bodies.md) | Pure camelCase → snake_case translators |
|
||||
| Error Classifier | [src/utils/classify-error.md](src/utils/classify-error.md) | Pure status + retry-after classifier |
|
||||
| Errors | [src/errors/telegram-client-error.md](src/errors/telegram-client-error.md) | Code registry + typed error class |
|
||||
| Config Schema | [src/types/telegram-bot-config.md](src/types/telegram-bot-config.md) | Zod validation at the boundary |
|
||||
| Types | [src/types/telegram-client.types.md](src/types/telegram-client.types.md) | Public type surface |
|
||||
|
||||
## Subpath Exports
|
||||
|
||||
```ts
|
||||
import { createTelegramBotClient } from "@suaveplan/telegram-client";
|
||||
import {
|
||||
TELEGRAM_CLIENT_CODES,
|
||||
TelegramClientError,
|
||||
} from "@suaveplan/telegram-client/errors";
|
||||
import type { TelegramMessage } from "@suaveplan/telegram-client/types";
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `TELEGRAM_CLIENT.CONFIG_MISSING` | Config rejected by Zod schema (missing token, invalid URL, etc.) |
|
||||
| `TELEGRAM_CLIENT.TIMEOUT` | Request aborted before completion |
|
||||
| `TELEGRAM_CLIENT.AUTH_ERROR` | Telegram returned HTTP 401 |
|
||||
| `TELEGRAM_CLIENT.RATE_LIMITED` | Telegram returned HTTP 429 (`context.retryAfterMs` set if parseable) |
|
||||
| `TELEGRAM_CLIENT.CHAT_NOT_FOUND` | HTTP 404, or HTTP 400 with `chat not found` / `channel not found` |
|
||||
| `TELEGRAM_CLIENT.BOT_BLOCKED` | HTTP 403 with `bot was blocked` / `user is deactivated` |
|
||||
| `TELEGRAM_CLIENT.FORBIDDEN` | Other HTTP 403, or HTTP 400 with `not enough rights` / `forbidden` |
|
||||
| `TELEGRAM_CLIENT.API_ERROR` | Catch-all for unclassified Telegram failures |
|
||||
|
||||
## OTel Instruments
|
||||
|
||||
- Counter `telegram_client.request.total` — keyed by `{ method, outcome }`
|
||||
- Histogram `telegram_client.request.duration_ms` — keyed by `{ method, outcome }`
|
||||
- `outcome ∈ { "success", "error", "timeout" }`
|
||||
|
||||
Call `resetTelegramClientMetrics()` from tests after replacing the global
|
||||
`MeterProvider` so each test sees a fresh meter.
|
||||
|
||||
## Verification
|
||||
|
||||
- Tests use injected `fetch` / `clock` — no mocks, no network.
|
||||
- 100% istanbul coverage on touched modules.
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Telegram Bot Client
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TelegramBotClient` is the public, runtime-agnostic Telegram Bot API client.
|
||||
It wraps the handful of Bot API endpoints used in production bot workflows
|
||||
(`sendMessage`, `editMessageText`, `deleteMessage`, the six media variants,
|
||||
`getChat`, `getMe`) behind a typed, injectable surface. The class itself is
|
||||
small; the value is the contract — Zod-validated config, injectable `fetch`
|
||||
and `clock`, typed errors via `TelegramClientError`, and OTel counters
|
||||
and histograms emitted on every code path.
|
||||
|
||||
The client is intentionally stateless beyond construction: every method is a
|
||||
pure mapping from a typed input to a single Bot API call. Retry, throttling,
|
||||
and queueing belong to a higher layer (a downstream `@suaveplan/notifications-*`
|
||||
package or `@suaveplan/retry`) so this client stays focused on protocol
|
||||
fidelity and never owns its own scheduler.
|
||||
|
||||
## Features
|
||||
|
||||
- Bot API surface — `sendMessage`, `editMessage`, `deleteMessage`, `sendMedia`,
|
||||
`getChat`, `getMe`
|
||||
- Zod validation at the construction boundary (`TelegramBotConfigSchema`)
|
||||
- Injectable `fetch` and `clock` — tests do not patch globals
|
||||
- `AbortController`-based per-client timeout (default 10s, override via `timeoutMs`)
|
||||
- Custom base URL via `baseUrl` (default `https://api.telegram.org`)
|
||||
- Typed errors — every failure is a `TelegramClientError` with one of the
|
||||
eight `TELEGRAM_CLIENT_CODES` values
|
||||
- OTel counter `telegram_client.request.total` and histogram
|
||||
`telegram_client.request.duration_ms`, keyed by `{ method, outcome }`
|
||||
- `outcome ∈ { success, error, timeout }`
|
||||
- `resetTelegramClientMetrics()` — clear the cached meter handles in tests
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createTelegramBotClient } from "@suaveplan/telegram-client";
|
||||
|
||||
const bot = createTelegramBotClient({ token: process.env.TELEGRAM_TOKEN! });
|
||||
const sent = await bot.sendMessage({
|
||||
chatId: 12345,
|
||||
text: "Hello",
|
||||
});
|
||||
console.log(sent.messageId);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTelegramBotClient,
|
||||
resetTelegramClientMetrics,
|
||||
TELEGRAM_CLIENT_CODES,
|
||||
TelegramClientError,
|
||||
type FetchLike,
|
||||
} from "@suaveplan/telegram-client";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
// 1. Wire an in-memory MeterProvider for tests / staging.
|
||||
metrics.setGlobalMeterProvider(myInMemoryProvider);
|
||||
resetTelegramClientMetrics();
|
||||
|
||||
// 2. Inject a deterministic fetch + clock for golden-path tests.
|
||||
const fetch: FetchLike = async (url, init) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () =>
|
||||
JSON.stringify({ ok: true, result: { id: 1, is_bot: true } }),
|
||||
json: async () => ({ ok: true, result: {} }),
|
||||
});
|
||||
|
||||
const bot = createTelegramBotClient(
|
||||
{ token: "test:token", timeoutMs: 5_000 },
|
||||
{ fetch, clock: () => 1_700_000_000_000 },
|
||||
);
|
||||
|
||||
// 3. Send with a fully-featured reply markup.
|
||||
try {
|
||||
await bot.sendMessage({
|
||||
chatId: "@my_channel",
|
||||
text: "*Pick one*",
|
||||
parseMode: "MarkdownV2",
|
||||
disableWebPagePreview: true,
|
||||
replyMarkup: {
|
||||
inlineKeyboard: [
|
||||
[
|
||||
{ text: "Docs", url: "https://core.telegram.org/bots/api" },
|
||||
{ text: "Approve", callbackData: "approve" },
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === TELEGRAM_CLIENT_CODES.RATE_LIMITED
|
||||
) {
|
||||
const ctx = err.context as { retryAfterMs?: number } | undefined;
|
||||
await scheduleRetry(ctx?.retryAfterMs ?? 1_000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createTelegramBotClient(config, injectables?) → TelegramBotClient`
|
||||
- `new TelegramBotClient(config, injectables?)`
|
||||
- `sendMessage(message: TelegramMessage) → Promise<TelegramSentMessage>`
|
||||
- `editMessage(chatId, messageId, text, parseMode?) → Promise<TelegramSentMessage>`
|
||||
- `deleteMessage(chatId, messageId) → Promise<boolean>`
|
||||
- `sendMedia(chatId, type, url, caption?) → Promise<TelegramSentMessage>`
|
||||
- `getChat(chatId) → Promise<TelegramChat>`
|
||||
- `getMe() → Promise<TelegramUser>`
|
||||
- `resetTelegramClientMetrics() → void` — test helper
|
||||
|
||||
`config` is validated against `TelegramBotConfigSchema`; failures throw
|
||||
`TelegramClientError(CONFIG_MISSING)`. `injectables.fetch` defaults to
|
||||
`globalThis.fetch`. `injectables.clock` defaults to `() => Date.now()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The client uses `AbortController` for timeouts. When the controller aborts,
|
||||
the underlying `fetch` rejects with an `AbortError`; the client maps that
|
||||
onto `TelegramClientError(TIMEOUT)` and records `outcome: "timeout"` on the
|
||||
OTel counter / histogram. Body read failures (caller-supplied `text()`
|
||||
rejecting) and JSON parse failures both map onto `API_ERROR` — the response
|
||||
is malformed enough that no narrower classification is meaningful. The OTel
|
||||
meter handles are cached at module scope and reset via
|
||||
`resetTelegramClientMetrics()`; tests call it after pointing the global
|
||||
provider at an in-memory recorder so every assertion sees fresh handles.
|
||||
The default `fetch` adapter is a thin wrapper that calls `globalThis.fetch`
|
||||
and casts the resulting `Response` to the structurally compatible
|
||||
`TelegramFetchResponse` contract.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Telegram Client Error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the namespaced, frozen error-code registry and the typed
|
||||
`TelegramClientError` class used by every runtime failure path in
|
||||
`@suaveplan/telegram-client`. Co-locating the codes with the error class
|
||||
guarantees that adding a new code is a single-file change and that the
|
||||
TypeScript union stays in lock-step with the runtime registry the
|
||||
OpenSpec conformance audit greps for. Every code listed in
|
||||
`TELEGRAM_CLIENT_CODES` is thrown by at least one production code path,
|
||||
pinned by a test in `telegram-client-error.test.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `TELEGRAM_CLIENT_CODES` — `defineCodes("TELEGRAM_CLIENT", […])` frozen record
|
||||
- `TelegramClientCode` — literal union of every code constant
|
||||
- `TelegramClientError` — extends `SuaveplanError<TelegramClientCode>` so
|
||||
callers can both `instanceof`-narrow and pattern-match on `.code`
|
||||
- Stable code identifiers survive minification and tree-shaking
|
||||
- Carries through `context`, `cause`, and `traceId` from the base options bundle
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
TELEGRAM_CLIENT_CODES,
|
||||
TelegramClientError,
|
||||
} from "@suaveplan/telegram-client/errors";
|
||||
|
||||
throw new TelegramClientError(
|
||||
TELEGRAM_CLIENT_CODES.CONFIG_MISSING,
|
||||
"Telegram bot token is required",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
TELEGRAM_CLIENT_CODES,
|
||||
TelegramClientError,
|
||||
type TelegramClientCode,
|
||||
} from "@suaveplan/telegram-client/errors";
|
||||
|
||||
function describeFailure(err: unknown): string {
|
||||
if (!(err instanceof TelegramClientError)) return "unknown";
|
||||
const code: TelegramClientCode = err.code;
|
||||
switch (code) {
|
||||
case TELEGRAM_CLIENT_CODES.RATE_LIMITED: {
|
||||
const ctx = err.context as { retryAfterMs?: number } | undefined;
|
||||
return `rate-limited; retry in ${ctx?.retryAfterMs ?? "?"}ms`;
|
||||
}
|
||||
case TELEGRAM_CLIENT_CODES.AUTH_ERROR:
|
||||
return "auth — rotate the bot token";
|
||||
case TELEGRAM_CLIENT_CODES.BOT_BLOCKED:
|
||||
return "user blocked the bot; mark the recipient as opted out";
|
||||
default:
|
||||
return `telegram failure: ${code}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`TELEGRAM_CLIENT_CODES` — readonly record:
|
||||
|
||||
| Constant | Value |
|
||||
|---|---|
|
||||
| `API_ERROR` | `TELEGRAM_CLIENT.API_ERROR` |
|
||||
| `AUTH_ERROR` | `TELEGRAM_CLIENT.AUTH_ERROR` |
|
||||
| `RATE_LIMITED` | `TELEGRAM_CLIENT.RATE_LIMITED` |
|
||||
| `CHAT_NOT_FOUND` | `TELEGRAM_CLIENT.CHAT_NOT_FOUND` |
|
||||
| `BOT_BLOCKED` | `TELEGRAM_CLIENT.BOT_BLOCKED` |
|
||||
| `CONFIG_MISSING` | `TELEGRAM_CLIENT.CONFIG_MISSING` |
|
||||
| `TIMEOUT` | `TELEGRAM_CLIENT.TIMEOUT` |
|
||||
| `FORBIDDEN` | `TELEGRAM_CLIENT.FORBIDDEN` |
|
||||
|
||||
`TelegramClientError`:
|
||||
|
||||
- `new TelegramClientError(code, messageKey, options?)`
|
||||
- Inherits `code`, `context`, `cause`, `timestamp`, `traceId` from `SuaveplanError`.
|
||||
- `name === "TelegramClientError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` produces a `Readonly<Record<KEY, "TELEGRAM_CLIENT.KEY">>` shape
|
||||
so consumers can write `TELEGRAM_CLIENT_CODES.AUTH_ERROR` and have TypeScript
|
||||
narrow the value to the literal `"TELEGRAM_CLIENT.AUTH_ERROR"`. The class
|
||||
extends the generic `SuaveplanError<TCode>` with `TelegramClientCode` so
|
||||
`instanceof TelegramClientError` narrows the `.code` field to the union of
|
||||
those eight literals without an additional assertion. Throwing every
|
||||
declared code from a real production path is enforced both by the spec audit
|
||||
script and by the `telegram-client-error.test.ts::"every declared code is
|
||||
thrown by a production path"` assertion.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Telegram Bot Config Schema
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod schema that validates `TelegramBotConfig` at the public
|
||||
construction boundary of `TelegramBotClient`. The schema is the runtime
|
||||
guardrail that converts the optimistic TypeScript interface into a hard,
|
||||
fail-fast check — empty tokens, invalid URLs, and non-positive timeouts are
|
||||
rejected with a structured `TelegramClientError` carrying the full Zod issue
|
||||
list, rather than allowing those bugs to surface as opaque network failures
|
||||
later in the request lifecycle.
|
||||
|
||||
Validating at the boundary lets every downstream module assume the config
|
||||
values are well-formed: `token` is a non-empty string, `baseUrl` is a valid
|
||||
absolute URL when supplied, and `timeoutMs` is a positive integer when
|
||||
supplied. Defaulting is intentionally NOT done in the schema (it is done in
|
||||
the client constructor) so the schema reflects only what the caller actually
|
||||
sent.
|
||||
|
||||
## Features
|
||||
|
||||
- `TelegramBotConfigSchema` — strict Zod schema; rejects unknown fields
|
||||
- Non-empty token requirement with explicit error message
|
||||
- `baseUrl` is `.url()` validated when present
|
||||
- `timeoutMs` is `.int().positive()` validated when present
|
||||
- `TelegramBotConfigParsed` — type inferred from the schema
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TelegramBotConfigSchema } from "@suaveplan/telegram-client/types";
|
||||
|
||||
const result = TelegramBotConfigSchema.safeParse({ token: "123:ABC" });
|
||||
if (!result.success) {
|
||||
console.error(result.error.issues);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TelegramBotConfigSchema } from "@suaveplan/telegram-client/types";
|
||||
|
||||
// In a factory that wraps config from env + CLI overrides.
|
||||
function loadTelegramConfig(env: Record<string, string | undefined>) {
|
||||
const result = TelegramBotConfigSchema.safeParse({
|
||||
token: env.TELEGRAM_BOT_TOKEN,
|
||||
baseUrl: env.TELEGRAM_BASE_URL,
|
||||
timeoutMs: env.TELEGRAM_TIMEOUT_MS
|
||||
? Number(env.TELEGRAM_TIMEOUT_MS)
|
||||
: undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid Telegram config: ${result.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TelegramBotConfigSchema: z.ZodObject<{ token, baseUrl?, timeoutMs? }>` (strict)
|
||||
- `TelegramBotConfigParsed = z.infer<typeof TelegramBotConfigSchema>`
|
||||
|
||||
The schema is `.strict()` — passing unknown keys produces a `ZodError` rather
|
||||
than silently dropping them. This keeps configuration drift visible.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema deliberately does not apply defaults; the client constructor
|
||||
applies `DEFAULT_BASE_URL` and `DEFAULT_TIMEOUT_MS` after validation succeeds.
|
||||
Splitting the responsibilities means schema test cases only exercise
|
||||
validation logic, never defaulting behaviour. This keeps both surfaces
|
||||
independently auditable. Construction failures are wrapped in a
|
||||
`TelegramClientError` with code `TELEGRAM_CLIENT.CONFIG_MISSING` so callers
|
||||
observe a uniform error type regardless of which field was invalid.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Telegram Client Types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the public TypeScript surface for `@suaveplan/telegram-client`. This
|
||||
module is the single source of truth for the shape of every option bundle,
|
||||
wire envelope, and inbound payload exchanged with the Telegram Bot API. Every
|
||||
other module — the client core, the error registry, the body builders, the
|
||||
classifier — imports its types from here so the wire contract stays consistent
|
||||
and any change to the public shape is a single-file edit.
|
||||
|
||||
The types are intentionally framework-agnostic. `FetchLike` and
|
||||
`TelegramFetchResponse` model the minimum subset of the global `fetch` /
|
||||
`Response` contract that the client needs. Passing `globalThis.fetch` works
|
||||
out of the box; passing a deterministic stub for tests works equally well.
|
||||
|
||||
## Features
|
||||
|
||||
- `TelegramBotConfig` — runtime config (token, optional baseUrl, optional timeoutMs)
|
||||
- `TelegramBotInjectables` — optional `fetch` and `clock` injection points
|
||||
- `FetchLike` / `TelegramFetchResponse` — minimal fetch contract
|
||||
- `TelegramMessage` + reply markup — outgoing message shape (camelCase)
|
||||
- `TelegramReplyMarkup`, `TelegramInlineButton`, `TelegramKeyboardButton` — keyboard primitives
|
||||
- `TelegramResponse<T>` — wire envelope for every Bot API call
|
||||
- `TelegramChat`, `TelegramUser`, `TelegramSentMessage` — incoming entity shapes
|
||||
- `TelegramDeliveryReport`, `TelegramDeliveryStatus` — observability shapes for downstream consumers
|
||||
- `TelegramMediaType` — discriminator for `sendMedia`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
TelegramBotConfig,
|
||||
TelegramMessage,
|
||||
} from "@suaveplan/telegram-client/types";
|
||||
|
||||
const config: TelegramBotConfig = { token: "123:ABC" };
|
||||
|
||||
const message: TelegramMessage = {
|
||||
chatId: 12345,
|
||||
text: "Hello",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
FetchLike,
|
||||
TelegramBotConfig,
|
||||
TelegramBotInjectables,
|
||||
TelegramMessage,
|
||||
} from "@suaveplan/telegram-client/types";
|
||||
|
||||
const config: TelegramBotConfig = {
|
||||
token: "123:ABC",
|
||||
baseUrl: "https://api.telegram.org",
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
|
||||
const fetchImpl: FetchLike = async (url, init) => {
|
||||
// Deterministic stub for tests — return a TelegramFetchResponse shape.
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => '{"ok":true,"result":{}}',
|
||||
json: async () => ({ ok: true, result: {} }),
|
||||
};
|
||||
};
|
||||
|
||||
const inject: TelegramBotInjectables = {
|
||||
fetch: fetchImpl,
|
||||
clock: () => 1_700_000_000_000,
|
||||
};
|
||||
|
||||
const msg: TelegramMessage = {
|
||||
chatId: 1,
|
||||
text: "Pick one",
|
||||
parseMode: "MarkdownV2",
|
||||
replyMarkup: {
|
||||
inlineKeyboard: [
|
||||
[
|
||||
{ text: "Yes", callbackData: "yes" },
|
||||
{ text: "No", callbackData: "no" },
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TelegramBotConfig` — `{ token: string; baseUrl?: string; timeoutMs?: number }`.
|
||||
- `TelegramBotInjectables` — `{ fetch?: FetchLike; clock?: Clock }`.
|
||||
- `FetchLike` — async URL + init function returning `TelegramFetchResponse`.
|
||||
- `TelegramFetchResponse` — `{ ok, status, statusText, headers.get(name), text(), json() }`.
|
||||
- `TelegramMessage` — `chatId`, `text`, plus optional `parseMode`, `disableWebPagePreview`, `disableNotification`, `replyToMessageId`, `replyMarkup`.
|
||||
- `TelegramReplyMarkup` — `inlineKeyboard`, `keyboard`, `removeKeyboard`, `oneTimeKeyboard`.
|
||||
- `TelegramInlineButton` — `text`, optional `url`, optional `callbackData`.
|
||||
- `TelegramKeyboardButton` — `text`, optional `requestContact`, optional `requestLocation`.
|
||||
- `TelegramResponse<T>` — wire envelope `{ ok, result?, errorCode?, description? }`.
|
||||
- `TelegramChat`, `TelegramUser`, `TelegramSentMessage` — incoming entities.
|
||||
- `TelegramDeliveryReport`, `TelegramDeliveryStatus` — downstream consumer shapes.
|
||||
- `TelegramMediaType` — literal union: `"photo" | "document" | "video" | "audio" | "voice" | "animation"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `FetchLike` contract intentionally omits the rich `RequestInit` surface
|
||||
(modes, credentials, integrity, etc.) because the Bot API only ever receives
|
||||
small JSON POSTs. Keeping the contract tight makes test stubs trivial and
|
||||
prevents drift between what we use and what we type. The
|
||||
`TelegramFetchResponse` shape is structurally compatible with the global
|
||||
`Response` type, so callers may pass `globalThis.fetch` (adapted internally
|
||||
inside the client) without a runtime wrapper. Camel-to-snake translation
|
||||
happens in `utils/build-bodies.ts`; public callers never see snake_case.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Build Bodies
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure, side-effect-free helpers that translate the camelCase public message /
|
||||
reply-markup shape used by callers into the snake_case wire shape that the
|
||||
Telegram Bot API expects. Splitting these helpers out of the client keeps
|
||||
the wire-format translation independently testable, makes it cheap to add
|
||||
new optional fields, and means every option permutation can be exercised
|
||||
without an HTTP round trip.
|
||||
|
||||
Telegram's Bot API mixes top-level fields (`chat_id`, `parse_mode`) with
|
||||
nested objects (`reply_markup.inline_keyboard`). Hand-rolling each
|
||||
conversion in the client would scatter snake-case knowledge across the
|
||||
codebase; centralising it here means the wire-format contract is one file
|
||||
to audit when Telegram releases a new option.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildSendMessageBody(message)` — converts `TelegramMessage` → wire body
|
||||
- `buildReplyMarkup(markup)` — converts `TelegramReplyMarkup` → wire markup
|
||||
- `MEDIA_METHOD_MAP` — frozen record of `TelegramMediaType` → Bot API method
|
||||
- Drops optional fields when falsy/absent — minimises wire payload
|
||||
- Preserves caller-provided `chatId` type (string or number) — Telegram accepts both
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildSendMessageBody } from "@suaveplan/telegram-client";
|
||||
|
||||
const body = buildSendMessageBody({
|
||||
chatId: 100,
|
||||
text: "Hello",
|
||||
parseMode: "HTML",
|
||||
});
|
||||
// body === { chat_id: 100, text: "Hello", parse_mode: "HTML" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildReplyMarkup,
|
||||
buildSendMessageBody,
|
||||
MEDIA_METHOD_MAP,
|
||||
} from "@suaveplan/telegram-client";
|
||||
|
||||
// Compose a fully-featured message body with inline + custom keyboards.
|
||||
const body = buildSendMessageBody({
|
||||
chatId: "@my_channel",
|
||||
text: "*Pick one*",
|
||||
parseMode: "MarkdownV2",
|
||||
disableWebPagePreview: true,
|
||||
replyToMessageId: 42,
|
||||
replyMarkup: {
|
||||
inlineKeyboard: [
|
||||
[
|
||||
{ text: "Docs", url: "https://core.telegram.org/bots/api" },
|
||||
{ text: "Yes", callbackData: "yes" },
|
||||
],
|
||||
],
|
||||
oneTimeKeyboard: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Pick the Bot API method for a media upload.
|
||||
const method = MEDIA_METHOD_MAP.photo; // "sendPhoto"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildSendMessageBody(message: TelegramMessage): Record<string, unknown>`
|
||||
- Always emits `chat_id` and `text`.
|
||||
- Emits `parse_mode`, `disable_web_page_preview`, `disable_notification`,
|
||||
`reply_to_message_id`, `reply_markup` only when the corresponding camelCase
|
||||
field is truthy.
|
||||
- `buildReplyMarkup(markup: TelegramReplyMarkup): Record<string, unknown>`
|
||||
- Emits `inline_keyboard` when `inlineKeyboard` is set; each button is
|
||||
`{ text, url?, callback_data? }`.
|
||||
- Emits `keyboard` when `keyboard` is set; each button is
|
||||
`{ text, request_contact?, request_location? }`.
|
||||
- Emits `remove_keyboard: true` / `one_time_keyboard: true` when requested.
|
||||
- `MEDIA_METHOD_MAP: Record<TelegramMediaType, "send<Type>">` — frozen.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helpers are pure: identical input → identical output, no global state,
|
||||
no `Date.now()`/`Math.random()`. That makes the test suite a flat property
|
||||
table — one expectation per option combination — and keeps coverage trivial.
|
||||
The "emit-only-when-truthy" rule is important: Telegram interprets
|
||||
`disable_notification: false` as the silent-mode opt-out, but a payload that
|
||||
never sent the field at all reverts to the chat's default. To match upstream
|
||||
behaviour exactly we drop the field when callers leave it `undefined` /
|
||||
`false` and only include it when the caller explicitly opts in.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Classify Error
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure helpers that turn an outgoing-protocol failure (HTTP status + Telegram
|
||||
`description` + parsed body) into a stable `TelegramClientCode` and decode
|
||||
Telegram's various retry-after hints into a single duration in milliseconds.
|
||||
Keeping the classifier free of side effects means every status × description
|
||||
× header combination can be exercised by a flat unit-test table — there is no
|
||||
HTTP transport involved and no global state to reset between cases.
|
||||
|
||||
The classifier embeds the small but real knowledge gap between the HTTP
|
||||
status the Bot API returns and the failure mode callers actually care about:
|
||||
HTTP 400 with description "chat not found" is semantically a 404 from the
|
||||
caller's point of view, and a 403 with "bot was blocked" is materially
|
||||
different from a generic 403. Centralising this mapping means every Bot API
|
||||
call site benefits from the same nuanced error taxonomy.
|
||||
|
||||
## Features
|
||||
|
||||
- `classifyStatus(status, description, data)` — pure `TelegramClientCode` resolver
|
||||
- Matches `chat not found`, `channel not found`, `bot was blocked`,
|
||||
`user is deactivated`, `not enough rights`, `forbidden` case-insensitively
|
||||
- `parseRetryAfterMs(response, data, clock)` — pure retry-after decoder
|
||||
- Honours: body `retry_after`, body `parameters.retry_after`,
|
||||
header `retry-after` (seconds or HTTP-date), header `x-ratelimit-reset`
|
||||
(epoch seconds, epoch millis, or ISO date)
|
||||
- Clamps negative durations to 0
|
||||
- Returns `undefined` when no hint is parseable
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { classifyStatus, parseRetryAfterMs } from "@suaveplan/telegram-client";
|
||||
|
||||
const code = classifyStatus(401, "Unauthorized", { ok: false });
|
||||
// "TELEGRAM_CLIENT.AUTH_ERROR"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyStatus,
|
||||
parseRetryAfterMs,
|
||||
} from "@suaveplan/telegram-client";
|
||||
|
||||
// Decoding a 429 envelope in a fan-out retry wrapper.
|
||||
const response = await fetchTelegram();
|
||||
const data = (await response.json()) as {
|
||||
ok: boolean;
|
||||
description?: string;
|
||||
parameters?: { retry_after?: number };
|
||||
};
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfterMs = parseRetryAfterMs(response, data, () => Date.now());
|
||||
if (retryAfterMs !== undefined) {
|
||||
await scheduleAfter(retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
const code = classifyStatus(
|
||||
response.status,
|
||||
data.description ?? "",
|
||||
data,
|
||||
);
|
||||
recordOutcome(code);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `classifyStatus(status, description, data) → TelegramClientCode`
|
||||
- 401 → `AUTH_ERROR`
|
||||
- 403 with `bot blocked` / `user deactivated` → `BOT_BLOCKED`
|
||||
- other 403 → `FORBIDDEN`
|
||||
- 404 → `CHAT_NOT_FOUND`
|
||||
- 400 with `chat not found` / `channel not found` → `CHAT_NOT_FOUND`
|
||||
- 400 with `not enough rights` / `forbidden` → `FORBIDDEN`
|
||||
- everything else → `API_ERROR`
|
||||
- `parseRetryAfterMs(response, data, clock) → number | undefined`
|
||||
- Order: body `retry_after` → body `parameters.retry_after` → header
|
||||
`retry-after` → header `x-ratelimit-reset`
|
||||
- Returns `undefined` when nothing is parseable
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The matchers use lazy-quantifier regexes (`{0,20}`) to tolerate the small
|
||||
phrasing variations Telegram has shipped over time ("bot was blocked",
|
||||
"bot is blocked", "user is deactivated") without committing to a brittle
|
||||
exact-string match. The retry decoder uses the injected `clock` rather than
|
||||
`Date.now()` so tests can pin both the request timestamp and the reset
|
||||
boundary to deterministic values. Negative durations (a `retry-after` HTTP
|
||||
date in the past) clamp to 0 so callers never sleep on a past timestamp.
|
||||
Reference in New Issue
Block a user