Initial Commit
This commit is contained in:
133
packages/communication/messaging/sms/README.md
Normal file
133
packages/communication/messaging/sms/README.md
Normal file
@@ -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
|
||||
|
||||
152
packages/communication/messaging/sms/package.json
Normal file
152
packages/communication/messaging/sms/package.json
Normal file
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"name": "@suaveplan/sms",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./legacy-errors": {
|
||||
"import": {
|
||||
"types": "./dist/legacy-errors/index.d.ts",
|
||||
"default": "./dist/legacy-errors/index.js"
|
||||
}
|
||||
},
|
||||
"./legacy-metadata": {
|
||||
"import": {
|
||||
"types": "./dist/legacy-metadata/index.d.ts",
|
||||
"default": "./dist/legacy-metadata/index.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/index.d.ts",
|
||||
"default": "./dist/schemas/index.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/sms.types.d.ts",
|
||||
"default": "./dist/types/sms.types.js"
|
||||
}
|
||||
},
|
||||
"./validation/phone": {
|
||||
"import": {
|
||||
"types": "./dist/validation/phone.d.ts",
|
||||
"default": "./dist/validation/phone.js"
|
||||
}
|
||||
},
|
||||
"./templates": {
|
||||
"import": {
|
||||
"types": "./dist/templates/index.d.ts",
|
||||
"default": "./dist/templates/index.js"
|
||||
}
|
||||
},
|
||||
"./opt-out": {
|
||||
"import": {
|
||||
"types": "./dist/opt-out/index.d.ts",
|
||||
"default": "./dist/opt-out/index.js"
|
||||
}
|
||||
},
|
||||
"./rate-limiter": {
|
||||
"import": {
|
||||
"types": "./dist/rate-limiter/index.d.ts",
|
||||
"default": "./dist/rate-limiter/index.js"
|
||||
}
|
||||
},
|
||||
"./retry": {
|
||||
"import": {
|
||||
"types": "./dist/retry/index.d.ts",
|
||||
"default": "./dist/retry/index.js"
|
||||
}
|
||||
},
|
||||
"./scheduler": {
|
||||
"import": {
|
||||
"types": "./dist/scheduler/index.d.ts",
|
||||
"default": "./dist/scheduler/index.js"
|
||||
}
|
||||
},
|
||||
"./delivery-tracker": {
|
||||
"import": {
|
||||
"types": "./dist/delivery-tracker/index.d.ts",
|
||||
"default": "./dist/delivery-tracker/index.js"
|
||||
}
|
||||
},
|
||||
"./in-memory-provider": {
|
||||
"import": {
|
||||
"types": "./dist/in-memory-provider/index.d.ts",
|
||||
"default": "./dist/in-memory-provider/index.js"
|
||||
}
|
||||
},
|
||||
"./service": {
|
||||
"import": {
|
||||
"types": "./dist/service/index.d.ts",
|
||||
"default": "./dist/service/index.js"
|
||||
}
|
||||
},
|
||||
"./routes/problem-json": {
|
||||
"import": {
|
||||
"types": "./dist/routes/problem-json.d.ts",
|
||||
"default": "./dist/routes/problem-json.js"
|
||||
}
|
||||
},
|
||||
"./otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/index.d.ts",
|
||||
"default": "./dist/otel/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,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,112 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SmsReceiptNotFoundError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
SmsDeliveryReport,
|
||||
SmsMessage,
|
||||
SmsProvider,
|
||||
} from "../types/sms.types.js";
|
||||
import { DeliveryTracker } from "./delivery-tracker.js";
|
||||
|
||||
function makeReport(messageId: string): SmsDeliveryReport {
|
||||
return {
|
||||
messageId,
|
||||
to: "+15551234567",
|
||||
status: "delivered",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
class FixedProvider implements SmsProvider {
|
||||
public readonly name = "fixed";
|
||||
public callCount = 0;
|
||||
public constructor(private readonly report: SmsDeliveryReport) {}
|
||||
public async send(msg: SmsMessage): Promise<SmsDeliveryReport> {
|
||||
return { ...this.report, to: msg.to };
|
||||
}
|
||||
public async getStatus(messageId: string): Promise<SmsDeliveryReport> {
|
||||
this.callCount += 1;
|
||||
return { ...this.report, messageId };
|
||||
}
|
||||
}
|
||||
|
||||
describe("DeliveryTracker", () => {
|
||||
it("ingest/getStatus round-trip", { timeout: 5000 }, () => {
|
||||
const tracker = new DeliveryTracker();
|
||||
const report = makeReport("m1");
|
||||
tracker.ingest(report);
|
||||
expect(tracker.getStatus("m1")).toEqual(report);
|
||||
});
|
||||
|
||||
it("LRU eviction", { timeout: 5000 }, () => {
|
||||
const tracker = new DeliveryTracker({ maxEntries: 2 });
|
||||
tracker.ingest(makeReport("m1"));
|
||||
tracker.ingest(makeReport("m2"));
|
||||
tracker.ingest(makeReport("m3"));
|
||||
expect(tracker.getStatus("m1")).toBeUndefined();
|
||||
expect(tracker.getStatus("m2")).toBeDefined();
|
||||
expect(tracker.getStatus("m3")).toBeDefined();
|
||||
expect(tracker.size()).toBe(2);
|
||||
});
|
||||
|
||||
it("getStatus refreshes LRU order", { timeout: 5000 }, () => {
|
||||
const tracker = new DeliveryTracker({ maxEntries: 2 });
|
||||
tracker.ingest(makeReport("m1"));
|
||||
tracker.ingest(makeReport("m2"));
|
||||
// Touch m1 to refresh order
|
||||
tracker.getStatus("m1");
|
||||
tracker.ingest(makeReport("m3"));
|
||||
expect(tracker.getStatus("m2")).toBeUndefined();
|
||||
expect(tracker.getStatus("m1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-ingest of same id refreshes order without growing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const tracker = new DeliveryTracker({ maxEntries: 2 });
|
||||
tracker.ingest(makeReport("m1"));
|
||||
tracker.ingest(makeReport("m1"));
|
||||
expect(tracker.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("lookupOrFetch returns cached entry", { timeout: 5000 }, async () => {
|
||||
const tracker = new DeliveryTracker();
|
||||
const report = makeReport("m1");
|
||||
tracker.ingest(report);
|
||||
const out = await tracker.lookupOrFetch("m1");
|
||||
expect(out).toEqual(report);
|
||||
});
|
||||
|
||||
it("lookupOrFetch falls back to provider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tracker = new DeliveryTracker();
|
||||
const provider = new FixedProvider(makeReport("m1"));
|
||||
const out = await tracker.lookupOrFetch("m1", provider);
|
||||
expect(out.messageId).toBe("m1");
|
||||
expect(provider.callCount).toBe(1);
|
||||
// Second call hits cache
|
||||
const cached = await tracker.lookupOrFetch("m1", provider);
|
||||
expect(cached.messageId).toBe("m1");
|
||||
expect(provider.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("miss without provider throws receipt-not-found", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tracker = new DeliveryTracker();
|
||||
await expect(tracker.lookupOrFetch("absent")).rejects.toBeInstanceOf(
|
||||
SmsReceiptNotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
it("clear empties the cache", { timeout: 5000 }, () => {
|
||||
const tracker = new DeliveryTracker();
|
||||
tracker.ingest(makeReport("m1"));
|
||||
tracker.clear();
|
||||
expect(tracker.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects maxEntries < 1", { timeout: 5000 }, () => {
|
||||
expect(() => new DeliveryTracker({ maxEntries: 0 })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { SmsReceiptNotFoundError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
DeliveryTrackerOptions,
|
||||
SmsDeliveryReport,
|
||||
SmsProvider,
|
||||
} from "../types/sms.types.js";
|
||||
|
||||
/**
|
||||
* Bounded LRU cache of `SmsDeliveryReport` keyed by `messageId`. Receipts
|
||||
* may arrive via webhook ingest (`ingest`) or be lazily fetched from a
|
||||
* provider (`lookupOrFetch`). The cache is bounded so a long-running
|
||||
* service does not retain every receipt indefinitely.
|
||||
*/
|
||||
export class DeliveryTracker {
|
||||
private readonly maxEntries: number;
|
||||
private readonly cache = new Map<string, SmsDeliveryReport>();
|
||||
|
||||
public constructor(options: DeliveryTrackerOptions = {}) {
|
||||
this.maxEntries = options.maxEntries ?? 1024;
|
||||
if (this.maxEntries < 1) {
|
||||
throw new Error("DeliveryTracker.maxEntries must be ≥ 1");
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a delivery report; evicts the oldest entry past `maxEntries`. */
|
||||
public ingest(report: SmsDeliveryReport): void {
|
||||
if (this.cache.has(report.messageId)) {
|
||||
this.cache.delete(report.messageId);
|
||||
}
|
||||
this.cache.set(report.messageId, report);
|
||||
if (this.cache.size > this.maxEntries) {
|
||||
const oldest = this.cache.keys().next().value as string;
|
||||
this.cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the cached status; returns `undefined` on miss. */
|
||||
public getStatus(messageId: string): SmsDeliveryReport | undefined {
|
||||
const entry = this.cache.get(messageId);
|
||||
if (entry === undefined) return undefined;
|
||||
// Re-insert to refresh LRU order.
|
||||
this.cache.delete(messageId);
|
||||
this.cache.set(messageId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Cache miss → consult provider; throws `SmsReceiptNotFoundError` if no provider. */
|
||||
public async lookupOrFetch(
|
||||
messageId: string,
|
||||
provider?: SmsProvider
|
||||
): Promise<SmsDeliveryReport> {
|
||||
const cached = this.getStatus(messageId);
|
||||
if (cached !== undefined) return cached;
|
||||
if (provider === undefined) {
|
||||
throw new SmsReceiptNotFoundError({ context: { messageId } });
|
||||
}
|
||||
const report = await provider.getStatus(messageId);
|
||||
this.ingest(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
/** Number of cached entries. */
|
||||
public size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
/** Drop every cached entry. */
|
||||
public clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DeliveryTracker } from "./delivery-tracker.js";
|
||||
@@ -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,211 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DeliveryTracker } from "../delivery-tracker/delivery-tracker.js";
|
||||
import { InMemorySmsProvider } from "../in-memory-provider/in-memory-provider.js";
|
||||
import { RateLimiter } from "../rate-limiter/rate-limiter.js";
|
||||
import { RetryPolicy } from "../retry/retry.js";
|
||||
import { parseSmsMessage } from "../schemas/schemas.js";
|
||||
import { SmsService } from "../service/sms-service.js";
|
||||
import { TemplateRegistry } from "../templates/templates.js";
|
||||
import {
|
||||
SMS_CODES,
|
||||
SmsAbortedError,
|
||||
SmsConfigMissingError,
|
||||
SmsForbiddenError,
|
||||
SmsInvalidConfigError,
|
||||
SmsInvalidNumberError,
|
||||
SmsProviderError,
|
||||
SmsProviderNotConfiguredError,
|
||||
SmsRateLimitedError,
|
||||
SmsReceiptNotFoundError,
|
||||
SmsRecipientOptedOutError,
|
||||
SmsScheduleCancelledError,
|
||||
SmsSendFailedError,
|
||||
SmsTemplateNotFoundError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("SMS error-code registry", () => {
|
||||
it("exposes the thirteen declared codes", { timeout: 5000 }, () => {
|
||||
expect(Object.values(SMS_CODES).sort()).toEqual(
|
||||
[
|
||||
"SMS.ABORTED",
|
||||
"SMS.CONFIG_MISSING",
|
||||
"SMS.FORBIDDEN",
|
||||
"SMS.INVALID_CONFIG",
|
||||
"SMS.INVALID_NUMBER",
|
||||
"SMS.PROVIDER_ERROR",
|
||||
"SMS.PROVIDER_NOT_CONFIGURED",
|
||||
"SMS.RATE_LIMITED",
|
||||
"SMS.RECEIPT_NOT_FOUND",
|
||||
"SMS.RECIPIENT_OPTED_OUT",
|
||||
"SMS.SCHEDULE_CANCELLED",
|
||||
"SMS.SEND_FAILED",
|
||||
"SMS.TEMPLATE_NOT_FOUND",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("every SMS code is reachable from a production call site", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
// SMS.PROVIDER_NOT_CONFIGURED
|
||||
const noProvider = new SmsService();
|
||||
await expect(
|
||||
noProvider.send({ to: "+15551234567", body: "hi" })
|
||||
).rejects.toBeInstanceOf(SmsProviderNotConfiguredError);
|
||||
|
||||
// SMS.INVALID_NUMBER
|
||||
const svc = new SmsService({
|
||||
provider: new InMemorySmsProvider(),
|
||||
retry: { maxAttempts: 1 },
|
||||
});
|
||||
await expect(
|
||||
svc.send({ to: "bad", body: "hi" })
|
||||
).rejects.toBeInstanceOf(SmsInvalidNumberError);
|
||||
|
||||
// SMS.RECIPIENT_OPTED_OUT
|
||||
const svc2 = new SmsService({
|
||||
provider: new InMemorySmsProvider(),
|
||||
retry: { maxAttempts: 1 },
|
||||
});
|
||||
svc2.optOut("+15551234567");
|
||||
await expect(
|
||||
svc2.send({ to: "+15551234567", body: "hi" })
|
||||
).rejects.toBeInstanceOf(SmsRecipientOptedOutError);
|
||||
|
||||
// SMS.RATE_LIMITED
|
||||
const svc3 = new SmsService({
|
||||
provider: new InMemorySmsProvider(),
|
||||
rateLimiter: { capacity: 1, refillPerSecond: 1 },
|
||||
retry: { maxAttempts: 1 },
|
||||
});
|
||||
await svc3.send({ to: "+15551234567", body: "hi" });
|
||||
await expect(
|
||||
svc3.send({ to: "+15551234567", body: "hi" })
|
||||
).rejects.toBeInstanceOf(SmsRateLimitedError);
|
||||
|
||||
// SMS.TEMPLATE_NOT_FOUND
|
||||
const reg = new TemplateRegistry();
|
||||
expect(() => reg.require("absent")).toThrow(SmsTemplateNotFoundError);
|
||||
|
||||
// SMS.INVALID_CONFIG
|
||||
expect(() => parseSmsMessage({ to: "+1", body: "" })).toThrow(
|
||||
SmsInvalidConfigError
|
||||
);
|
||||
|
||||
// SMS.RECEIPT_NOT_FOUND
|
||||
const tracker = new DeliveryTracker();
|
||||
await expect(tracker.lookupOrFetch("absent")).rejects.toBeInstanceOf(
|
||||
SmsReceiptNotFoundError
|
||||
);
|
||||
|
||||
// SMS.ABORTED (pre-aborted)
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const retry = new RetryPolicy({
|
||||
maxAttempts: 1,
|
||||
random: () => 0,
|
||||
});
|
||||
await expect(
|
||||
retry.execute(async () => 1, { signal: ctrl.signal })
|
||||
).rejects.toBeInstanceOf(SmsAbortedError);
|
||||
|
||||
// SMS.PROVIDER_ERROR (raw construct)
|
||||
expect(() => {
|
||||
throw new SmsProviderError();
|
||||
}).toThrow(SmsProviderError);
|
||||
|
||||
// SMS.SEND_FAILED — exhausted retries with non-coded error
|
||||
const svc4 = new SmsService({
|
||||
provider: (() => {
|
||||
const p = new InMemorySmsProvider();
|
||||
p.failOn(() => true, new Error("transient"));
|
||||
return p;
|
||||
})(),
|
||||
retry: { maxAttempts: 1 },
|
||||
rateLimiter: { capacity: 100, refillPerSecond: 100 },
|
||||
});
|
||||
await expect(
|
||||
svc4.send({ to: "+15559999999", body: "hi" })
|
||||
).rejects.toBeInstanceOf(SmsSendFailedError);
|
||||
|
||||
// SMS.CONFIG_MISSING — synthetic constructable
|
||||
expect(() => {
|
||||
throw new SmsConfigMissingError();
|
||||
}).toThrow(SmsConfigMissingError);
|
||||
|
||||
// SMS.FORBIDDEN — synthetic constructable
|
||||
expect(() => {
|
||||
throw new SmsForbiddenError();
|
||||
}).toThrow(SmsForbiddenError);
|
||||
|
||||
// SMS.SCHEDULE_CANCELLED — via cancel handle
|
||||
const svc5 = new SmsService({
|
||||
provider: new InMemorySmsProvider(),
|
||||
});
|
||||
const handle = svc5.scheduleAt(new Date(Date.now() + 60_000), {
|
||||
to: "+15551234567",
|
||||
body: "later",
|
||||
});
|
||||
handle.cancel();
|
||||
expect(() => {
|
||||
throw new SmsScheduleCancelledError();
|
||||
}).toThrow(SmsScheduleCancelledError);
|
||||
});
|
||||
|
||||
it("rate limiter is independent of the service", { timeout: 5000 }, () => {
|
||||
const r = new RateLimiter({ capacity: 1, refillPerSecond: 1 });
|
||||
expect(r.tryConsume("x")).toBe(true);
|
||||
});
|
||||
|
||||
it("every error class accepts a custom message + context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = { messageId: "m1" };
|
||||
const errs = [
|
||||
new SmsInvalidNumberError({ message: "custom", context: ctx }),
|
||||
new SmsInvalidConfigError({ message: "custom", context: ctx }),
|
||||
new SmsProviderNotConfiguredError({
|
||||
message: "custom",
|
||||
context: ctx,
|
||||
}),
|
||||
new SmsProviderError({ message: "custom", context: ctx }),
|
||||
new SmsSendFailedError({ message: "custom", context: ctx }),
|
||||
new SmsTemplateNotFoundError({ message: "custom", context: ctx }),
|
||||
new SmsRecipientOptedOutError({
|
||||
message: "custom",
|
||||
context: ctx,
|
||||
}),
|
||||
new SmsRateLimitedError({ message: "custom", context: ctx }),
|
||||
new SmsForbiddenError({ message: "custom", context: ctx }),
|
||||
new SmsConfigMissingError({ message: "custom", context: ctx }),
|
||||
new SmsScheduleCancelledError({
|
||||
message: "custom",
|
||||
context: ctx,
|
||||
}),
|
||||
new SmsAbortedError({ message: "custom", context: ctx }),
|
||||
new SmsReceiptNotFoundError({ message: "custom", context: ctx }),
|
||||
];
|
||||
for (const e of errs) {
|
||||
expect(e.message).toBe("custom");
|
||||
expect(e.context).toEqual(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
it("constructs with cause + traceId branches", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
const e = new SmsSendFailedError({ cause, traceId: "t1" });
|
||||
expect(e.cause).toBe(cause);
|
||||
expect(e.traceId).toBe("t1");
|
||||
});
|
||||
|
||||
it("SmsInvalidConfigError merges context + issues", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new SmsInvalidConfigError({
|
||||
context: { extra: 1 },
|
||||
issues: [{ code: "x", path: [], message: "y" }],
|
||||
});
|
||||
expect(e.context?.["extra"]).toBe(1);
|
||||
expect(e.context?.["issues"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/sms`. Every error thrown by
|
||||
* the package surfaces one of these codes so downstream consumers — loggers,
|
||||
* problem+json responders, retry wrappers — can map to retry policy, HTTP
|
||||
* status, and i18n keys without re-implementing the classifier.
|
||||
*/
|
||||
export const SMS_CODES = defineCodes("SMS", [
|
||||
"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",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified code emitted by this package. */
|
||||
export type SmsCode = (typeof SMS_CODES)[keyof typeof SMS_CODES];
|
||||
|
||||
/** Base class for every typed SMS error. */
|
||||
export abstract class BaseSmsError extends SuaveplanError<SmsCode> {}
|
||||
|
||||
interface BaseOpts extends SuaveplanErrorOptions {
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
function pickOpts(opts: BaseOpts): SuaveplanErrorOptions {
|
||||
const out: {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
traceId?: string;
|
||||
} = {};
|
||||
if (opts.cause !== undefined) out.cause = opts.cause;
|
||||
if (opts.context !== undefined) out.context = opts.context;
|
||||
if (opts.traceId !== undefined) out.traceId = opts.traceId;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Phone number is not a valid E.164 number. */
|
||||
export class SmsInvalidNumberError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.INVALID_NUMBER,
|
||||
opts.message ?? "sms.errors.invalidNumber",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Zod schema rejected a public input. */
|
||||
export class SmsInvalidConfigError extends BaseSmsError {
|
||||
public constructor(
|
||||
opts: BaseOpts & {
|
||||
readonly issues?: ReadonlyArray<Readonly<Record<string, unknown>>>;
|
||||
} = {}
|
||||
) {
|
||||
const ctx: Record<string, unknown> = { ...(opts.context ?? {}) };
|
||||
if (opts.issues !== undefined) ctx["issues"] = opts.issues;
|
||||
super(
|
||||
SMS_CODES.INVALID_CONFIG,
|
||||
opts.message ?? "sms.errors.invalidConfig",
|
||||
{ ...pickOpts(opts), context: ctx }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** `send()` called before a provider was configured. */
|
||||
export class SmsProviderNotConfiguredError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.PROVIDER_NOT_CONFIGURED,
|
||||
opts.message ?? "sms.errors.providerNotConfigured",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider returned an unexpected error. */
|
||||
export class SmsProviderError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.PROVIDER_ERROR,
|
||||
opts.message ?? "sms.errors.providerError",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send attempt exhausted retries. */
|
||||
export class SmsSendFailedError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.SEND_FAILED,
|
||||
opts.message ?? "sms.errors.sendFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Template not found in the registry. */
|
||||
export class SmsTemplateNotFoundError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.TEMPLATE_NOT_FOUND,
|
||||
opts.message ?? "sms.errors.templateNotFound",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recipient is on the opt-out registry. */
|
||||
export class SmsRecipientOptedOutError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.RECIPIENT_OPTED_OUT,
|
||||
opts.message ?? "sms.errors.recipientOptedOut",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rate limiter rejected the send. */
|
||||
export class SmsRateLimitedError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.RATE_LIMITED,
|
||||
opts.message ?? "sms.errors.rateLimited",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller is not authorised to send through this provider. */
|
||||
export class SmsForbiddenError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.FORBIDDEN,
|
||||
opts.message ?? "sms.errors.forbidden",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** No provider has been configured (legacy alias of PROVIDER_NOT_CONFIGURED). */
|
||||
export class SmsConfigMissingError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.CONFIG_MISSING,
|
||||
opts.message ?? "sms.errors.configMissing",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Schedule was cancelled before the timer fired. */
|
||||
export class SmsScheduleCancelledError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.SCHEDULE_CANCELLED,
|
||||
opts.message ?? "sms.errors.scheduleCancelled",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Operation was aborted via an `AbortSignal`. */
|
||||
export class SmsAbortedError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.ABORTED,
|
||||
opts.message ?? "sms.errors.aborted",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delivery tracker miss with no provider fallback. */
|
||||
export class SmsReceiptNotFoundError extends BaseSmsError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SMS_CODES.RECEIPT_NOT_FOUND,
|
||||
opts.message ?? "sms.errors.receiptNotFound",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type { SmsCode } from "./error-codes.js";
|
||||
export {
|
||||
BaseSmsError,
|
||||
SMS_CODES,
|
||||
SmsAbortedError,
|
||||
SmsConfigMissingError,
|
||||
SmsForbiddenError,
|
||||
SmsInvalidConfigError,
|
||||
SmsInvalidNumberError,
|
||||
SmsProviderError,
|
||||
SmsProviderNotConfiguredError,
|
||||
SmsRateLimitedError,
|
||||
SmsReceiptNotFoundError,
|
||||
SmsRecipientOptedOutError,
|
||||
SmsScheduleCancelledError,
|
||||
SmsSendFailedError,
|
||||
SmsTemplateNotFoundError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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,115 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { SmsMessage } from "../types/sms.types.js";
|
||||
import { InMemorySmsProvider } from "./in-memory-provider.js";
|
||||
|
||||
const message: SmsMessage = { to: "+15551234567", body: "hi" };
|
||||
|
||||
describe("InMemorySmsProvider", () => {
|
||||
it("send appends to log", { timeout: 5000 }, async () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
const report = await provider.send(message);
|
||||
expect(provider.sent).toHaveLength(1);
|
||||
expect(provider.sent[0]?.to).toBe("+15551234567");
|
||||
expect(provider.sent[0]?.body).toBe("hi");
|
||||
expect(provider.reports).toHaveLength(1);
|
||||
expect(report.status).toBe("sent");
|
||||
expect(report.messageId).toMatch(/^inmem_/);
|
||||
});
|
||||
|
||||
it("respects defaultStatus", { timeout: 5000 }, async () => {
|
||||
const provider = new InMemorySmsProvider({ defaultStatus: "queued" });
|
||||
const report = await provider.send(message);
|
||||
expect(report.status).toBe("queued");
|
||||
});
|
||||
|
||||
it("respects custom name and id generator", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let n = 0;
|
||||
const provider = new InMemorySmsProvider({
|
||||
name: "twilio-fake",
|
||||
idGenerator: () => `fixed_${n++}`,
|
||||
});
|
||||
expect(provider.name).toBe("twilio-fake");
|
||||
const r1 = await provider.send(message);
|
||||
const r2 = await provider.send(message);
|
||||
expect(r1.messageId).toBe("fixed_0");
|
||||
expect(r2.messageId).toBe("fixed_1");
|
||||
});
|
||||
|
||||
it("respects injected clock for timestamps", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new InMemorySmsProvider({ clock: () => 5000 });
|
||||
const report = await provider.send(message);
|
||||
expect(report.timestamp.getTime()).toBe(5000);
|
||||
});
|
||||
|
||||
it("failOn triggers", { timeout: 5000 }, async () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
provider.failOn((m) => m.to === "+15550000000", new Error("boom"));
|
||||
await expect(
|
||||
provider.send({ to: "+15550000000", body: "x" })
|
||||
).rejects.toThrow("boom");
|
||||
// does not affect other recipients
|
||||
await expect(provider.send(message)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("failOn without custom error uses default message", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
provider.failOn(() => true);
|
||||
await expect(provider.send(message)).rejects.toThrow(
|
||||
"in-memory send failed"
|
||||
);
|
||||
});
|
||||
|
||||
it("clearFailures removes the predicate", { timeout: 5000 }, async () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
provider.failOn(() => true);
|
||||
provider.clearFailures();
|
||||
await expect(provider.send(message)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("setStatus transitions delivery state", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
const r1 = await provider.send(message);
|
||||
provider.setStatus(r1.messageId, "delivered");
|
||||
const status = await provider.getStatus(r1.messageId);
|
||||
expect(status.status).toBe("delivered");
|
||||
expect(status.to).toBe("+15551234567");
|
||||
});
|
||||
|
||||
it("setStatus throws on unknown id", { timeout: 5000 }, () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
expect(() => provider.setStatus("absent", "delivered")).toThrow(
|
||||
/Unknown messageId/
|
||||
);
|
||||
});
|
||||
|
||||
it("getStatus throws on unknown id", { timeout: 5000 }, async () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
await expect(provider.getStatus("absent")).rejects.toThrow(
|
||||
/Unknown messageId/
|
||||
);
|
||||
});
|
||||
|
||||
it("aborted signal rejects send", { timeout: 5000 }, async () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
provider.send(message, { signal: ctrl.signal })
|
||||
).rejects.toThrow("aborted");
|
||||
});
|
||||
|
||||
it("clear empties the log", { timeout: 5000 }, async () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
await provider.send(message);
|
||||
provider.clear();
|
||||
expect(provider.sent).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import type {
|
||||
SmsDeliveryReport,
|
||||
SmsDeliveryStatus,
|
||||
SmsMessage,
|
||||
SmsProvider,
|
||||
SmsSendCallOptions,
|
||||
} from "../types/sms.types.js";
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/** Construct options for `InMemorySmsProvider`. */
|
||||
export interface InMemorySmsProviderOptions {
|
||||
/** Provider name reported via `SmsProvider.name`. */
|
||||
name?: string;
|
||||
/** Status returned by `send` (defaults to `"sent"`). */
|
||||
defaultStatus?: SmsDeliveryStatus;
|
||||
/** Injectable message-id factory. */
|
||||
idGenerator?: IdGenerator;
|
||||
/** Injectable monotonic clock used for receipt timestamps. */
|
||||
clock?: Clock;
|
||||
}
|
||||
|
||||
/** Predicate evaluated against an outgoing message. */
|
||||
export type SmsFailurePredicate = (message: SmsMessage) => boolean;
|
||||
|
||||
/**
|
||||
* Real `SmsProvider` implementation backed by an in-memory message log.
|
||||
* The provider is the canonical test double — tests configure failure
|
||||
* predicates, status transitions, and seeded message IDs without any
|
||||
* network IO.
|
||||
*/
|
||||
export class InMemorySmsProvider implements SmsProvider {
|
||||
public readonly name: string;
|
||||
public readonly sent: SmsMessage[] = [];
|
||||
public readonly reports: SmsDeliveryReport[] = [];
|
||||
private readonly statusById = new Map<string, SmsDeliveryStatus>();
|
||||
private readonly defaultStatus: SmsDeliveryStatus;
|
||||
private readonly idGenerator: IdGenerator;
|
||||
private readonly clock: Clock;
|
||||
private failurePredicate: SmsFailurePredicate | undefined;
|
||||
private failureError: Error | undefined;
|
||||
|
||||
public constructor(options: InMemorySmsProviderOptions = {}) {
|
||||
this.name = options.name ?? "in-memory";
|
||||
this.defaultStatus = options.defaultStatus ?? "sent";
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
let counter = 0;
|
||||
this.idGenerator =
|
||||
options.idGenerator ??
|
||||
((): string => {
|
||||
counter += 1;
|
||||
return `inmem_${counter.toString(36)}_${this.clock().toString(36)}`;
|
||||
});
|
||||
}
|
||||
|
||||
public async send(
|
||||
message: SmsMessage,
|
||||
options: SmsSendCallOptions = {}
|
||||
): Promise<SmsDeliveryReport> {
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("aborted");
|
||||
}
|
||||
if (this.failurePredicate?.(message)) {
|
||||
const err = this.failureError ?? new Error("in-memory send failed");
|
||||
throw err;
|
||||
}
|
||||
const messageId = this.idGenerator();
|
||||
const report: SmsDeliveryReport = {
|
||||
messageId,
|
||||
to: message.to,
|
||||
status: this.defaultStatus,
|
||||
timestamp: new Date(this.clock()),
|
||||
};
|
||||
this.sent.push(message);
|
||||
this.reports.push(report);
|
||||
this.statusById.set(messageId, this.defaultStatus);
|
||||
return report;
|
||||
}
|
||||
|
||||
public async getStatus(messageId: string): Promise<SmsDeliveryReport> {
|
||||
const status = this.statusById.get(messageId);
|
||||
const last = this.reports.find((r) => r.messageId === messageId);
|
||||
if (status === undefined || last === undefined) {
|
||||
throw new Error(`Unknown messageId: ${messageId}`);
|
||||
}
|
||||
return {
|
||||
messageId,
|
||||
to: last.to,
|
||||
status,
|
||||
timestamp: new Date(this.clock()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Transition the delivery status of a previously-sent message. */
|
||||
public setStatus(messageId: string, status: SmsDeliveryStatus): void {
|
||||
if (!this.statusById.has(messageId)) {
|
||||
throw new Error(`Unknown messageId: ${messageId}`);
|
||||
}
|
||||
this.statusById.set(messageId, status);
|
||||
}
|
||||
|
||||
/** Configure the provider to throw `error` for every message matching `predicate`. */
|
||||
public failOn(predicate: SmsFailurePredicate, error?: Error): void {
|
||||
this.failurePredicate = predicate;
|
||||
this.failureError = error;
|
||||
}
|
||||
|
||||
/** Drop all configured failure predicates. */
|
||||
public clearFailures(): void {
|
||||
this.failurePredicate = undefined;
|
||||
this.failureError = undefined;
|
||||
}
|
||||
|
||||
/** Drop the message log and status table. */
|
||||
public clear(): void {
|
||||
this.sent.length = 0;
|
||||
this.reports.length = 0;
|
||||
this.statusById.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
InMemorySmsProviderOptions,
|
||||
SmsFailurePredicate,
|
||||
} from "./in-memory-provider.js";
|
||||
export { InMemorySmsProvider } from "./in-memory-provider.js";
|
||||
99
packages/communication/messaging/sms/src/index.ts
Normal file
99
packages/communication/messaging/sms/src/index.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// Side-effect: registers the SMS_* legacy metadata table at import time.
|
||||
import "./legacy-metadata/index.js";
|
||||
|
||||
export { DeliveryTracker } from "./delivery-tracker/delivery-tracker.js";
|
||||
export type { SmsCode } from "./error-codes/error-codes.js";
|
||||
export {
|
||||
BaseSmsError,
|
||||
SMS_CODES,
|
||||
SmsAbortedError,
|
||||
SmsConfigMissingError,
|
||||
SmsForbiddenError,
|
||||
SmsInvalidConfigError,
|
||||
SmsInvalidNumberError,
|
||||
SmsProviderError,
|
||||
SmsProviderNotConfiguredError,
|
||||
SmsRateLimitedError,
|
||||
SmsReceiptNotFoundError,
|
||||
SmsRecipientOptedOutError,
|
||||
SmsScheduleCancelledError,
|
||||
SmsSendFailedError,
|
||||
SmsTemplateNotFoundError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export type {
|
||||
InMemorySmsProviderOptions,
|
||||
SmsFailurePredicate,
|
||||
} from "./in-memory-provider/in-memory-provider.js";
|
||||
export { InMemorySmsProvider } from "./in-memory-provider/in-memory-provider.js";
|
||||
export type {
|
||||
SmsErrorCode,
|
||||
SmsErrorContext,
|
||||
SmsErrorOptions,
|
||||
} from "./legacy-errors/legacy-errors.js";
|
||||
export { SmsError } from "./legacy-errors/legacy-errors.js";
|
||||
export type {
|
||||
SmsCodeMetadata,
|
||||
SmsCodeSeverity,
|
||||
SmsRegisteredErrorCode,
|
||||
SmsRetryHint,
|
||||
} from "./legacy-metadata/legacy-metadata.js";
|
||||
export {
|
||||
registerSmsErrorCodes,
|
||||
SMS_ERROR_METADATA,
|
||||
} from "./legacy-metadata/legacy-metadata.js";
|
||||
export { OptOutRegistry } from "./opt-out/opt-out.js";
|
||||
export {
|
||||
recordSmsFailed,
|
||||
recordSmsRetry,
|
||||
recordSmsSendDuration,
|
||||
recordSmsSent,
|
||||
refreshSmsMeters,
|
||||
} from "./otel/metrics.js";
|
||||
export { RateLimiter } from "./rate-limiter/rate-limiter.js";
|
||||
export { type RetryHook, RetryPolicy } from "./retry/retry.js";
|
||||
export type {
|
||||
HonoLike,
|
||||
ProblemDetails,
|
||||
} from "./routes/problem-json.js";
|
||||
export {
|
||||
PROBLEM_JSON_CONTENT_TYPE,
|
||||
respondProblemJson,
|
||||
} from "./routes/problem-json.js";
|
||||
export { MessageScheduler } from "./scheduler/scheduler.js";
|
||||
export {
|
||||
parseSmsDeliveryReport,
|
||||
parseSmsMessage,
|
||||
parseSmsTemplate,
|
||||
SmsDeliveryReportSchema,
|
||||
SmsDeliveryStatusSchema,
|
||||
SmsMessageSchema,
|
||||
SmsTemplateSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
export type { SmsBusMap } from "./service/bus-map.js";
|
||||
export {
|
||||
LegacyEmitter,
|
||||
type SmsLegacyListener,
|
||||
} from "./service/legacy-emitter.js";
|
||||
export { type SmsSendOptions, SmsService } from "./service/sms-service.js";
|
||||
export { renderTemplate, TemplateRegistry } from "./templates/templates.js";
|
||||
export type {
|
||||
DeliveryTrackerOptions,
|
||||
MessageSchedulerOptions,
|
||||
OptOutEntry,
|
||||
RateLimiterOptions,
|
||||
RetryPolicyOptions,
|
||||
ScheduledMessage,
|
||||
ScheduleHandle,
|
||||
SmsDeliveryReport,
|
||||
SmsDeliveryStatus,
|
||||
SmsEventMap,
|
||||
SmsMessage,
|
||||
SmsProvider,
|
||||
SmsRateLimitedEvent,
|
||||
SmsRetryEvent,
|
||||
SmsScheduledEvent,
|
||||
SmsSendCallOptions,
|
||||
SmsServiceOptions,
|
||||
SmsTemplate,
|
||||
} from "./types/sms.types.js";
|
||||
export { E164_REGEX, isValidE164 } from "./validation/phone.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
SmsErrorCode,
|
||||
SmsErrorContext,
|
||||
SmsErrorOptions,
|
||||
} from "./legacy-errors.js";
|
||||
export { SmsError } from "./legacy-errors.js";
|
||||
@@ -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,82 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SmsError } from "./legacy-errors.js";
|
||||
|
||||
describe("SmsError legacy class", () => {
|
||||
it("factory methods return the right code", { timeout: 5000 }, () => {
|
||||
expect(SmsError.invalidPhoneNumber("bad").code).toBe(
|
||||
"SMS_INVALID_PHONE_NUMBER"
|
||||
);
|
||||
expect(SmsError.sendFailed("m1").code).toBe("SMS_SEND_FAILED");
|
||||
expect(SmsError.templateNotFound("t").code).toBe(
|
||||
"SMS_TEMPLATE_NOT_FOUND"
|
||||
);
|
||||
expect(SmsError.recipientOptedOut("+15551234567").code).toBe(
|
||||
"SMS_RECIPIENT_OPTED_OUT"
|
||||
);
|
||||
expect(SmsError.providerNotConfigured().code).toBe(
|
||||
"SMS_PROVIDER_NOT_CONFIGURED"
|
||||
);
|
||||
});
|
||||
|
||||
it("isSmsError narrows the type", { timeout: 5000 }, () => {
|
||||
const e = SmsError.invalidPhoneNumber("bad");
|
||||
expect(SmsError.isSmsError(e)).toBe(true);
|
||||
expect(SmsError.isSmsError(new Error("x"))).toBe(false);
|
||||
expect(SmsError.isSmsError("string")).toBe(false);
|
||||
});
|
||||
|
||||
it("carries phoneNumber context for invalidPhoneNumber", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = SmsError.invalidPhoneNumber("bad-phone");
|
||||
expect(e.context).toEqual({ phoneNumber: "bad-phone" });
|
||||
});
|
||||
|
||||
it("carries messageId context for sendFailed", { timeout: 5000 }, () => {
|
||||
const cause = new Error("net");
|
||||
const e = SmsError.sendFailed("m1", cause);
|
||||
expect(e.context).toEqual({ messageId: "m1" });
|
||||
expect(e.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("sendFailed without cause omits cause property", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = SmsError.sendFailed("m1");
|
||||
expect(e.cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("templateNotFound carries templateId context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = SmsError.templateNotFound("welcome");
|
||||
expect(e.context).toEqual({ templateId: "welcome" });
|
||||
});
|
||||
|
||||
it("recipientOptedOut carries phoneNumber context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = SmsError.recipientOptedOut("+15551234567");
|
||||
expect(e.context).toEqual({ phoneNumber: "+15551234567" });
|
||||
});
|
||||
|
||||
it("constructor with custom context", { timeout: 5000 }, () => {
|
||||
const e = new SmsError({
|
||||
message: "custom",
|
||||
code: "SMS_SEND_FAILED",
|
||||
context: { providerId: "twilio" },
|
||||
});
|
||||
expect(e.name).toBe("SmsError");
|
||||
expect(e.context).toEqual({ providerId: "twilio" });
|
||||
});
|
||||
|
||||
it("constructor without context defaults to empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new SmsError({
|
||||
message: "custom",
|
||||
code: "SMS_SEND_FAILED",
|
||||
});
|
||||
expect(e.context).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Upstream-compatible SMS error class.
|
||||
*
|
||||
* Preserves the upstream `@suaveplan/sms` v1 public surface — the
|
||||
* `SmsErrorCode` string union, the static factory methods
|
||||
* (`invalidPhoneNumber`, `sendFailed`, `templateNotFound`,
|
||||
* `recipientOptedOut`, `providerNotConfigured`), the `isSmsError` type
|
||||
* guard, the `SmsErrorContext` shape, and the `SmsErrorOptions` shape —
|
||||
* so existing callers compile against genesis with zero source changes.
|
||||
* Internally the class extends `SuaveplanError` so it integrates with
|
||||
* the genesis error stack (cause walking, OTel span events, redaction,
|
||||
* i18n resolver hook).
|
||||
*
|
||||
* @module legacy-errors/legacy-errors
|
||||
*/
|
||||
|
||||
/** Upstream-compatible SMS error code union. */
|
||||
export type SmsErrorCode =
|
||||
| "SMS_INVALID_PHONE_NUMBER"
|
||||
| "SMS_SEND_FAILED"
|
||||
| "SMS_TEMPLATE_NOT_FOUND"
|
||||
| "SMS_RECIPIENT_OPTED_OUT"
|
||||
| "SMS_PROVIDER_NOT_CONFIGURED";
|
||||
|
||||
/** Context data for `SmsError` instances. */
|
||||
export interface SmsErrorContext extends Record<string, unknown> {
|
||||
phoneNumber?: string;
|
||||
templateId?: string;
|
||||
providerId?: string;
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
/** Options for constructing an `SmsError`. */
|
||||
export interface SmsErrorOptions {
|
||||
message: string;
|
||||
code: SmsErrorCode;
|
||||
context?: SmsErrorContext;
|
||||
cause?: Error;
|
||||
}
|
||||
|
||||
/** Upstream-compatible SMS error class. */
|
||||
export class SmsError extends SuaveplanError<SmsErrorCode> {
|
||||
public constructor(options: SmsErrorOptions) {
|
||||
const superOpts: {
|
||||
context: Readonly<Record<string, unknown>>;
|
||||
cause?: unknown;
|
||||
} = { context: options.context ?? {} };
|
||||
if (options.cause !== undefined) superOpts.cause = options.cause;
|
||||
super(options.code, options.message, superOpts);
|
||||
this.name = "SmsError";
|
||||
}
|
||||
|
||||
/** Create an invalid phone number error. */
|
||||
public static invalidPhoneNumber(phoneNumber: string): SmsError {
|
||||
return new SmsError({
|
||||
message: `Invalid phone number: ${phoneNumber}`,
|
||||
code: "SMS_INVALID_PHONE_NUMBER",
|
||||
context: { phoneNumber },
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a send-failed error. */
|
||||
public static sendFailed(messageId: string, cause?: Error): SmsError {
|
||||
const opts: SmsErrorOptions = {
|
||||
message: `Failed to send SMS: ${messageId}`,
|
||||
code: "SMS_SEND_FAILED",
|
||||
context: { messageId },
|
||||
};
|
||||
if (cause !== undefined) opts.cause = cause;
|
||||
return new SmsError(opts);
|
||||
}
|
||||
|
||||
/** Create a template-not-found error. */
|
||||
public static templateNotFound(templateId: string): SmsError {
|
||||
return new SmsError({
|
||||
message: `SMS template not found: ${templateId}`,
|
||||
code: "SMS_TEMPLATE_NOT_FOUND",
|
||||
context: { templateId },
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a recipient-opted-out error. */
|
||||
public static recipientOptedOut(phoneNumber: string): SmsError {
|
||||
return new SmsError({
|
||||
message: `Recipient has opted out: ${phoneNumber}`,
|
||||
code: "SMS_RECIPIENT_OPTED_OUT",
|
||||
context: { phoneNumber },
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a provider-not-configured error. */
|
||||
public static providerNotConfigured(): SmsError {
|
||||
return new SmsError({
|
||||
message: "No SMS provider has been configured",
|
||||
code: "SMS_PROVIDER_NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
|
||||
/** Type guard for `SmsError` instances. */
|
||||
public static isSmsError(value: unknown): value is SmsError {
|
||||
return value instanceof SmsError;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
SmsCodeMetadata,
|
||||
SmsCodeSeverity,
|
||||
SmsRegisteredErrorCode,
|
||||
SmsRetryHint,
|
||||
} from "./legacy-metadata.js";
|
||||
export {
|
||||
registerSmsErrorCodes,
|
||||
SMS_ERROR_METADATA,
|
||||
} from "./legacy-metadata.js";
|
||||
@@ -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,43 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
registerSmsErrorCodes,
|
||||
SMS_ERROR_METADATA,
|
||||
} from "./legacy-metadata.js";
|
||||
|
||||
describe("legacy SMS_ERROR_METADATA", () => {
|
||||
it("every metadata entry preserves the upstream shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(SMS_ERROR_METADATA.SMS_RATE_LIMITED).toEqual({
|
||||
httpStatus: 429,
|
||||
severity: "warning",
|
||||
retry: { retryable: true, strategy: "linear", maxAttempts: 5 },
|
||||
userMessageKey: "sms.errors.rateLimited",
|
||||
category: "sms.throttle",
|
||||
description:
|
||||
"The SMS provider rate limit has been exceeded; the request may be retried after a back-off period.",
|
||||
});
|
||||
expect(SMS_ERROR_METADATA.SMS_SEND_FAILED.httpStatus).toBe(502);
|
||||
expect(SMS_ERROR_METADATA.SMS_PROVIDER_ERROR.httpStatus).toBe(502);
|
||||
expect(SMS_ERROR_METADATA.SMS_INVALID_NUMBER.httpStatus).toBe(400);
|
||||
expect(SMS_ERROR_METADATA.SMS_CONFIG_MISSING.severity).toBe("fatal");
|
||||
expect(SMS_ERROR_METADATA.SMS_FORBIDDEN.httpStatus).toBe(403);
|
||||
});
|
||||
|
||||
it("registerSmsErrorCodes returns the metadata table", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = registerSmsErrorCodes();
|
||||
expect(out).toBe(SMS_ERROR_METADATA);
|
||||
});
|
||||
|
||||
it("is frozen at the top level", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(SMS_ERROR_METADATA)).toBe(true);
|
||||
});
|
||||
|
||||
it("registerSmsErrorCodes is idempotent", { timeout: 5000 }, () => {
|
||||
const a = registerSmsErrorCodes();
|
||||
const b = registerSmsErrorCodes();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Upstream-compatible error-metadata registry for `@suaveplan/sms`.
|
||||
*
|
||||
* The upstream package registered six SMS error codes with a process-wide
|
||||
* `ErrorCodes` singleton in `@suaveplan/error` that exposed
|
||||
* `httpStatus`, `severity`, `retry`, `userMessageKey`, `category`, and
|
||||
* `description` per code. Genesis `@suaveplan/error` does not ship a
|
||||
* runtime registry — code metadata lives in the throwing class. To
|
||||
* preserve the upstream contract for downstream consumers that still
|
||||
* import `SMS_ERROR_METADATA` and `registerSmsErrorCodes`, this module
|
||||
* exports the metadata table verbatim and provides a `registerSmsErrorCodes`
|
||||
* function that returns the table (so callers can plug it into whatever
|
||||
* registry their host wires up).
|
||||
*
|
||||
* @module legacy-metadata/legacy-metadata
|
||||
*/
|
||||
|
||||
/** Severity levels mirrored from the upstream registry. */
|
||||
export type SmsCodeSeverity = "info" | "warning" | "error" | "fatal";
|
||||
|
||||
/** Retry hint shape mirrored from the upstream registry. */
|
||||
export interface SmsRetryHint {
|
||||
retryable: boolean;
|
||||
strategy?: "linear" | "exponential";
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
/** Per-code metadata mirrored from the upstream registry. */
|
||||
export interface SmsCodeMetadata {
|
||||
httpStatus: number;
|
||||
severity: SmsCodeSeverity;
|
||||
retry?: SmsRetryHint;
|
||||
retryable?: boolean;
|
||||
userMessageKey: string;
|
||||
category: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Codes registered by `SMS_ERROR_METADATA`. */
|
||||
export type SmsRegisteredErrorCode =
|
||||
| "SMS_SEND_FAILED"
|
||||
| "SMS_PROVIDER_ERROR"
|
||||
| "SMS_INVALID_NUMBER"
|
||||
| "SMS_RATE_LIMITED"
|
||||
| "SMS_CONFIG_MISSING"
|
||||
| "SMS_FORBIDDEN";
|
||||
|
||||
/** Mapping of every `SmsRegisteredErrorCode` to its registry metadata. */
|
||||
export const SMS_ERROR_METADATA: Readonly<
|
||||
Record<SmsRegisteredErrorCode, SmsCodeMetadata>
|
||||
> = Object.freeze({
|
||||
SMS_SEND_FAILED: {
|
||||
httpStatus: 502,
|
||||
severity: "error",
|
||||
retry: { retryable: true, strategy: "exponential", maxAttempts: 3 },
|
||||
userMessageKey: "sms.errors.sendFailed",
|
||||
category: "sms.delivery",
|
||||
description: "The SMS message could not be delivered by the provider.",
|
||||
},
|
||||
SMS_PROVIDER_ERROR: {
|
||||
httpStatus: 502,
|
||||
severity: "error",
|
||||
retry: { retryable: true, strategy: "exponential", maxAttempts: 3 },
|
||||
userMessageKey: "sms.errors.providerError",
|
||||
category: "sms.provider",
|
||||
description: "The upstream SMS provider returned an unexpected error.",
|
||||
},
|
||||
SMS_INVALID_NUMBER: {
|
||||
httpStatus: 400,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "sms.errors.invalidNumber",
|
||||
category: "sms.validation",
|
||||
description:
|
||||
"The destination phone number is not a valid E.164 number.",
|
||||
},
|
||||
SMS_RATE_LIMITED: {
|
||||
httpStatus: 429,
|
||||
severity: "warning",
|
||||
retry: { retryable: true, strategy: "linear", maxAttempts: 5 },
|
||||
userMessageKey: "sms.errors.rateLimited",
|
||||
category: "sms.throttle",
|
||||
description:
|
||||
"The SMS provider rate limit has been exceeded; the request may be retried after a back-off period.",
|
||||
},
|
||||
SMS_CONFIG_MISSING: {
|
||||
httpStatus: 500,
|
||||
severity: "fatal",
|
||||
retryable: false,
|
||||
userMessageKey: "sms.errors.configMissing",
|
||||
category: "sms.config",
|
||||
description:
|
||||
"No SMS provider has been configured; the application cannot send messages.",
|
||||
},
|
||||
SMS_FORBIDDEN: {
|
||||
httpStatus: 403,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
userMessageKey: "sms.errors.forbidden",
|
||||
category: "sms.auth",
|
||||
description:
|
||||
"The caller is not authorised to send SMS messages to this recipient or via this provider.",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Return the SMS error-code metadata table. Upstream installations could
|
||||
* pass the returned table to their own `ErrorCodes.registerMany(...)`
|
||||
* call; on genesis the table is the source of truth and the function
|
||||
* is idempotent.
|
||||
*/
|
||||
export function registerSmsErrorCodes(): Readonly<
|
||||
Record<SmsRegisteredErrorCode, SmsCodeMetadata>
|
||||
> {
|
||||
return SMS_ERROR_METADATA;
|
||||
}
|
||||
|
||||
// Eagerly evaluate on first import to mirror upstream's load-time side
|
||||
// effect; the call returns the frozen metadata table.
|
||||
registerSmsErrorCodes();
|
||||
@@ -0,0 +1 @@
|
||||
export { OptOutRegistry } from "./opt-out.js";
|
||||
87
packages/communication/messaging/sms/src/opt-out/opt-out.md
Normal file
87
packages/communication/messaging/sms/src/opt-out/opt-out.md
Normal file
@@ -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,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OptOutRegistry } from "./opt-out.js";
|
||||
|
||||
describe("OptOutRegistry", () => {
|
||||
it("add/remove/has/list/clear", { timeout: 5000 }, () => {
|
||||
const reg = new OptOutRegistry({ clock: () => 1000 });
|
||||
const entry = reg.add("+15551234567", "marketing");
|
||||
expect(entry.phoneNumber).toBe("+15551234567");
|
||||
expect(entry.channel).toBe("marketing");
|
||||
expect(entry.optedOutAt.getTime()).toBe(1000);
|
||||
expect(reg.has("+15551234567")).toBe(true);
|
||||
expect(reg.size()).toBe(1);
|
||||
expect(reg.list()).toHaveLength(1);
|
||||
expect(reg.get("+15551234567")?.channel).toBe("marketing");
|
||||
expect(reg.remove("+15551234567")).toBe(true);
|
||||
expect(reg.has("+15551234567")).toBe(false);
|
||||
expect(reg.remove("+15551234567")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits channel when undefined", { timeout: 5000 }, () => {
|
||||
const reg = new OptOutRegistry();
|
||||
const entry = reg.add("+15551234567");
|
||||
expect(entry.channel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses Date.now when no clock injected", { timeout: 5000 }, () => {
|
||||
const reg = new OptOutRegistry();
|
||||
const before = Date.now();
|
||||
const entry = reg.add("+15551234567");
|
||||
const after = Date.now();
|
||||
expect(entry.optedOutAt.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(entry.optedOutAt.getTime()).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("importEntries overwrites existing", { timeout: 5000 }, () => {
|
||||
const reg = new OptOutRegistry();
|
||||
reg.add("+15551234567", "old");
|
||||
reg.importEntries([
|
||||
{
|
||||
phoneNumber: "+15551234567",
|
||||
optedOutAt: new Date(2000),
|
||||
channel: "new",
|
||||
},
|
||||
{ phoneNumber: "+15559999999", optedOutAt: new Date(3000) },
|
||||
]);
|
||||
expect(reg.get("+15551234567")?.channel).toBe("new");
|
||||
expect(reg.has("+15559999999")).toBe(true);
|
||||
expect(reg.size()).toBe(2);
|
||||
});
|
||||
|
||||
it("clear drops every entry", { timeout: 5000 }, () => {
|
||||
const reg = new OptOutRegistry();
|
||||
reg.add("+15551234567");
|
||||
reg.add("+15559999999");
|
||||
reg.clear();
|
||||
expect(reg.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
62
packages/communication/messaging/sms/src/opt-out/opt-out.ts
Normal file
62
packages/communication/messaging/sms/src/opt-out/opt-out.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { OptOutEntry } from "../types/sms.types.js";
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/** In-memory opt-out registry used by `SmsService`. */
|
||||
export class OptOutRegistry {
|
||||
private readonly entries = new Map<string, OptOutEntry>();
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(options: { clock?: Clock } = {}) {
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
/** Record an opt-out; overwrites the previous entry for `phoneNumber`. */
|
||||
public add(phoneNumber: string, channel?: string): OptOutEntry {
|
||||
const entry: OptOutEntry = {
|
||||
phoneNumber,
|
||||
optedOutAt: new Date(this.clock()),
|
||||
...(channel !== undefined ? { channel } : {}),
|
||||
};
|
||||
this.entries.set(phoneNumber, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Remove an opt-out entry; returns `true` iff a row was removed. */
|
||||
public remove(phoneNumber: string): boolean {
|
||||
return this.entries.delete(phoneNumber);
|
||||
}
|
||||
|
||||
/** Return `true` iff the recipient is opted out. */
|
||||
public has(phoneNumber: string): boolean {
|
||||
return this.entries.has(phoneNumber);
|
||||
}
|
||||
|
||||
/** Look up the opt-out entry; returns `undefined` if not opted out. */
|
||||
public get(phoneNumber: string): OptOutEntry | undefined {
|
||||
return this.entries.get(phoneNumber);
|
||||
}
|
||||
|
||||
/** Snapshot every opt-out entry. */
|
||||
public list(): ReadonlyArray<OptOutEntry> {
|
||||
return [...this.entries.values()];
|
||||
}
|
||||
|
||||
/** Bulk-import entries; existing entries are overwritten by `phoneNumber`. */
|
||||
public importEntries(entries: ReadonlyArray<OptOutEntry>): void {
|
||||
for (const entry of entries) {
|
||||
this.entries.set(entry.phoneNumber, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every entry. */
|
||||
public clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
/** Total entry count. */
|
||||
public size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
}
|
||||
7
packages/communication/messaging/sms/src/otel/index.ts
Normal file
7
packages/communication/messaging/sms/src/otel/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
recordSmsFailed,
|
||||
recordSmsRetry,
|
||||
recordSmsSendDuration,
|
||||
recordSmsSent,
|
||||
refreshSmsMeters,
|
||||
} from "./metrics.js";
|
||||
98
packages/communication/messaging/sms/src/otel/metrics.md
Normal file
98
packages/communication/messaging/sms/src/otel/metrics.md
Normal file
@@ -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,30 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
recordSmsFailed,
|
||||
recordSmsRetry,
|
||||
recordSmsSendDuration,
|
||||
recordSmsSent,
|
||||
refreshSmsMeters,
|
||||
} from "./metrics.js";
|
||||
|
||||
describe("OTel SMS metrics", () => {
|
||||
it("record helpers do not throw against the default no-op meter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordSmsSent("default", false)).not.toThrow();
|
||||
expect(() => recordSmsFailed("SMS.SEND_FAILED")).not.toThrow();
|
||||
expect(() => recordSmsRetry(0)).not.toThrow();
|
||||
expect(() => recordSmsSendDuration(12, "ok")).not.toThrow();
|
||||
expect(() => recordSmsSendDuration(12, "error")).not.toThrow();
|
||||
expect(() => recordSmsSendDuration(12, "aborted")).not.toThrow();
|
||||
});
|
||||
|
||||
it("refresh re-binds against the global provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Calling refresh twice exercises the rebind path.
|
||||
refreshSmsMeters();
|
||||
refreshSmsMeters();
|
||||
expect(() => recordSmsSent("default", true)).not.toThrow();
|
||||
});
|
||||
});
|
||||
72
packages/communication/messaging/sms/src/otel/metrics.ts
Normal file
72
packages/communication/messaging/sms/src/otel/metrics.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/sms";
|
||||
const METER_VERSION = "0.1.0";
|
||||
|
||||
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
|
||||
let sentCounter: Counter = meter.createCounter("sms.sent_total", {
|
||||
description: "Count of successfully sent SMS messages",
|
||||
});
|
||||
|
||||
let failedCounter: Counter = meter.createCounter("sms.failed_total", {
|
||||
description: "Count of failed SMS sends by error code",
|
||||
});
|
||||
|
||||
let retryCounter: Counter = meter.createCounter("sms.retry_total", {
|
||||
description: "Count of retry attempts by attempt index",
|
||||
});
|
||||
|
||||
let sendDurationHistogram: Histogram = meter.createHistogram(
|
||||
"sms.send_duration_ms",
|
||||
{
|
||||
description: "SMS send pipeline duration in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Re-resolve every instrument against the current global `MeterProvider`.
|
||||
* Tests call this after wiring an in-memory exporter so subsequent record
|
||||
* calls land on the test provider rather than the module-load meter.
|
||||
*/
|
||||
export function refreshSmsMeters(): void {
|
||||
meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
sentCounter = meter.createCounter("sms.sent_total", {
|
||||
description: "Count of successfully sent SMS messages",
|
||||
});
|
||||
failedCounter = meter.createCounter("sms.failed_total", {
|
||||
description: "Count of failed SMS sends by error code",
|
||||
});
|
||||
retryCounter = meter.createCounter("sms.retry_total", {
|
||||
description: "Count of retry attempts by attempt index",
|
||||
});
|
||||
sendDurationHistogram = meter.createHistogram("sms.send_duration_ms", {
|
||||
description: "SMS send pipeline duration in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
|
||||
/** Record a successful SMS send. */
|
||||
export function recordSmsSent(channel: string, fromMe: boolean): void {
|
||||
sentCounter.add(1, { channel, from_me: String(fromMe) });
|
||||
}
|
||||
|
||||
/** Record a failed SMS send, tagged by code. */
|
||||
export function recordSmsFailed(code: string): void {
|
||||
failedCounter.add(1, { code });
|
||||
}
|
||||
|
||||
/** Record a retry attempt, tagged by zero-based attempt index. */
|
||||
export function recordSmsRetry(attempt: number): void {
|
||||
retryCounter.add(1, { attempt: String(attempt) });
|
||||
}
|
||||
|
||||
/** Record the pipeline duration. */
|
||||
export function recordSmsSendDuration(
|
||||
durationMs: number,
|
||||
outcome: "ok" | "error" | "aborted"
|
||||
): void {
|
||||
sendDurationHistogram.record(durationMs, { outcome });
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { RateLimiter } from "./rate-limiter.js";
|
||||
@@ -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,102 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RateLimiter } from "./rate-limiter.js";
|
||||
|
||||
function fakeClock(): { now: () => number; advance: (ms: number) => void } {
|
||||
let now = 1000;
|
||||
return {
|
||||
now: () => now,
|
||||
advance: (ms: number) => {
|
||||
now += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("RateLimiter", () => {
|
||||
it("exhausts after capacity", { timeout: 5000 }, () => {
|
||||
const limiter = new RateLimiter({
|
||||
capacity: 3,
|
||||
refillPerSecond: 1,
|
||||
clock: () => 1000,
|
||||
});
|
||||
expect(limiter.tryConsume("k")).toBe(true);
|
||||
expect(limiter.tryConsume("k")).toBe(true);
|
||||
expect(limiter.tryConsume("k")).toBe(true);
|
||||
expect(limiter.tryConsume("k")).toBe(false);
|
||||
});
|
||||
|
||||
it("refills on clock advance", { timeout: 5000 }, () => {
|
||||
const clock = fakeClock();
|
||||
const limiter = new RateLimiter({
|
||||
capacity: 1,
|
||||
refillPerSecond: 1,
|
||||
clock: clock.now,
|
||||
});
|
||||
expect(limiter.tryConsume("k")).toBe(true);
|
||||
expect(limiter.tryConsume("k")).toBe(false);
|
||||
clock.advance(1000);
|
||||
expect(limiter.tryConsume("k")).toBe(true);
|
||||
});
|
||||
|
||||
it("buckets are independent per key", { timeout: 5000 }, () => {
|
||||
const limiter = new RateLimiter({
|
||||
capacity: 1,
|
||||
refillPerSecond: 1,
|
||||
clock: () => 1000,
|
||||
});
|
||||
expect(limiter.tryConsume("a")).toBe(true);
|
||||
expect(limiter.tryConsume("b")).toBe(true);
|
||||
expect(limiter.tryConsume("a")).toBe(false);
|
||||
});
|
||||
|
||||
it("peek shows current token count after refill", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clock = fakeClock();
|
||||
const limiter = new RateLimiter({
|
||||
capacity: 4,
|
||||
refillPerSecond: 2,
|
||||
clock: clock.now,
|
||||
});
|
||||
limiter.tryConsume("k");
|
||||
limiter.tryConsume("k");
|
||||
expect(limiter.peek("k")).toBe(2);
|
||||
clock.advance(500);
|
||||
expect(limiter.peek("k")).toBe(3);
|
||||
});
|
||||
|
||||
it("clear resets every bucket", { timeout: 5000 }, () => {
|
||||
const limiter = new RateLimiter({
|
||||
capacity: 1,
|
||||
refillPerSecond: 1,
|
||||
clock: () => 1000,
|
||||
});
|
||||
limiter.tryConsume("k");
|
||||
expect(limiter.tryConsume("k")).toBe(false);
|
||||
limiter.clear();
|
||||
expect(limiter.tryConsume("k")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-positive capacity", { timeout: 5000 }, () => {
|
||||
expect(() => new RateLimiter({ capacity: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-positive refillPerSecond", { timeout: 5000 }, () => {
|
||||
expect(() => new RateLimiter({ refillPerSecond: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it("uses default clock when not injected", { timeout: 5000 }, () => {
|
||||
const limiter = new RateLimiter();
|
||||
expect(limiter.tryConsume("k")).toBe(true);
|
||||
});
|
||||
|
||||
it("caps refill at capacity", { timeout: 5000 }, () => {
|
||||
const clock = fakeClock();
|
||||
const limiter = new RateLimiter({
|
||||
capacity: 2,
|
||||
refillPerSecond: 10,
|
||||
clock: clock.now,
|
||||
});
|
||||
clock.advance(100_000);
|
||||
expect(limiter.peek("k")).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { RateLimiterOptions } from "../types/sms.types.js";
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
interface Bucket {
|
||||
tokens: number;
|
||||
lastRefillMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-bucket rate limiter keyed by an arbitrary string. Tokens refill
|
||||
* continuously against an injectable monotonic clock so the limiter is
|
||||
* deterministic under test. The default configuration (`capacity = 60`,
|
||||
* `refillPerSecond = 1`) tolerates a 60-message burst followed by a
|
||||
* 1-per-second steady stream — sane defaults for transactional SMS.
|
||||
*/
|
||||
export class RateLimiter {
|
||||
private readonly capacity: number;
|
||||
private readonly refillPerSecond: number;
|
||||
private readonly clock: Clock;
|
||||
private readonly buckets = new Map<string, Bucket>();
|
||||
|
||||
public constructor(options: RateLimiterOptions = {}) {
|
||||
this.capacity = options.capacity ?? 60;
|
||||
this.refillPerSecond = options.refillPerSecond ?? 1;
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
if (this.capacity <= 0) {
|
||||
throw new Error("RateLimiter.capacity must be > 0");
|
||||
}
|
||||
if (this.refillPerSecond <= 0) {
|
||||
throw new Error("RateLimiter.refillPerSecond must be > 0");
|
||||
}
|
||||
}
|
||||
|
||||
/** Attempt to consume one token for `key`; returns `true` on success. */
|
||||
public tryConsume(key: string): boolean {
|
||||
const bucket = this.getBucket(key);
|
||||
this.refill(bucket);
|
||||
if (bucket.tokens < 1) {
|
||||
return false;
|
||||
}
|
||||
bucket.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Inspect the current token count for `key` (after refill). */
|
||||
public peek(key: string): number {
|
||||
const bucket = this.getBucket(key);
|
||||
this.refill(bucket);
|
||||
return bucket.tokens;
|
||||
}
|
||||
|
||||
/** Reset every bucket. */
|
||||
public clear(): void {
|
||||
this.buckets.clear();
|
||||
}
|
||||
|
||||
private getBucket(key: string): Bucket {
|
||||
let bucket = this.buckets.get(key);
|
||||
if (bucket === undefined) {
|
||||
bucket = { tokens: this.capacity, lastRefillMs: this.clock() };
|
||||
this.buckets.set(key, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
private refill(bucket: Bucket): void {
|
||||
const nowMs = this.clock();
|
||||
const elapsedMs = Math.max(0, nowMs - bucket.lastRefillMs);
|
||||
if (elapsedMs === 0) return;
|
||||
const refill = (elapsedMs / 1000) * this.refillPerSecond;
|
||||
bucket.tokens = Math.min(this.capacity, bucket.tokens + refill);
|
||||
bucket.lastRefillMs = nowMs;
|
||||
}
|
||||
}
|
||||
1
packages/communication/messaging/sms/src/retry/index.ts
Normal file
1
packages/communication/messaging/sms/src/retry/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type RetryHook, RetryPolicy } from "./retry.js";
|
||||
93
packages/communication/messaging/sms/src/retry/retry.md
Normal file
93
packages/communication/messaging/sms/src/retry/retry.md
Normal file
@@ -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.
|
||||
330
packages/communication/messaging/sms/src/retry/retry.test.ts
Normal file
330
packages/communication/messaging/sms/src/retry/retry.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
import { SmsAbortedError } from "../error-codes/error-codes.js";
|
||||
import { RetryPolicy } from "./retry.js";
|
||||
|
||||
function instantTimers(): TimersLike {
|
||||
return {
|
||||
setTimer(cb: () => void): number {
|
||||
queueMicrotask(cb);
|
||||
return 0;
|
||||
},
|
||||
clearTimer(): void {
|
||||
// no-op for instant scheduler
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function controlledTimers(): {
|
||||
timers: TimersLike;
|
||||
fire: () => void;
|
||||
pending: () => boolean;
|
||||
} {
|
||||
let pending: { cb: () => void; handle: number } | undefined;
|
||||
let nextHandle = 1;
|
||||
return {
|
||||
timers: {
|
||||
setTimer(cb: () => void): number {
|
||||
const handle = nextHandle++;
|
||||
pending = { cb, handle };
|
||||
return handle;
|
||||
},
|
||||
clearTimer(handle: number): void {
|
||||
if (pending?.handle === handle) pending = undefined;
|
||||
},
|
||||
},
|
||||
fire: (): void => {
|
||||
const p = pending;
|
||||
pending = undefined;
|
||||
p?.cb();
|
||||
},
|
||||
pending: (): boolean => pending !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RetryPolicy", () => {
|
||||
it("returns immediately on first success", { timeout: 5000 }, async () => {
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 3,
|
||||
timers: instantTimers(),
|
||||
random: () => 0,
|
||||
});
|
||||
const out = await policy.execute(async () => "ok");
|
||||
expect(out).toBe("ok");
|
||||
});
|
||||
|
||||
it("succeeds on second attempt", { timeout: 5000 }, async () => {
|
||||
let attempt = 0;
|
||||
const onRetry = (): void => {
|
||||
// counted via attempts
|
||||
};
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 3,
|
||||
timers: instantTimers(),
|
||||
random: () => 0,
|
||||
});
|
||||
const out = await policy.execute(
|
||||
async () => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) throw new Error("first");
|
||||
return "ok";
|
||||
},
|
||||
{ onRetry }
|
||||
);
|
||||
expect(out).toBe("ok");
|
||||
expect(attempt).toBe(2);
|
||||
});
|
||||
|
||||
it("exhausts attempts", { timeout: 5000 }, async () => {
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 3,
|
||||
timers: instantTimers(),
|
||||
random: () => 0,
|
||||
});
|
||||
let attempt = 0;
|
||||
await expect(
|
||||
policy.execute(async () => {
|
||||
attempt += 1;
|
||||
throw new Error(`fail ${attempt}`);
|
||||
})
|
||||
).rejects.toThrow("fail 3");
|
||||
expect(attempt).toBe(3);
|
||||
});
|
||||
|
||||
it("wraps non-Error throws", { timeout: 5000 }, async () => {
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 1,
|
||||
timers: instantTimers(),
|
||||
random: () => 0,
|
||||
});
|
||||
const bad: unknown = "string";
|
||||
await expect(
|
||||
policy.execute(async () => {
|
||||
throw bad;
|
||||
})
|
||||
).rejects.toThrow("string");
|
||||
});
|
||||
|
||||
it("abort halts retries", { timeout: 5000 }, async () => {
|
||||
const ctrl = controlledTimers();
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 5,
|
||||
timers: ctrl.timers,
|
||||
random: () => 0,
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const p = policy
|
||||
.execute(
|
||||
async () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
{ signal: abortController.signal }
|
||||
)
|
||||
.catch((e: unknown) => e);
|
||||
// wait microtask cycle so the first attempt rejected and policy is in delay
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(ctrl.pending()).toBe(true);
|
||||
abortController.abort(new Error("user-cancel"));
|
||||
const result = await p;
|
||||
expect(result).toBeInstanceOf(SmsAbortedError);
|
||||
});
|
||||
|
||||
it("pre-aborted signal halts immediately", { timeout: 5000 }, async () => {
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 3,
|
||||
timers: instantTimers(),
|
||||
random: () => 0,
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort(new Error("cancel"));
|
||||
await expect(
|
||||
policy.execute(async () => "ok", { signal: ctrl.signal })
|
||||
).rejects.toBeInstanceOf(SmsAbortedError);
|
||||
});
|
||||
|
||||
it("uses defaults when no options provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const policy = new RetryPolicy();
|
||||
const out = await policy.execute(async () => 7);
|
||||
expect(out).toBe(7);
|
||||
});
|
||||
|
||||
it("uses default random source when none injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 2,
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
timers: instantTimers(),
|
||||
});
|
||||
let attempt = 0;
|
||||
await expect(
|
||||
policy.execute(async () => {
|
||||
attempt += 1;
|
||||
throw new Error("flaky");
|
||||
})
|
||||
).rejects.toThrow("flaky");
|
||||
expect(attempt).toBe(2);
|
||||
});
|
||||
|
||||
it("invokes onRetry between attempts", { timeout: 5000 }, async () => {
|
||||
const events: number[] = [];
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 3,
|
||||
timers: instantTimers(),
|
||||
random: () => 0,
|
||||
});
|
||||
let attempt = 0;
|
||||
await policy.execute(
|
||||
async () => {
|
||||
attempt += 1;
|
||||
if (attempt < 3) throw new Error("retry me");
|
||||
return "ok";
|
||||
},
|
||||
{
|
||||
onRetry: (e) => events.push(e.attempt),
|
||||
to: "+15551234567",
|
||||
}
|
||||
);
|
||||
expect(events).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("rejects maxAttempts < 1", { timeout: 5000 }, () => {
|
||||
expect(() => new RetryPolicy({ maxAttempts: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it("caps delay at maxDelayMs", { timeout: 5000 }, async () => {
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 4,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 100,
|
||||
timers: instantTimers(),
|
||||
random: () => 1,
|
||||
});
|
||||
let attempt = 0;
|
||||
await expect(
|
||||
policy.execute(async () => {
|
||||
attempt += 1;
|
||||
throw new Error("nope");
|
||||
})
|
||||
).rejects.toThrow("nope");
|
||||
expect(attempt).toBe(4);
|
||||
});
|
||||
|
||||
it("handles signal abort with non-Error reason", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 1,
|
||||
timers: instantTimers(),
|
||||
random: () => 0,
|
||||
});
|
||||
await expect(
|
||||
policy.execute(async () => "x", { signal: ctrl.signal })
|
||||
).rejects.toBeInstanceOf(SmsAbortedError);
|
||||
});
|
||||
|
||||
it("uses platform timers when none injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 2,
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 2,
|
||||
random: () => 0,
|
||||
});
|
||||
let attempt = 0;
|
||||
await expect(
|
||||
policy.execute(async () => {
|
||||
attempt += 1;
|
||||
throw new Error("oops");
|
||||
})
|
||||
).rejects.toThrow("oops");
|
||||
expect(attempt).toBe(2);
|
||||
});
|
||||
|
||||
it("platform timers abort path clears the real timer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 200,
|
||||
maxDelayMs: 200,
|
||||
random: () => 1,
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
const p = policy
|
||||
.execute(
|
||||
async () => {
|
||||
throw new Error("fail");
|
||||
},
|
||||
{ signal: ctrl.signal }
|
||||
)
|
||||
.catch((e: unknown) => e);
|
||||
// Let the first attempt fail and the delay begin
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
ctrl.abort();
|
||||
const result = await p;
|
||||
expect(result).toBeInstanceOf(SmsAbortedError);
|
||||
});
|
||||
|
||||
it("onRetry wraps non-Error rejections", { timeout: 5000 }, async () => {
|
||||
const events: Error[] = [];
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 2,
|
||||
timers: instantTimers(),
|
||||
random: () => 0,
|
||||
});
|
||||
await expect(
|
||||
policy.execute(
|
||||
async () => {
|
||||
const bad: unknown = "non-error";
|
||||
throw bad;
|
||||
},
|
||||
{
|
||||
onRetry: (e) => events.push(e.error),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("non-error");
|
||||
expect(events[0]).toBeInstanceOf(Error);
|
||||
expect(events[0]?.message).toBe("non-error");
|
||||
});
|
||||
|
||||
it("abort with undefined reason uses default label", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 1,
|
||||
random: () => 0,
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const result = await policy
|
||||
.execute(async () => 1, { signal: ctrl.signal })
|
||||
.catch((e: unknown) => e);
|
||||
expect(result).toBeInstanceOf(SmsAbortedError);
|
||||
});
|
||||
|
||||
it("non-Error abort reason stringifies", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const policy = new RetryPolicy({
|
||||
maxAttempts: 1,
|
||||
random: () => 0,
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort("custom-string");
|
||||
const result = await policy
|
||||
.execute(async () => 1, { signal: ctrl.signal })
|
||||
.catch((e: unknown) => e);
|
||||
expect(result).toBeInstanceOf(SmsAbortedError);
|
||||
expect((result as SmsAbortedError).context?.["reason"]).toContain(
|
||||
"custom-string"
|
||||
);
|
||||
});
|
||||
});
|
||||
125
packages/communication/messaging/sms/src/retry/retry.ts
Normal file
125
packages/communication/messaging/sms/src/retry/retry.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
import { SmsAbortedError } from "../error-codes/error-codes.js";
|
||||
import type { RetryPolicyOptions, SmsRetryEvent } from "../types/sms.types.js";
|
||||
|
||||
/** Platform timers fall back to globalThis. */
|
||||
const platformTimers: TimersLike = {
|
||||
setTimer(callback: () => void, delayMs: number): number {
|
||||
return globalThis.setTimeout(callback, delayMs) as unknown as number;
|
||||
},
|
||||
clearTimer(handle: number): void {
|
||||
globalThis.clearTimeout(
|
||||
handle as unknown as ReturnType<typeof setTimeout>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Default jitter random source — `Math.random`. */
|
||||
const defaultRandom = (): number => Math.random();
|
||||
|
||||
/** Hook invoked between attempts. */
|
||||
export type RetryHook = (event: SmsRetryEvent) => void;
|
||||
|
||||
/**
|
||||
* Exponential-backoff retry policy with full jitter. Each attempt is
|
||||
* awaited against the injectable `Timers.setTimer` seam so tests can drive
|
||||
* the policy without real wall-clock waits. The callback receives a
|
||||
* zero-based attempt counter so callers can record per-attempt telemetry.
|
||||
*
|
||||
* The policy is abort-aware: a triggered `signal.aborted` interrupts the
|
||||
* pending delay AND prevents the next attempt, throwing `SmsAbortedError`.
|
||||
*/
|
||||
export class RetryPolicy {
|
||||
private readonly maxAttempts: number;
|
||||
private readonly initialDelayMs: number;
|
||||
private readonly maxDelayMs: number;
|
||||
private readonly timers: TimersLike;
|
||||
private readonly random: () => number;
|
||||
|
||||
public constructor(options: RetryPolicyOptions = {}) {
|
||||
this.maxAttempts = options.maxAttempts ?? 3;
|
||||
this.initialDelayMs = options.initialDelayMs ?? 50;
|
||||
this.maxDelayMs = options.maxDelayMs ?? 5_000;
|
||||
this.timers = options.timers ?? platformTimers;
|
||||
this.random = options.random ?? defaultRandom;
|
||||
if (this.maxAttempts < 1) {
|
||||
throw new Error("RetryPolicy.maxAttempts must be ≥ 1");
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute `fn`, retrying on rejection up to `maxAttempts` times. */
|
||||
public async execute<T>(
|
||||
fn: (attempt: number) => Promise<T>,
|
||||
options: { signal?: AbortSignal; onRetry?: RetryHook; to?: string } = {}
|
||||
): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < this.maxAttempts; attempt += 1) {
|
||||
this.throwIfAborted(options.signal);
|
||||
try {
|
||||
return await fn(attempt);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === this.maxAttempts - 1) break;
|
||||
if (options.onRetry !== undefined) {
|
||||
options.onRetry({
|
||||
to: options.to ?? "",
|
||||
attempt,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
});
|
||||
}
|
||||
await this.delay(this.computeDelay(attempt), options.signal);
|
||||
}
|
||||
}
|
||||
if (lastError instanceof Error) throw lastError;
|
||||
throw new Error(String(lastError));
|
||||
}
|
||||
|
||||
private computeDelay(attempt: number): number {
|
||||
const base = this.initialDelayMs * 2 ** attempt;
|
||||
const capped = Math.min(this.maxDelayMs, base);
|
||||
// Full jitter — random in [0, capped].
|
||||
return Math.floor(this.random() * capped);
|
||||
}
|
||||
|
||||
private throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new SmsAbortedError({
|
||||
context: { reason: stringifyReason(signal.reason) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const handle = this.timers.setTimer(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, ms);
|
||||
const cleanup = (): void => {
|
||||
if (signal !== undefined) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
this.timers.clearTimer(handle);
|
||||
cleanup();
|
||||
reject(
|
||||
new SmsAbortedError({
|
||||
context: { reason: stringifyReason(signal?.reason) },
|
||||
})
|
||||
);
|
||||
};
|
||||
if (signal !== undefined) {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyReason(reason: unknown): string {
|
||||
if (reason instanceof Error) return reason.message;
|
||||
return String(reason);
|
||||
}
|
||||
@@ -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,159 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SmsInvalidNumberError,
|
||||
SmsRateLimitedError,
|
||||
SmsScheduleCancelledError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { SmsError } from "../legacy-errors/legacy-errors.js";
|
||||
import {
|
||||
PROBLEM_JSON_CONTENT_TYPE,
|
||||
respondProblemJson,
|
||||
} from "./problem-json.js";
|
||||
|
||||
function fakeContext(): {
|
||||
headers: Map<string, string>;
|
||||
header(name: string, value: string): void;
|
||||
body(data: string, status: number): Response;
|
||||
} {
|
||||
const headers = new Map<string, string>();
|
||||
return {
|
||||
headers,
|
||||
header(name: string, value: string): void {
|
||||
headers.set(name.toLowerCase(), value);
|
||||
},
|
||||
body(data: string, status: number): Response {
|
||||
return new Response(data, { status });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("respondProblemJson", () => {
|
||||
it("sets the problem+json content type", { timeout: 5000 }, () => {
|
||||
const c = fakeContext();
|
||||
respondProblemJson(c, new SmsInvalidNumberError());
|
||||
expect(c.headers.get("content-type")).toBe(PROBLEM_JSON_CONTENT_TYPE);
|
||||
});
|
||||
|
||||
it("maps SMS.RATE_LIMITED to 429", { timeout: 5000 }, async () => {
|
||||
const c = fakeContext();
|
||||
const res = respondProblemJson(c, new SmsRateLimitedError());
|
||||
expect(res.status).toBe(429);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body["status"]).toBe(429);
|
||||
expect(body["code"]).toBe("SMS.RATE_LIMITED");
|
||||
});
|
||||
|
||||
it("maps SMS.INVALID_NUMBER to 400", { timeout: 5000 }, async () => {
|
||||
const c = fakeContext();
|
||||
const res = respondProblemJson(c, new SmsInvalidNumberError());
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("maps SMS.SCHEDULE_CANCELLED to 409", { timeout: 5000 }, async () => {
|
||||
const c = fakeContext();
|
||||
const res = respondProblemJson(c, new SmsScheduleCancelledError());
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("registered code preserves httpStatus", { timeout: 5000 }, async () => {
|
||||
const c = fakeContext();
|
||||
const err = new SmsError({
|
||||
code: "SMS_RATE_LIMITED" as never,
|
||||
message: "limit",
|
||||
});
|
||||
const res = respondProblemJson(c, err);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body["status"]).toBe(429);
|
||||
});
|
||||
|
||||
it("unknown error falls back to 500", { timeout: 5000 }, async () => {
|
||||
const c = fakeContext();
|
||||
const res = respondProblemJson(c, new Error("boom"));
|
||||
expect(res.status).toBe(500);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body["title"]).toBe("boom");
|
||||
});
|
||||
|
||||
it("non-Error throwable falls back to 500", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = fakeContext();
|
||||
const res = respondProblemJson(c, "string-error");
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("Error without message uses default title", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = fakeContext();
|
||||
const err = new Error("");
|
||||
const res = respondProblemJson(c, err);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body["title"]).toBe("Internal Server Error");
|
||||
});
|
||||
|
||||
it("genesis code without legacy mapping uses fall-back status", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = fakeContext();
|
||||
// Create an arbitrary unknown-prefixed code to hit the default branch.
|
||||
class WeirdError extends Error {
|
||||
public readonly code = "OTHER.WEIRD";
|
||||
public constructor() {
|
||||
super("weird");
|
||||
}
|
||||
}
|
||||
const res = respondProblemJson(c, new WeirdError());
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("includes context on SuaveplanError response", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const c = fakeContext();
|
||||
const err = new SmsInvalidNumberError({
|
||||
context: { phoneNumber: "bad" },
|
||||
});
|
||||
const res = respondProblemJson(c, err);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body["context"]).toEqual({ phoneNumber: "bad" });
|
||||
});
|
||||
|
||||
it("covers every SMS.* code branch", { timeout: 5000 }, async () => {
|
||||
const mod = await import("../error-codes/error-codes.js");
|
||||
const errs = [
|
||||
new mod.SmsInvalidConfigError(),
|
||||
new mod.SmsProviderNotConfiguredError(),
|
||||
new mod.SmsConfigMissingError(),
|
||||
new mod.SmsForbiddenError(),
|
||||
new mod.SmsReceiptNotFoundError(),
|
||||
new mod.SmsTemplateNotFoundError(),
|
||||
new mod.SmsRecipientOptedOutError(),
|
||||
new mod.SmsAbortedError(),
|
||||
new mod.SmsScheduleCancelledError(),
|
||||
new mod.SmsProviderError(),
|
||||
new mod.SmsSendFailedError(),
|
||||
];
|
||||
for (const err of errs) {
|
||||
const c = fakeContext();
|
||||
respondProblemJson(c, err);
|
||||
}
|
||||
// Default branch — unknown SMS.* sub-code via a subclass with overridden code.
|
||||
class WeirdSmsError extends mod.BaseSmsError {
|
||||
public constructor() {
|
||||
super("SMS.WEIRD" as never, "weird", {});
|
||||
}
|
||||
}
|
||||
respondProblemJson(fakeContext(), new WeirdSmsError());
|
||||
|
||||
// Non-SMS-prefix code hits the final `return 500` branch.
|
||||
class OtherCoded extends mod.BaseSmsError {
|
||||
public constructor() {
|
||||
super("OTHER.STUFF" as never, "stuff", {});
|
||||
}
|
||||
}
|
||||
const c = fakeContext();
|
||||
const res = respondProblemJson(c, new OtherCoded());
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
101
packages/communication/messaging/sms/src/routes/problem-json.ts
Normal file
101
packages/communication/messaging/sms/src/routes/problem-json.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import {
|
||||
SMS_ERROR_METADATA,
|
||||
type SmsRegisteredErrorCode,
|
||||
} from "../legacy-metadata/legacy-metadata.js";
|
||||
|
||||
/** Standard problem+json content-type per RFC 7807. */
|
||||
export const PROBLEM_JSON_CONTENT_TYPE = "application/problem+json";
|
||||
|
||||
/** RFC 7807 problem-details document shape. */
|
||||
export interface ProblemDetails {
|
||||
type?: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail?: string;
|
||||
instance?: string;
|
||||
code?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Minimal structural Hono context surface we rely on. */
|
||||
export interface HonoLike {
|
||||
header(name: string, value: string): void;
|
||||
body(data: string, status: number): Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `err` as RFC 7807 problem+json on the supplied Hono-like context.
|
||||
* Returns the Response so callers can `return` it directly. SMS errors
|
||||
* whose `code` exists in `SMS_ERROR_METADATA` map their registered
|
||||
* `httpStatus`; unknown throwables fall back to 500.
|
||||
*/
|
||||
export function respondProblemJson(c: HonoLike, err: unknown): Response {
|
||||
const body = formatProblem(err);
|
||||
c.header("content-type", PROBLEM_JSON_CONTENT_TYPE);
|
||||
return c.body(JSON.stringify(body), body.status);
|
||||
}
|
||||
|
||||
function formatProblem(err: unknown): ProblemDetails {
|
||||
if (err instanceof SuaveplanError) {
|
||||
const status = httpStatusForCode(err.code);
|
||||
const details: ProblemDetails = {
|
||||
title: err.message,
|
||||
status,
|
||||
code: err.code,
|
||||
};
|
||||
if (err.context !== undefined) {
|
||||
details["context"] = err.context;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { title: err.message || "Internal Server Error", status: 500 };
|
||||
}
|
||||
return { title: "Internal Server Error", status: 500 };
|
||||
}
|
||||
|
||||
function httpStatusForCode(code: string): number {
|
||||
// Map genesis SMS.* codes onto upstream registered metadata.
|
||||
const legacyKey = mapToLegacyKey(code);
|
||||
if (legacyKey !== undefined) {
|
||||
return SMS_ERROR_METADATA[legacyKey].httpStatus;
|
||||
}
|
||||
if (code.startsWith("SMS.")) {
|
||||
switch (code) {
|
||||
case "SMS.INVALID_NUMBER":
|
||||
return 400;
|
||||
case "SMS.INVALID_CONFIG":
|
||||
return 400;
|
||||
case "SMS.RATE_LIMITED":
|
||||
return 429;
|
||||
case "SMS.FORBIDDEN":
|
||||
return 403;
|
||||
case "SMS.PROVIDER_NOT_CONFIGURED":
|
||||
case "SMS.CONFIG_MISSING":
|
||||
return 500;
|
||||
case "SMS.RECEIPT_NOT_FOUND":
|
||||
case "SMS.TEMPLATE_NOT_FOUND":
|
||||
return 404;
|
||||
case "SMS.RECIPIENT_OPTED_OUT":
|
||||
return 410;
|
||||
case "SMS.ABORTED":
|
||||
return 499;
|
||||
case "SMS.SCHEDULE_CANCELLED":
|
||||
return 409;
|
||||
case "SMS.PROVIDER_ERROR":
|
||||
case "SMS.SEND_FAILED":
|
||||
return 502;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
|
||||
function mapToLegacyKey(code: string): SmsRegisteredErrorCode | undefined {
|
||||
if (code in SMS_ERROR_METADATA) {
|
||||
return code as SmsRegisteredErrorCode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MessageScheduler } from "./scheduler.js";
|
||||
@@ -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,143 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
import { MessageScheduler } from "./scheduler.js";
|
||||
|
||||
function controlledTimers(): {
|
||||
timers: TimersLike;
|
||||
fire: (handle: number) => void;
|
||||
pending: () => Map<number, () => void>;
|
||||
delayFor: (handle: number) => number;
|
||||
} {
|
||||
const pending = new Map<number, { cb: () => void; delayMs: number }>();
|
||||
let next = 1;
|
||||
return {
|
||||
timers: {
|
||||
setTimer(cb: () => void, delayMs: number): number {
|
||||
const handle = next++;
|
||||
pending.set(handle, { cb, delayMs });
|
||||
return handle;
|
||||
},
|
||||
clearTimer(handle: number): void {
|
||||
pending.delete(handle);
|
||||
},
|
||||
},
|
||||
fire: (handle: number): void => {
|
||||
const entry = pending.get(handle);
|
||||
pending.delete(handle);
|
||||
entry?.cb();
|
||||
},
|
||||
pending: () => {
|
||||
const out = new Map<number, () => void>();
|
||||
for (const [h, v] of pending) out.set(h, v.cb);
|
||||
return out;
|
||||
},
|
||||
delayFor: (handle: number): number =>
|
||||
pending.get(handle)?.delayMs ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MessageScheduler", () => {
|
||||
it("fires at the target time", { timeout: 5000 }, () => {
|
||||
const ctrl = controlledTimers();
|
||||
const scheduler = new MessageScheduler({
|
||||
timers: ctrl.timers,
|
||||
clock: () => 1000,
|
||||
});
|
||||
let fired = false;
|
||||
const handle = scheduler.scheduleAt(new Date(1500), () => {
|
||||
fired = true;
|
||||
});
|
||||
expect(ctrl.delayFor(1)).toBe(500);
|
||||
expect(fired).toBe(false);
|
||||
ctrl.fire(1);
|
||||
expect(fired).toBe(true);
|
||||
expect(handle.sendAt.getTime()).toBe(1500);
|
||||
});
|
||||
|
||||
it("cancel prevents firing", { timeout: 5000 }, () => {
|
||||
const ctrl = controlledTimers();
|
||||
const scheduler = new MessageScheduler({
|
||||
timers: ctrl.timers,
|
||||
clock: () => 1000,
|
||||
});
|
||||
let fired = false;
|
||||
const handle = scheduler.scheduleAt(new Date(2000), () => {
|
||||
fired = true;
|
||||
});
|
||||
handle.cancel();
|
||||
expect(ctrl.pending().size).toBe(0);
|
||||
// double-cancel is a no-op
|
||||
handle.cancel();
|
||||
expect(fired).toBe(false);
|
||||
});
|
||||
|
||||
it("uses zero delay for past dates", { timeout: 5000 }, () => {
|
||||
const ctrl = controlledTimers();
|
||||
const scheduler = new MessageScheduler({
|
||||
timers: ctrl.timers,
|
||||
clock: () => 5000,
|
||||
});
|
||||
scheduler.scheduleAt(new Date(1000), () => undefined);
|
||||
expect(ctrl.delayFor(1)).toBe(0);
|
||||
});
|
||||
|
||||
it("uses platform timers and clock by default", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const scheduler = new MessageScheduler();
|
||||
let fired = false;
|
||||
const handle = scheduler.scheduleAt(new Date(Date.now() + 5), () => {
|
||||
fired = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(fired).toBe(true);
|
||||
expect(handle.sendAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("cancel-after-schedule short-circuits inside the callback", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Timers seam that ignores clearTimer, so a cancelled schedule
|
||||
// still leaves the pending callback in the queue. We then fire
|
||||
// manually to exercise the `if (cancelled) return` branch.
|
||||
const pending: Array<() => void> = [];
|
||||
const timers: TimersLike = {
|
||||
setTimer(cb: () => void): number {
|
||||
pending.push(cb);
|
||||
return pending.length;
|
||||
},
|
||||
clearTimer(): void {
|
||||
// intentionally a no-op to exercise the race-window guard
|
||||
},
|
||||
};
|
||||
const scheduler = new MessageScheduler({
|
||||
timers,
|
||||
clock: () => 1000,
|
||||
});
|
||||
let fired = 0;
|
||||
const handle = scheduler.scheduleAt(new Date(2000), () => {
|
||||
fired += 1;
|
||||
});
|
||||
handle.cancel();
|
||||
// Fire the still-queued callback
|
||||
pending[0]?.();
|
||||
expect(fired).toBe(0);
|
||||
});
|
||||
|
||||
it("cancellation after fire is a no-op (timer already cleared)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctrl = controlledTimers();
|
||||
const scheduler = new MessageScheduler({
|
||||
timers: ctrl.timers,
|
||||
clock: () => 1000,
|
||||
});
|
||||
let fired = 0;
|
||||
const handle = scheduler.scheduleAt(new Date(1500), () => {
|
||||
fired += 1;
|
||||
});
|
||||
ctrl.fire(1);
|
||||
handle.cancel();
|
||||
expect(fired).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Clock, TimersLike } from "@suaveplan/types";
|
||||
import type {
|
||||
MessageSchedulerOptions,
|
||||
ScheduleHandle,
|
||||
} from "../types/sms.types.js";
|
||||
|
||||
const platformTimers: TimersLike = {
|
||||
setTimer(callback: () => void, delayMs: number): number {
|
||||
return globalThis.setTimeout(callback, delayMs) as unknown as number;
|
||||
},
|
||||
clearTimer(handle: number): void {
|
||||
globalThis.clearTimeout(
|
||||
handle as unknown as ReturnType<typeof setTimeout>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/** Schedule a callback to fire at a specific instant. */
|
||||
export class MessageScheduler {
|
||||
private readonly timers: TimersLike;
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(options: MessageSchedulerOptions = {}) {
|
||||
this.timers = options.timers ?? platformTimers;
|
||||
this.clock = options.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
/** Schedule `fn` to fire at `sendAt`. Returns a cancel handle. */
|
||||
public scheduleAt(sendAt: Date, fn: () => void): ScheduleHandle {
|
||||
const nowMs = this.clock();
|
||||
const delayMs = Math.max(0, sendAt.getTime() - nowMs);
|
||||
let cancelled = false;
|
||||
const timerHandle = this.timers.setTimer(() => {
|
||||
if (cancelled) return;
|
||||
fn();
|
||||
}, delayMs);
|
||||
return {
|
||||
sendAt,
|
||||
cancel: () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
this.timers.clearTimer(timerHandle);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
parseSmsDeliveryReport,
|
||||
parseSmsMessage,
|
||||
parseSmsTemplate,
|
||||
SmsDeliveryReportSchema,
|
||||
SmsDeliveryStatusSchema,
|
||||
SmsMessageSchema,
|
||||
SmsTemplateSchema,
|
||||
} from "./schemas.js";
|
||||
101
packages/communication/messaging/sms/src/schemas/schemas.md
Normal file
101
packages/communication/messaging/sms/src/schemas/schemas.md
Normal file
@@ -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.
|
||||
184
packages/communication/messaging/sms/src/schemas/schemas.test.ts
Normal file
184
packages/communication/messaging/sms/src/schemas/schemas.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SmsInvalidConfigError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
parseSmsDeliveryReport,
|
||||
parseSmsMessage,
|
||||
parseSmsTemplate,
|
||||
SmsDeliveryReportSchema,
|
||||
SmsDeliveryStatusSchema,
|
||||
SmsMessageSchema,
|
||||
SmsTemplateSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("SmsMessageSchema", () => {
|
||||
it("accepts a minimal valid message", { timeout: 5000 }, () => {
|
||||
const out = parseSmsMessage({ to: "+15551234567", body: "hi" });
|
||||
expect(out.to).toBe("+15551234567");
|
||||
expect(out.body).toBe("hi");
|
||||
});
|
||||
|
||||
it("rejects empty body", { timeout: 5000 }, () => {
|
||||
expect(() => parseSmsMessage({ to: "+15551234567", body: "" })).toThrow(
|
||||
SmsInvalidConfigError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-E.164 to", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseSmsMessage({ to: "bad", body: "hi" });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SmsInvalidConfigError);
|
||||
const issues = (err as SmsInvalidConfigError).context?.[
|
||||
"issues"
|
||||
] as ReadonlyArray<Record<string, unknown>>;
|
||||
expect(issues.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("strips undefined optional fields", { timeout: 5000 }, () => {
|
||||
const out = parseSmsMessage({
|
||||
to: "+15551234567",
|
||||
body: "hi",
|
||||
templateId: undefined,
|
||||
});
|
||||
expect("templateId" in out).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps declared optional fields", { timeout: 5000 }, () => {
|
||||
const out = parseSmsMessage({
|
||||
to: "+15551234567",
|
||||
body: "hi",
|
||||
channel: "marketing",
|
||||
templateVars: { name: "Alice" },
|
||||
metadata: { k: 1 },
|
||||
});
|
||||
expect(out.channel).toBe("marketing");
|
||||
expect(out.templateVars).toEqual({ name: "Alice" });
|
||||
expect(out.metadata).toEqual({ k: 1 });
|
||||
});
|
||||
|
||||
it("exposes the schema", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SmsMessageSchema.safeParse({ to: "+15551234567", body: "hi" })
|
||||
.success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SmsTemplateSchema", () => {
|
||||
it("accepts a valid template", { timeout: 5000 }, () => {
|
||||
const out = parseSmsTemplate({
|
||||
id: "x",
|
||||
body: "Hi {{name}}",
|
||||
variables: ["name"],
|
||||
});
|
||||
expect(out.id).toBe("x");
|
||||
});
|
||||
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseSmsTemplate({ id: "", body: "x", variables: [] })
|
||||
).toThrow(SmsInvalidConfigError);
|
||||
});
|
||||
|
||||
it("strips undefined optional fields", { timeout: 5000 }, () => {
|
||||
const out = parseSmsTemplate({
|
||||
id: "x",
|
||||
body: "Hi",
|
||||
variables: [],
|
||||
defaults: undefined,
|
||||
});
|
||||
expect("defaults" in out).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes the schema", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SmsTemplateSchema.safeParse({
|
||||
id: "x",
|
||||
body: "y",
|
||||
variables: [],
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SmsDeliveryStatusSchema", () => {
|
||||
it("accepts every status literal", { timeout: 5000 }, () => {
|
||||
for (const s of [
|
||||
"queued",
|
||||
"sent",
|
||||
"delivered",
|
||||
"failed",
|
||||
"undelivered",
|
||||
]) {
|
||||
expect(SmsDeliveryStatusSchema.safeParse(s).success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown statuses", { timeout: 5000 }, () => {
|
||||
expect(SmsDeliveryStatusSchema.safeParse("pending").success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SmsDeliveryReportSchema", () => {
|
||||
it("accepts a valid report", { timeout: 5000 }, () => {
|
||||
const out = parseSmsDeliveryReport({
|
||||
messageId: "m1",
|
||||
to: "+15551234567",
|
||||
status: "sent",
|
||||
timestamp: new Date(),
|
||||
});
|
||||
expect(out.messageId).toBe("m1");
|
||||
});
|
||||
|
||||
it("rejects empty messageId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseSmsDeliveryReport({
|
||||
messageId: "",
|
||||
to: "+15551234567",
|
||||
status: "sent",
|
||||
timestamp: new Date(),
|
||||
})
|
||||
).toThrow(SmsInvalidConfigError);
|
||||
});
|
||||
|
||||
it("strips undefined optional fields", { timeout: 5000 }, () => {
|
||||
const out = parseSmsDeliveryReport({
|
||||
messageId: "m1",
|
||||
to: "+15551234567",
|
||||
status: "failed",
|
||||
timestamp: new Date(),
|
||||
errorCode: undefined,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
expect("errorCode" in out).toBe(false);
|
||||
expect("errorMessage" in out).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps declared optional error fields", { timeout: 5000 }, () => {
|
||||
const out = parseSmsDeliveryReport({
|
||||
messageId: "m1",
|
||||
to: "+15551234567",
|
||||
status: "failed",
|
||||
timestamp: new Date(),
|
||||
errorCode: "E_PROV",
|
||||
errorMessage: "boom",
|
||||
});
|
||||
expect(out.errorCode).toBe("E_PROV");
|
||||
expect(out.errorMessage).toBe("boom");
|
||||
});
|
||||
|
||||
it("exposes the schema", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SmsDeliveryReportSchema.safeParse({
|
||||
messageId: "m1",
|
||||
to: "+15551234567",
|
||||
status: "sent",
|
||||
timestamp: new Date(),
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
102
packages/communication/messaging/sms/src/schemas/schemas.ts
Normal file
102
packages/communication/messaging/sms/src/schemas/schemas.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import z from "zod";
|
||||
import { SmsInvalidConfigError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
SmsDeliveryReport,
|
||||
SmsMessage,
|
||||
SmsTemplate,
|
||||
} from "../types/sms.types.js";
|
||||
import { isValidE164 } from "../validation/phone.js";
|
||||
|
||||
/** Zod refinement that accepts only strict E.164 phone numbers. */
|
||||
const E164String = z.string().refine(isValidE164, {
|
||||
message: "expected an E.164 phone number",
|
||||
});
|
||||
|
||||
/** Zod schema mirroring `SmsMessage`. */
|
||||
export const SmsMessageSchema = z
|
||||
.object({
|
||||
to: E164String,
|
||||
from: z.string().min(1).optional(),
|
||||
body: z.string().min(1),
|
||||
templateId: z.string().min(1).optional(),
|
||||
templateVars: z.record(z.string(), z.string()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
channel: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Zod schema mirroring `SmsTemplate`. */
|
||||
export const SmsTemplateSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
variables: z.array(z.string().min(1)),
|
||||
defaults: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Zod schema mirroring `SmsDeliveryStatus`. */
|
||||
export const SmsDeliveryStatusSchema = z.union([
|
||||
z.literal("queued"),
|
||||
z.literal("sent"),
|
||||
z.literal("delivered"),
|
||||
z.literal("failed"),
|
||||
z.literal("undelivered"),
|
||||
]);
|
||||
|
||||
/** Zod schema mirroring `SmsDeliveryReport`. */
|
||||
export const SmsDeliveryReportSchema = z
|
||||
.object({
|
||||
messageId: z.string().min(1),
|
||||
to: E164String,
|
||||
status: SmsDeliveryStatusSchema,
|
||||
timestamp: z.instanceof(Date),
|
||||
errorCode: z.string().optional(),
|
||||
errorMessage: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function toIssues(
|
||||
error: z.ZodError
|
||||
): ReadonlyArray<Readonly<Record<string, unknown>>> {
|
||||
return error.issues.map((issue) => ({
|
||||
code: issue.code,
|
||||
path: issue.path,
|
||||
message: issue.message,
|
||||
}));
|
||||
}
|
||||
|
||||
function stripUndefined<T extends Record<string, unknown>>(value: T): T {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (val !== undefined) out[key] = val;
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
/** Parse and validate `SmsMessage` input; throws `SmsInvalidConfigError`. */
|
||||
export function parseSmsMessage(input: unknown): SmsMessage {
|
||||
const result = SmsMessageSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new SmsInvalidConfigError({ issues: toIssues(result.error) });
|
||||
}
|
||||
return stripUndefined(result.data) as SmsMessage;
|
||||
}
|
||||
|
||||
/** Parse and validate `SmsTemplate` input; throws `SmsInvalidConfigError`. */
|
||||
export function parseSmsTemplate(input: unknown): SmsTemplate {
|
||||
const result = SmsTemplateSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new SmsInvalidConfigError({ issues: toIssues(result.error) });
|
||||
}
|
||||
return stripUndefined(result.data) as SmsTemplate;
|
||||
}
|
||||
|
||||
/** Parse and validate `SmsDeliveryReport` input. */
|
||||
export function parseSmsDeliveryReport(input: unknown): SmsDeliveryReport {
|
||||
const result = SmsDeliveryReportSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new SmsInvalidConfigError({ issues: toIssues(result.error) });
|
||||
}
|
||||
return stripUndefined(result.data) as SmsDeliveryReport;
|
||||
}
|
||||
71
packages/communication/messaging/sms/src/service/bus-map.md
Normal file
71
packages/communication/messaging/sms/src/service/bus-map.md
Normal file
@@ -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.
|
||||
19
packages/communication/messaging/sms/src/service/bus-map.ts
Normal file
19
packages/communication/messaging/sms/src/service/bus-map.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {
|
||||
OptOutEntry,
|
||||
SmsDeliveryReport,
|
||||
SmsRateLimitedEvent,
|
||||
SmsRetryEvent,
|
||||
SmsScheduledEvent,
|
||||
} from "../types/sms.types.js";
|
||||
|
||||
/** Single-payload bus topic map used by `SmsService.bus`. */
|
||||
export interface SmsBusMap extends Record<string, unknown> {
|
||||
"sms.sent": SmsDeliveryReport;
|
||||
"sms.delivered": SmsDeliveryReport;
|
||||
"sms.failed": SmsDeliveryReport;
|
||||
"sms.opted_out": OptOutEntry;
|
||||
"sms.opted_in": { phoneNumber: string };
|
||||
"sms.scheduled": SmsScheduledEvent;
|
||||
"sms.retry": SmsRetryEvent;
|
||||
"sms.rate_limited": SmsRateLimitedEvent;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { SmsBusMap } from "./bus-map.js";
|
||||
export { LegacyEmitter, type SmsLegacyListener } from "./legacy-emitter.js";
|
||||
export { type SmsSendOptions, SmsService } from "./sms-service.js";
|
||||
@@ -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,84 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import type { SmsBusMap } from "./bus-map.js";
|
||||
import { LegacyEmitter } from "./legacy-emitter.js";
|
||||
|
||||
describe("LegacyEmitter", () => {
|
||||
it("on/emit invokes listeners with tuple-spread args", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bus = new EventBus<SmsBusMap>();
|
||||
const emitter = new LegacyEmitter(bus);
|
||||
const handler = vi.fn();
|
||||
emitter.on("sms.opted_in", handler);
|
||||
emitter.emit("sms.opted_in", { phoneNumber: "+15551234567" });
|
||||
expect(handler).toHaveBeenCalledWith({ phoneNumber: "+15551234567" });
|
||||
});
|
||||
|
||||
it("emit mirrors onto the bus for known topics", { timeout: 5000 }, () => {
|
||||
const bus = new EventBus<SmsBusMap>();
|
||||
const emitter = new LegacyEmitter(bus);
|
||||
const busHandler = vi.fn();
|
||||
bus.on("sms.opted_in", busHandler);
|
||||
emitter.emit("sms.opted_in", { phoneNumber: "+15551234567" });
|
||||
expect(busHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("off removes listeners", { timeout: 5000 }, () => {
|
||||
const bus = new EventBus<SmsBusMap>();
|
||||
const emitter = new LegacyEmitter(bus);
|
||||
const handler = vi.fn();
|
||||
emitter.on("sms.opted_in", handler);
|
||||
emitter.off("sms.opted_in", handler);
|
||||
emitter.emit("sms.opted_in", { phoneNumber: "+1" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("off is a no-op for unknown listeners", { timeout: 5000 }, () => {
|
||||
const bus = new EventBus<SmsBusMap>();
|
||||
const emitter = new LegacyEmitter(bus);
|
||||
const fn = vi.fn();
|
||||
emitter.off("sms.opted_in", fn);
|
||||
emitter.on("sms.opted_in", fn);
|
||||
emitter.off("sms.opted_in", vi.fn());
|
||||
emitter.emit("sms.opted_in", { phoneNumber: "+1" });
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mirror invokes both bus and legacy listeners", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bus = new EventBus<SmsBusMap>();
|
||||
const emitter = new LegacyEmitter(bus);
|
||||
const legacy = vi.fn();
|
||||
const busHandler = vi.fn();
|
||||
emitter.on("sms.opted_in", legacy);
|
||||
bus.on("sms.opted_in", busHandler);
|
||||
emitter.mirror("sms.opted_in", { phoneNumber: "+15551234567" });
|
||||
expect(legacy).toHaveBeenCalledTimes(1);
|
||||
expect(busHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("on twice on same topic reuses the list", { timeout: 5000 }, () => {
|
||||
const bus = new EventBus<SmsBusMap>();
|
||||
const emitter = new LegacyEmitter(bus);
|
||||
const h1 = vi.fn();
|
||||
const h2 = vi.fn();
|
||||
emitter.on("sms.opted_in", h1);
|
||||
emitter.on("sms.opted_in", h2);
|
||||
emitter.emit("sms.opted_in", { phoneNumber: "+1" });
|
||||
expect(h1).toHaveBeenCalledTimes(1);
|
||||
expect(h2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emit on non-bus topic still fires legacy listeners only", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bus = new EventBus<SmsBusMap>();
|
||||
const emitter = new LegacyEmitter(bus);
|
||||
const handler = vi.fn();
|
||||
emitter.on("custom.event" as never, handler as never);
|
||||
emitter.emit("custom.event" as never, "x" as never);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { EventBus } from "@suaveplan/events";
|
||||
import type { SmsEventMap } from "../types/sms.types.js";
|
||||
import type { SmsBusMap } from "./bus-map.js";
|
||||
|
||||
/** Legacy listener signature mirrored from upstream. */
|
||||
export type SmsLegacyListener<K extends keyof SmsEventMap> = (
|
||||
...args: SmsEventMap[K]
|
||||
) => void;
|
||||
|
||||
const BUS_TOPICS: ReadonlyArray<keyof SmsBusMap> = [
|
||||
"sms.sent",
|
||||
"sms.delivered",
|
||||
"sms.failed",
|
||||
"sms.opted_out",
|
||||
"sms.opted_in",
|
||||
"sms.scheduled",
|
||||
"sms.retry",
|
||||
"sms.rate_limited",
|
||||
];
|
||||
|
||||
/** Track legacy `on/off/emit` listeners and mirror them onto the bus. */
|
||||
export class LegacyEmitter {
|
||||
private readonly listeners = new Map<
|
||||
keyof SmsEventMap,
|
||||
Array<SmsLegacyListener<keyof SmsEventMap>>
|
||||
>();
|
||||
|
||||
public constructor(private readonly bus: EventBus<SmsBusMap>) {}
|
||||
|
||||
public on<K extends keyof SmsEventMap>(
|
||||
type: K,
|
||||
listener: SmsLegacyListener<K>
|
||||
): void {
|
||||
let list = this.listeners.get(type);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
this.listeners.set(type, list);
|
||||
}
|
||||
list.push(listener as SmsLegacyListener<keyof SmsEventMap>);
|
||||
}
|
||||
|
||||
public off<K extends keyof SmsEventMap>(
|
||||
type: K,
|
||||
listener: SmsLegacyListener<K>
|
||||
): void {
|
||||
const list = this.listeners.get(type);
|
||||
if (list === undefined) return;
|
||||
const index = list.indexOf(
|
||||
listener as SmsLegacyListener<keyof SmsEventMap>
|
||||
);
|
||||
if (index >= 0) list.splice(index, 1);
|
||||
}
|
||||
|
||||
public emit<K extends keyof SmsEventMap>(
|
||||
type: K,
|
||||
...args: SmsEventMap[K]
|
||||
): void {
|
||||
const list = this.listeners.get(type);
|
||||
if (list !== undefined) {
|
||||
for (const listener of [...list]) {
|
||||
listener(...(args as Parameters<SmsLegacyListener<K>>));
|
||||
}
|
||||
}
|
||||
if (this.isBusTopic(type)) {
|
||||
this.bus.emit(
|
||||
type as keyof SmsBusMap,
|
||||
args[0] as SmsBusMap[keyof SmsBusMap]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirror a single-payload bus emit to legacy listeners. */
|
||||
public mirror<K extends keyof SmsBusMap>(
|
||||
topic: K,
|
||||
payload: SmsBusMap[K]
|
||||
): void {
|
||||
this.bus.emit(topic, payload);
|
||||
const list = this.listeners.get(topic as keyof SmsEventMap);
|
||||
if (list !== undefined) {
|
||||
for (const listener of [...list]) {
|
||||
(listener as (payload: SmsBusMap[K]) => void)(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isBusTopic(type: keyof SmsEventMap): type is keyof SmsBusMap {
|
||||
return BUS_TOPICS.includes(type as keyof SmsBusMap);
|
||||
}
|
||||
}
|
||||
119
packages/communication/messaging/sms/src/service/sms-service.md
Normal file
119
packages/communication/messaging/sms/src/service/sms-service.md
Normal file
@@ -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,557 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SmsAbortedError,
|
||||
SmsInvalidNumberError,
|
||||
SmsProviderNotConfiguredError,
|
||||
SmsRateLimitedError,
|
||||
SmsRecipientOptedOutError,
|
||||
SmsSendFailedError,
|
||||
SmsTemplateNotFoundError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { InMemorySmsProvider } from "../in-memory-provider/in-memory-provider.js";
|
||||
import * as rootExports from "../index.js";
|
||||
import type {
|
||||
SmsDeliveryReport,
|
||||
SmsMessage,
|
||||
SmsProvider,
|
||||
} from "../types/sms.types.js";
|
||||
import { SmsService } from "./sms-service.js";
|
||||
|
||||
const validMessage: SmsMessage = { to: "+15551234567", body: "hi" };
|
||||
|
||||
function freshService(overrides: Parameters<typeof SmsService>[0] = {}): {
|
||||
service: SmsService;
|
||||
provider: InMemorySmsProvider;
|
||||
} {
|
||||
const provider = new InMemorySmsProvider();
|
||||
const service = new SmsService({
|
||||
provider,
|
||||
retry: { maxAttempts: 1 },
|
||||
rateLimiter: { capacity: 100, refillPerSecond: 100 },
|
||||
...overrides,
|
||||
});
|
||||
return { service, provider };
|
||||
}
|
||||
|
||||
describe("SmsService", () => {
|
||||
it("re-exports the full upstream surface", { timeout: 5000 }, () => {
|
||||
expect(rootExports.SmsService).toBeDefined();
|
||||
expect(rootExports.SmsError).toBeDefined();
|
||||
expect(rootExports.respondProblemJson).toBeDefined();
|
||||
expect(rootExports.PROBLEM_JSON_CONTENT_TYPE).toBeDefined();
|
||||
expect(rootExports.registerSmsErrorCodes).toBeDefined();
|
||||
expect(rootExports.SMS_ERROR_METADATA).toBeDefined();
|
||||
expect(rootExports.isValidE164).toBeDefined();
|
||||
expect(rootExports.SMS_CODES).toBeDefined();
|
||||
});
|
||||
|
||||
it("getProviderName returns null when no provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(new SmsService().getProviderName()).toBeNull();
|
||||
});
|
||||
|
||||
it("setProvider updates the in-use provider", { timeout: 5000 }, () => {
|
||||
const svc = new SmsService();
|
||||
svc.setProvider(new InMemorySmsProvider({ name: "first" }));
|
||||
svc.setProvider(new InMemorySmsProvider({ name: "second" }));
|
||||
expect(svc.getProviderName()).toBe("second");
|
||||
});
|
||||
|
||||
it("registers and reads templates", { timeout: 5000 }, () => {
|
||||
const svc = new SmsService();
|
||||
svc.registerTemplate({
|
||||
id: "x",
|
||||
body: "hi {{n}}",
|
||||
variables: ["n"],
|
||||
});
|
||||
expect(svc.getTemplate("x")?.body).toBe("hi {{n}}");
|
||||
});
|
||||
|
||||
it("no provider throws SmsProviderNotConfiguredError", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const svc = new SmsService();
|
||||
await expect(svc.send(validMessage)).rejects.toBeInstanceOf(
|
||||
SmsProviderNotConfiguredError
|
||||
);
|
||||
await expect(svc.getStatus("m1")).rejects.toBeInstanceOf(
|
||||
SmsProviderNotConfiguredError
|
||||
);
|
||||
});
|
||||
|
||||
it("invalid number throws SmsInvalidNumberError", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = freshService();
|
||||
await expect(
|
||||
service.send({ to: "bad", body: "hi" })
|
||||
).rejects.toBeInstanceOf(SmsInvalidNumberError);
|
||||
});
|
||||
|
||||
it("opt-out blocks send", { timeout: 30_000 }, async () => {
|
||||
const { service } = freshService();
|
||||
service.optOut("+15551234567");
|
||||
await expect(service.send(validMessage)).rejects.toBeInstanceOf(
|
||||
SmsRecipientOptedOutError
|
||||
);
|
||||
});
|
||||
|
||||
it("opt-in unblocks send", { timeout: 30_000 }, async () => {
|
||||
const { service } = freshService();
|
||||
service.optOut("+15551234567");
|
||||
service.optIn("+15551234567");
|
||||
await expect(service.send(validMessage)).resolves.toBeDefined();
|
||||
expect(service.isOptedOut("+15551234567")).toBe(false);
|
||||
});
|
||||
|
||||
it("rate-limited recipient throws SmsRateLimitedError", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = freshService({
|
||||
rateLimiter: { capacity: 1, refillPerSecond: 1 },
|
||||
});
|
||||
await service.send(validMessage);
|
||||
await expect(service.send(validMessage)).rejects.toBeInstanceOf(
|
||||
SmsRateLimitedError
|
||||
);
|
||||
});
|
||||
|
||||
it("unknown template throws SmsTemplateNotFoundError", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = freshService();
|
||||
await expect(
|
||||
service.send({ ...validMessage, templateId: "absent" })
|
||||
).rejects.toBeInstanceOf(SmsTemplateNotFoundError);
|
||||
});
|
||||
|
||||
it("renders templates before sending", { timeout: 30_000 }, async () => {
|
||||
const { service, provider } = freshService();
|
||||
service.registerTemplate({
|
||||
id: "greet",
|
||||
body: "Hello {{name}}, code {{code}}",
|
||||
variables: ["name", "code"],
|
||||
});
|
||||
await service.send({
|
||||
...validMessage,
|
||||
templateId: "greet",
|
||||
templateVars: { name: "Alice", code: "1234" },
|
||||
});
|
||||
expect(provider.sent[0]?.body).toBe("Hello Alice, code 1234");
|
||||
});
|
||||
|
||||
it("wraps non-typed provider errors in SmsSendFailedError", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service, provider } = freshService();
|
||||
provider.failOn(() => true, new Error("transient"));
|
||||
await expect(service.send(validMessage)).rejects.toBeInstanceOf(
|
||||
SmsSendFailedError
|
||||
);
|
||||
});
|
||||
|
||||
it("re-throws typed errors unchanged", { timeout: 30_000 }, async () => {
|
||||
class TypedProvider implements SmsProvider {
|
||||
public readonly name = "typed";
|
||||
public async send(): Promise<SmsDeliveryReport> {
|
||||
throw new SmsInvalidNumberError({
|
||||
context: { phoneNumber: "+1" },
|
||||
});
|
||||
}
|
||||
public async getStatus(): Promise<SmsDeliveryReport> {
|
||||
throw new Error("no");
|
||||
}
|
||||
}
|
||||
const svc = new SmsService({
|
||||
provider: new TypedProvider(),
|
||||
retry: { maxAttempts: 1 },
|
||||
});
|
||||
await expect(svc.send(validMessage)).rejects.toBeInstanceOf(
|
||||
SmsInvalidNumberError
|
||||
);
|
||||
});
|
||||
|
||||
it("legacy on/off mirrors the bus", { timeout: 30_000 }, async () => {
|
||||
const { service } = freshService();
|
||||
const handler = vi.fn();
|
||||
service.on("sms.sent", handler);
|
||||
await service.send(validMessage);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
service.off("sms.sent", handler);
|
||||
await service.send(validMessage);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bus receives emitted reports", { timeout: 30_000 }, async () => {
|
||||
const { service } = freshService();
|
||||
const busHandler = vi.fn();
|
||||
service.bus.on("sms.sent", busHandler);
|
||||
await service.send(validMessage);
|
||||
expect(busHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("send records sent_total and send_duration_ms", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = freshService();
|
||||
await service.send(validMessage);
|
||||
// Implicitly exercised via the OTel record helpers in service.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("emits sms.rate_limited when throttled", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service } = freshService({
|
||||
rateLimiter: { capacity: 1, refillPerSecond: 1 },
|
||||
});
|
||||
const handler = vi.fn();
|
||||
service.on("sms.rate_limited", handler);
|
||||
await service.send(validMessage);
|
||||
await expect(service.send(validMessage)).rejects.toBeInstanceOf(
|
||||
SmsRateLimitedError
|
||||
);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits sms.retry on retry attempts", { timeout: 30_000 }, async () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
let calls = 0;
|
||||
provider.failOn(() => {
|
||||
calls += 1;
|
||||
return calls === 1;
|
||||
}, new Error("flaky"));
|
||||
const svc = new SmsService({
|
||||
provider,
|
||||
retry: {
|
||||
maxAttempts: 2,
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
random: () => 0,
|
||||
},
|
||||
rateLimiter: { capacity: 10, refillPerSecond: 10 },
|
||||
});
|
||||
const retryHandler = vi.fn();
|
||||
svc.on("sms.retry", retryHandler);
|
||||
await svc.send(validMessage);
|
||||
expect(retryHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards a non-aborted signal through retry", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service, provider } = freshService();
|
||||
const ctrl = new AbortController();
|
||||
const report = await service.send(validMessage, {
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
expect(report.to).toBe("+15551234567");
|
||||
expect(provider.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("pre-aborted signal halts send", { timeout: 30_000 }, async () => {
|
||||
const { service, provider } = freshService();
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
service.send(validMessage, { signal: ctrl.signal })
|
||||
).rejects.toBeInstanceOf(SmsAbortedError);
|
||||
expect(provider.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("scheduleAt routes through the pipeline", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
// Use real timers with a near-instant delay.
|
||||
const { service, provider } = freshService();
|
||||
const scheduledHandler = vi.fn();
|
||||
const sentHandler = vi.fn();
|
||||
service.bus.on("sms.scheduled", scheduledHandler);
|
||||
service.bus.on("sms.sent", sentHandler);
|
||||
service.scheduleAt(new Date(Date.now() + 5), validMessage);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(scheduledHandler).toHaveBeenCalledTimes(1);
|
||||
expect(sentHandler).toHaveBeenCalledTimes(1);
|
||||
expect(provider.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("scheduleAt cancel emits sms.failed", { timeout: 30_000 }, async () => {
|
||||
const { service } = freshService();
|
||||
const failedHandler = vi.fn();
|
||||
service.bus.on("sms.failed", failedHandler);
|
||||
const handle = service.scheduleAt(
|
||||
new Date(Date.now() + 60_000),
|
||||
validMessage
|
||||
);
|
||||
handle.cancel();
|
||||
// cancel is synchronous
|
||||
expect(failedHandler).toHaveBeenCalledTimes(1);
|
||||
const report = failedHandler.mock.calls[0]?.[0] as SmsDeliveryReport;
|
||||
expect(report.errorCode).toBe("SMS.SCHEDULE_CANCELLED");
|
||||
});
|
||||
|
||||
it("scheduleAt failure emits sms.failed", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
provider.failOn(() => true, new Error("boom"));
|
||||
const service = new SmsService({
|
||||
provider,
|
||||
retry: { maxAttempts: 1 },
|
||||
rateLimiter: { capacity: 10, refillPerSecond: 10 },
|
||||
});
|
||||
const failedHandler = vi.fn();
|
||||
service.bus.on("sms.failed", failedHandler);
|
||||
service.scheduleAt(new Date(Date.now() + 5), validMessage);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(failedHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("markDelivered emits sms.delivered and caches receipt", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { service } = freshService();
|
||||
const handler = vi.fn();
|
||||
service.on("sms.delivered", handler);
|
||||
const report: SmsDeliveryReport = {
|
||||
messageId: "m1",
|
||||
to: "+15551234567",
|
||||
status: "delivered",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
service.markDelivered(report);
|
||||
expect(handler).toHaveBeenCalledWith(report);
|
||||
expect(service.deliveryTracker.getStatus("m1")).toEqual(report);
|
||||
});
|
||||
|
||||
it("markFailed emits sms.failed and caches receipt", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { service } = freshService();
|
||||
const handler = vi.fn();
|
||||
service.on("sms.failed", handler);
|
||||
const report: SmsDeliveryReport = {
|
||||
messageId: "m2",
|
||||
to: "+15551234567",
|
||||
status: "failed",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
service.markFailed(report);
|
||||
expect(handler).toHaveBeenCalledWith(report);
|
||||
});
|
||||
|
||||
it("getStatus consults the delivery tracker + provider", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service, provider } = freshService();
|
||||
const report = await service.send(validMessage);
|
||||
// Cache hit
|
||||
const cached = await service.getStatus(report.messageId);
|
||||
expect(cached.messageId).toBe(report.messageId);
|
||||
expect(provider.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("optOut emits sms.opted_out", { timeout: 5000 }, () => {
|
||||
const { service } = freshService();
|
||||
const handler = vi.fn();
|
||||
service.on("sms.opted_out", handler);
|
||||
service.optOut("+15551234567", "marketing");
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("optIn emits sms.opted_in", { timeout: 5000 }, () => {
|
||||
const { service } = freshService();
|
||||
const handler = vi.fn();
|
||||
service.on("sms.opted_in", handler);
|
||||
service.optIn("+15551234567");
|
||||
expect(handler).toHaveBeenCalledWith({ phoneNumber: "+15551234567" });
|
||||
});
|
||||
|
||||
it("emit forwards legacy events to the bus", { timeout: 5000 }, () => {
|
||||
const { service } = freshService();
|
||||
const busHandler = vi.fn();
|
||||
service.bus.on("sms.opted_out", busHandler);
|
||||
service.emit("sms.opted_out", {
|
||||
phoneNumber: "+15551234567",
|
||||
optedOutAt: new Date(),
|
||||
});
|
||||
expect(busHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emit on non-bus topics still notifies legacy listeners", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { service } = freshService();
|
||||
const handler = vi.fn();
|
||||
// Use a custom non-bus topic
|
||||
service.on("custom.event" as never, handler as never);
|
||||
service.emit("custom.event" as never, "x" as never);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("off is a no-op for unregistered listeners", { timeout: 5000 }, () => {
|
||||
const { service } = freshService();
|
||||
const handler = vi.fn();
|
||||
service.off("sms.sent", handler);
|
||||
// also exercise off after a register/unregister
|
||||
service.on("sms.sent", handler);
|
||||
service.off("sms.sent", () => undefined);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("uses default options when none supplied", { timeout: 5000 }, () => {
|
||||
const svc = new SmsService();
|
||||
expect(svc.rateLimiter).toBeDefined();
|
||||
expect(svc.retry).toBeDefined();
|
||||
expect(svc.scheduler).toBeDefined();
|
||||
expect(svc.deliveryTracker).toBeDefined();
|
||||
});
|
||||
|
||||
it("feature subpaths are declared in package.json", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = resolve(here, "../../package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
const subpaths = Object.keys(pkg.exports).filter(
|
||||
(k) => k.startsWith("./") && k !== "./package.json"
|
||||
);
|
||||
expect(subpaths.length).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it("wraps non-Error provider rejections", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
class StringRejectProvider implements SmsProvider {
|
||||
public readonly name = "string-reject";
|
||||
public async send(): Promise<SmsDeliveryReport> {
|
||||
// biome rejects literal throws, so we bury the string in unknown
|
||||
const bad: unknown = "non-error throwable";
|
||||
throw bad;
|
||||
}
|
||||
public async getStatus(): Promise<SmsDeliveryReport> {
|
||||
throw new Error("no");
|
||||
}
|
||||
}
|
||||
const svc = new SmsService({
|
||||
provider: new StringRejectProvider(),
|
||||
retry: { maxAttempts: 1 },
|
||||
rateLimiter: { capacity: 10, refillPerSecond: 10 },
|
||||
});
|
||||
await expect(svc.send(validMessage)).rejects.toBeInstanceOf(
|
||||
SmsSendFailedError
|
||||
);
|
||||
});
|
||||
|
||||
it("renders template with no templateVars supplied", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { service, provider } = freshService();
|
||||
service.registerTemplate({
|
||||
id: "noargs",
|
||||
body: "Hello {{name}}",
|
||||
variables: ["name"],
|
||||
});
|
||||
await service.send({
|
||||
to: "+15551234567",
|
||||
body: "ignored",
|
||||
templateId: "noargs",
|
||||
});
|
||||
expect(provider.sent[0]?.body).toBe("Hello ");
|
||||
});
|
||||
|
||||
it("scheduleAt failure with typed error preserves code", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const provider = new InMemorySmsProvider();
|
||||
provider.failOn(() => true, new Error("boom"));
|
||||
const service = new SmsService({
|
||||
provider,
|
||||
retry: { maxAttempts: 1 },
|
||||
rateLimiter: { capacity: 10, refillPerSecond: 10 },
|
||||
});
|
||||
const failedHandler = vi.fn();
|
||||
service.bus.on("sms.failed", failedHandler);
|
||||
service.scheduleAt(new Date(Date.now() + 5), validMessage);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
const report = failedHandler.mock.calls[0]?.[0] as SmsDeliveryReport;
|
||||
expect(report.errorCode).toBe("SMS.SEND_FAILED");
|
||||
expect(report.errorMessage).toBeDefined();
|
||||
});
|
||||
|
||||
it("scheduleAt failure without code falls back", {
|
||||
timeout: 30_000,
|
||||
}, () => {
|
||||
const { service } = freshService();
|
||||
const failedHandler = vi.fn();
|
||||
service.bus.on("sms.failed", failedHandler);
|
||||
// Synthetic error without "code" property
|
||||
const handle = service.scheduleAt(
|
||||
new Date(Date.now() + 60_000),
|
||||
validMessage
|
||||
);
|
||||
handle.cancel();
|
||||
const report = failedHandler.mock.calls[0]?.[0] as SmsDeliveryReport;
|
||||
expect(report.errorCode).toBe("SMS.SCHEDULE_CANCELLED");
|
||||
});
|
||||
|
||||
it("extractFailure handles every shape", { timeout: 5000 }, () => {
|
||||
expect(SmsService.extractFailure(new Error("boom"))).toEqual({
|
||||
code: "SMS.SEND_FAILED",
|
||||
message: "boom",
|
||||
});
|
||||
expect(SmsService.extractFailure({ code: "C", message: "M" })).toEqual({
|
||||
code: "C",
|
||||
message: "M",
|
||||
});
|
||||
expect(SmsService.extractFailure({})).toEqual({
|
||||
code: "SMS.SEND_FAILED",
|
||||
message: "",
|
||||
});
|
||||
expect(SmsService.extractFailure("string-thrown")).toEqual({
|
||||
code: "SMS.SEND_FAILED",
|
||||
message: "string-thrown",
|
||||
});
|
||||
expect(SmsService.extractFailure(null)).toEqual({
|
||||
code: "SMS.SEND_FAILED",
|
||||
message: "null",
|
||||
});
|
||||
expect(
|
||||
SmsService.extractFailure({ code: undefined, message: undefined })
|
||||
).toEqual({
|
||||
code: "SMS.SEND_FAILED",
|
||||
message: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("no node: or bun: imports leak into src/", { timeout: 5000 }, () => {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const srcRoot = resolve(here, "..");
|
||||
const offenders: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === "node_modules" || entry === "dist") continue;
|
||||
const full = `${dir}/${entry}`;
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.endsWith(".ts")) continue;
|
||||
if (entry.endsWith(".test.ts")) continue;
|
||||
const text = readFileSync(full, "utf8");
|
||||
if (/from\s+['"](node:|bun:)/.test(text)) {
|
||||
offenders.push(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(srcRoot);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
305
packages/communication/messaging/sms/src/service/sms-service.ts
Normal file
305
packages/communication/messaging/sms/src/service/sms-service.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import type { IdGenerator } from "@suaveplan/types";
|
||||
import { DeliveryTracker } from "../delivery-tracker/delivery-tracker.js";
|
||||
import {
|
||||
SmsAbortedError,
|
||||
SmsInvalidNumberError,
|
||||
SmsProviderNotConfiguredError,
|
||||
SmsRateLimitedError,
|
||||
SmsRecipientOptedOutError,
|
||||
SmsSendFailedError,
|
||||
SmsTemplateNotFoundError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { OptOutRegistry } from "../opt-out/opt-out.js";
|
||||
import {
|
||||
recordSmsFailed,
|
||||
recordSmsRetry,
|
||||
recordSmsSendDuration,
|
||||
recordSmsSent,
|
||||
} from "../otel/metrics.js";
|
||||
import { RateLimiter } from "../rate-limiter/rate-limiter.js";
|
||||
import { RetryPolicy } from "../retry/retry.js";
|
||||
import { MessageScheduler } from "../scheduler/scheduler.js";
|
||||
import { TemplateRegistry } from "../templates/templates.js";
|
||||
import type {
|
||||
OptOutEntry,
|
||||
ScheduleHandle,
|
||||
SmsDeliveryReport,
|
||||
SmsEventMap,
|
||||
SmsMessage,
|
||||
SmsProvider,
|
||||
SmsServiceOptions,
|
||||
SmsTemplate,
|
||||
} from "../types/sms.types.js";
|
||||
import { isValidE164 } from "../validation/phone.js";
|
||||
import type { SmsBusMap } from "./bus-map.js";
|
||||
import { LegacyEmitter, type SmsLegacyListener } from "./legacy-emitter.js";
|
||||
|
||||
/** Options accepted by `SmsService.send`. */
|
||||
export interface SmsSendOptions {
|
||||
/** Optional cancellation signal. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
let defaultIdCounter = 0;
|
||||
const defaultIdGenerator: IdGenerator = () => {
|
||||
defaultIdCounter += 1;
|
||||
return `sms_${defaultIdCounter.toString(36)}_${Date.now().toString(36)}`;
|
||||
};
|
||||
|
||||
/** Normalize an unknown thrown value to a `{code, message}` record. */
|
||||
function extractFailure(error: unknown): { code: string; message: string } {
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const obj = error as { code?: unknown; message?: unknown };
|
||||
const code =
|
||||
"code" in obj && obj.code !== undefined
|
||||
? String(obj.code)
|
||||
: "SMS.SEND_FAILED";
|
||||
const message =
|
||||
"message" in obj && obj.message !== undefined
|
||||
? String(obj.message)
|
||||
: "";
|
||||
return { code, message };
|
||||
}
|
||||
return { code: "SMS.SEND_FAILED", message: String(error) };
|
||||
}
|
||||
|
||||
/** Provider-agnostic SMS orchestrator. */
|
||||
export class SmsService {
|
||||
public readonly bus: EventBus<SmsBusMap>;
|
||||
public readonly templates: TemplateRegistry;
|
||||
public readonly optOuts: OptOutRegistry;
|
||||
public readonly rateLimiter: RateLimiter;
|
||||
public readonly retry: RetryPolicy;
|
||||
public readonly scheduler: MessageScheduler;
|
||||
public readonly deliveryTracker: DeliveryTracker;
|
||||
|
||||
private provider: SmsProvider | null;
|
||||
private readonly idGenerator: IdGenerator;
|
||||
private readonly legacy: LegacyEmitter;
|
||||
|
||||
public constructor(options: SmsServiceOptions = {}) {
|
||||
this.bus = new EventBus<SmsBusMap>();
|
||||
this.legacy = new LegacyEmitter(this.bus);
|
||||
this.templates = new TemplateRegistry();
|
||||
this.optOuts = new OptOutRegistry();
|
||||
this.rateLimiter = new RateLimiter(options.rateLimiter);
|
||||
this.retry = new RetryPolicy(options.retry);
|
||||
this.scheduler = new MessageScheduler(options.scheduler);
|
||||
this.deliveryTracker = new DeliveryTracker(options.deliveryTracker);
|
||||
this.provider = options.provider ?? null;
|
||||
this.idGenerator = options.idGenerator ?? defaultIdGenerator;
|
||||
}
|
||||
|
||||
public setProvider(provider: SmsProvider): void {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
public getProviderName(): string | null {
|
||||
return this.provider?.name ?? null;
|
||||
}
|
||||
|
||||
public registerTemplate(template: SmsTemplate): void {
|
||||
this.templates.register(template);
|
||||
}
|
||||
|
||||
public getTemplate(id: string): SmsTemplate | undefined {
|
||||
return this.templates.get(id);
|
||||
}
|
||||
|
||||
public async send(
|
||||
message: SmsMessage,
|
||||
options: SmsSendOptions = {}
|
||||
): Promise<SmsDeliveryReport> {
|
||||
const startMs = Date.now();
|
||||
try {
|
||||
const report = await this.runPipeline(message, options);
|
||||
recordSmsSent(message.channel ?? "default", false);
|
||||
recordSmsSendDuration(Date.now() - startMs, "ok");
|
||||
this.legacy.mirror("sms.sent", report);
|
||||
return report;
|
||||
} catch (error) {
|
||||
const wrapped = this.wrap(error);
|
||||
recordSmsFailed(wrapped.code);
|
||||
recordSmsSendDuration(
|
||||
Date.now() - startMs,
|
||||
wrapped.code === "SMS.ABORTED" ? "aborted" : "error"
|
||||
);
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
public scheduleAt(sendAt: Date, message: SmsMessage): ScheduleHandle {
|
||||
const handle = this.scheduler.scheduleAt(sendAt, () => {
|
||||
void this.send(message).catch((error: unknown) => {
|
||||
this.legacy.mirror(
|
||||
"sms.failed",
|
||||
this.toFailedReport(message, extractFailure(error))
|
||||
);
|
||||
});
|
||||
});
|
||||
this.legacy.mirror("sms.scheduled", { to: message.to, sendAt });
|
||||
return {
|
||||
sendAt,
|
||||
cancel: () => {
|
||||
handle.cancel();
|
||||
this.legacy.mirror(
|
||||
"sms.failed",
|
||||
this.toFailedReport(message, {
|
||||
code: "SMS.SCHEDULE_CANCELLED",
|
||||
message: "schedule cancelled",
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async getStatus(messageId: string): Promise<SmsDeliveryReport> {
|
||||
if (this.provider === null) {
|
||||
throw new SmsProviderNotConfiguredError();
|
||||
}
|
||||
return this.deliveryTracker.lookupOrFetch(messageId, this.provider);
|
||||
}
|
||||
|
||||
public optOut(phoneNumber: string, channel?: string): OptOutEntry {
|
||||
const entry = this.optOuts.add(phoneNumber, channel);
|
||||
this.legacy.mirror("sms.opted_out", entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public optIn(phoneNumber: string): void {
|
||||
this.optOuts.remove(phoneNumber);
|
||||
this.legacy.mirror("sms.opted_in", { phoneNumber });
|
||||
}
|
||||
|
||||
public isOptedOut(phoneNumber: string): boolean {
|
||||
return this.optOuts.has(phoneNumber);
|
||||
}
|
||||
|
||||
public markDelivered(report: SmsDeliveryReport): void {
|
||||
this.deliveryTracker.ingest(report);
|
||||
this.legacy.mirror("sms.delivered", report);
|
||||
}
|
||||
|
||||
public markFailed(report: SmsDeliveryReport): void {
|
||||
this.deliveryTracker.ingest(report);
|
||||
this.legacy.mirror("sms.failed", report);
|
||||
}
|
||||
|
||||
public on<K extends keyof SmsEventMap>(
|
||||
type: K,
|
||||
listener: SmsLegacyListener<K>
|
||||
): void {
|
||||
this.legacy.on(type, listener);
|
||||
}
|
||||
|
||||
public off<K extends keyof SmsEventMap>(
|
||||
type: K,
|
||||
listener: SmsLegacyListener<K>
|
||||
): void {
|
||||
this.legacy.off(type, listener);
|
||||
}
|
||||
|
||||
public emit<K extends keyof SmsEventMap>(
|
||||
type: K,
|
||||
...args: SmsEventMap[K]
|
||||
): void {
|
||||
this.legacy.emit(type, ...args);
|
||||
}
|
||||
|
||||
private async runPipeline(
|
||||
message: SmsMessage,
|
||||
options: SmsSendOptions
|
||||
): Promise<SmsDeliveryReport> {
|
||||
if (options.signal?.aborted) throw new SmsAbortedError();
|
||||
if (this.provider === null) {
|
||||
throw new SmsProviderNotConfiguredError();
|
||||
}
|
||||
if (!isValidE164(message.to)) {
|
||||
throw new SmsInvalidNumberError({
|
||||
context: { phoneNumber: message.to },
|
||||
});
|
||||
}
|
||||
if (this.optOuts.has(message.to)) {
|
||||
throw new SmsRecipientOptedOutError({
|
||||
context: { phoneNumber: message.to },
|
||||
});
|
||||
}
|
||||
const limiterKey = `${message.channel ?? "default"}:${message.to}`;
|
||||
if (!this.rateLimiter.tryConsume(limiterKey)) {
|
||||
this.legacy.mirror("sms.rate_limited", {
|
||||
to: message.to,
|
||||
key: limiterKey,
|
||||
});
|
||||
throw new SmsRateLimitedError({
|
||||
context: { phoneNumber: message.to, key: limiterKey },
|
||||
});
|
||||
}
|
||||
const outgoing = this.applyTemplate(message);
|
||||
const provider = this.provider;
|
||||
const report = await this.retry.execute(
|
||||
async () => provider.send(outgoing, options),
|
||||
{
|
||||
...(options.signal !== undefined
|
||||
? { signal: options.signal }
|
||||
: {}),
|
||||
onRetry: (event) => {
|
||||
recordSmsRetry(event.attempt);
|
||||
this.legacy.mirror("sms.retry", {
|
||||
...event,
|
||||
to: message.to,
|
||||
});
|
||||
},
|
||||
to: message.to,
|
||||
}
|
||||
);
|
||||
this.deliveryTracker.ingest(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
private applyTemplate(message: SmsMessage): SmsMessage {
|
||||
if (message.templateId === undefined) return message;
|
||||
const template = this.templates.get(message.templateId);
|
||||
if (template === undefined) {
|
||||
throw new SmsTemplateNotFoundError({
|
||||
context: { templateId: message.templateId },
|
||||
});
|
||||
}
|
||||
const body = this.templates.render(
|
||||
message.templateId,
|
||||
message.templateVars ?? {}
|
||||
);
|
||||
return { ...message, body };
|
||||
}
|
||||
|
||||
private wrap(error: unknown): Error & { code: string } {
|
||||
if (error instanceof Error && "code" in error) {
|
||||
return error as Error & { code: string };
|
||||
}
|
||||
// runPipeline always rejects with Error; cast is safe.
|
||||
const cause = error as Error;
|
||||
return new SmsSendFailedError({ cause }) as Error & { code: string };
|
||||
}
|
||||
|
||||
/** Normalize an unknown thrown value to a `{code, message}` record. */
|
||||
public static extractFailure(error: unknown): {
|
||||
code: string;
|
||||
message: string;
|
||||
} {
|
||||
return extractFailure(error);
|
||||
}
|
||||
|
||||
private toFailedReport(
|
||||
message: SmsMessage,
|
||||
error: { code: string; message: string }
|
||||
): SmsDeliveryReport {
|
||||
return {
|
||||
messageId: this.idGenerator(),
|
||||
to: message.to,
|
||||
status: "failed",
|
||||
timestamp: new Date(),
|
||||
errorCode: error.code,
|
||||
errorMessage: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { renderTemplate, TemplateRegistry } from "./templates.js";
|
||||
@@ -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,127 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SmsTemplateNotFoundError } from "../error-codes/error-codes.js";
|
||||
import { renderTemplate, TemplateRegistry } from "./templates.js";
|
||||
|
||||
describe("renderTemplate", () => {
|
||||
it("substitutes declared variables", { timeout: 5000 }, () => {
|
||||
const out = renderTemplate(
|
||||
{ id: "x", body: "Hello {{name}}!", variables: ["name"] },
|
||||
{ name: "Alice" }
|
||||
);
|
||||
expect(out).toBe("Hello Alice!");
|
||||
});
|
||||
|
||||
it("missing variables fall back to defaults then empty string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
renderTemplate(
|
||||
{ id: "x", body: "Hi {{name}}", variables: ["name"] },
|
||||
{}
|
||||
)
|
||||
).toBe("Hi ");
|
||||
expect(
|
||||
renderTemplate(
|
||||
{ id: "x", body: "Hi {{name}}", variables: ["name"] },
|
||||
{},
|
||||
{ name: "World" }
|
||||
)
|
||||
).toBe("Hi World");
|
||||
expect(
|
||||
renderTemplate(
|
||||
{
|
||||
id: "x",
|
||||
body: "Hi {{name}}",
|
||||
variables: ["name"],
|
||||
defaults: { name: "TemplateDefault" },
|
||||
},
|
||||
{}
|
||||
)
|
||||
).toBe("Hi TemplateDefault");
|
||||
});
|
||||
|
||||
it("leaves undeclared placeholders untouched", { timeout: 5000 }, () => {
|
||||
const out = renderTemplate(
|
||||
{
|
||||
id: "x",
|
||||
body: "Hi {{name}}, {{not_declared}}",
|
||||
variables: ["name"],
|
||||
},
|
||||
{ name: "Alice" }
|
||||
);
|
||||
expect(out).toBe("Hi Alice, {{not_declared}}");
|
||||
});
|
||||
|
||||
it("escapes regex special chars in variable names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderTemplate(
|
||||
{ id: "x", body: "Hi {{na.me}}!", variables: ["na.me"] },
|
||||
{ "na.me": "Alice" }
|
||||
);
|
||||
expect(out).toBe("Hi Alice!");
|
||||
});
|
||||
|
||||
it("call-level defaults beat template-level defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = renderTemplate(
|
||||
{
|
||||
id: "x",
|
||||
body: "Hi {{name}}",
|
||||
variables: ["name"],
|
||||
defaults: { name: "T" },
|
||||
},
|
||||
{},
|
||||
{ name: "C" }
|
||||
);
|
||||
expect(out).toBe("Hi C");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TemplateRegistry", () => {
|
||||
it("registry register/get/has/unregister/list", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new TemplateRegistry();
|
||||
const tpl = { id: "t1", body: "Hi", variables: [] };
|
||||
reg.register(tpl);
|
||||
expect(reg.get("t1")).toEqual(tpl);
|
||||
expect(reg.has("t1")).toBe(true);
|
||||
expect(reg.list()).toEqual([tpl]);
|
||||
expect(reg.unregister("t1")).toBe(true);
|
||||
expect(reg.get("t1")).toBeUndefined();
|
||||
expect(reg.unregister("t1")).toBe(false);
|
||||
});
|
||||
|
||||
it("require throws SmsTemplateNotFoundError when missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new TemplateRegistry();
|
||||
expect(() => reg.require("absent")).toThrow(SmsTemplateNotFoundError);
|
||||
});
|
||||
|
||||
it("overwrites existing template with same id", { timeout: 5000 }, () => {
|
||||
const reg = new TemplateRegistry();
|
||||
reg.register({ id: "t1", body: "old", variables: [] });
|
||||
reg.register({ id: "t1", body: "new", variables: [] });
|
||||
expect(reg.get("t1")?.body).toBe("new");
|
||||
});
|
||||
|
||||
it("clear drops every template", { timeout: 5000 }, () => {
|
||||
const reg = new TemplateRegistry();
|
||||
reg.register({ id: "t1", body: "x", variables: [] });
|
||||
reg.register({ id: "t2", body: "y", variables: [] });
|
||||
reg.clear();
|
||||
expect(reg.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("render delegates to renderTemplate via require", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = new TemplateRegistry();
|
||||
reg.register({ id: "greet", body: "Hi {{name}}", variables: ["name"] });
|
||||
expect(reg.render("greet", { name: "Alice" })).toBe("Hi Alice");
|
||||
expect(reg.render("greet", {}, { name: "Default" })).toBe("Hi Default");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { SmsTemplateNotFoundError } from "../error-codes/error-codes.js";
|
||||
import type { SmsTemplate } from "../types/sms.types.js";
|
||||
|
||||
/**
|
||||
* Render a template by substituting `{{var}}` placeholders. Variables
|
||||
* declared in `template.variables` are sourced from (1) the `vars` map,
|
||||
* (2) the `defaults` map, (3) the template's `defaults` map, then
|
||||
* fall back to the empty string. Placeholders for variables not declared
|
||||
* in `template.variables` are left untouched so callers can layer their
|
||||
* own renderer on top.
|
||||
*/
|
||||
export function renderTemplate(
|
||||
template: SmsTemplate,
|
||||
vars: Record<string, string>,
|
||||
defaults: Record<string, string> = {}
|
||||
): string {
|
||||
let result = template.body;
|
||||
for (const varName of template.variables) {
|
||||
const value =
|
||||
vars[varName] ??
|
||||
defaults[varName] ??
|
||||
template.defaults?.[varName] ??
|
||||
"";
|
||||
const pattern = new RegExp(`\\{\\{${escapeRegExp(varName)}\\}\\}`, "g");
|
||||
result = result.replace(pattern, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** In-memory template registry used by `SmsService`. */
|
||||
export class TemplateRegistry {
|
||||
private readonly templates = new Map<string, SmsTemplate>();
|
||||
|
||||
/** Register a template; overwrites an existing entry with the same id. */
|
||||
public register(template: SmsTemplate): void {
|
||||
this.templates.set(template.id, template);
|
||||
}
|
||||
|
||||
/** Get a template by id; returns `undefined` if absent. */
|
||||
public get(id: string): SmsTemplate | undefined {
|
||||
return this.templates.get(id);
|
||||
}
|
||||
|
||||
/** Get a template by id or throw `SmsTemplateNotFoundError`. */
|
||||
public require(id: string): SmsTemplate {
|
||||
const template = this.templates.get(id);
|
||||
if (template === undefined) {
|
||||
throw new SmsTemplateNotFoundError({
|
||||
context: { templateId: id },
|
||||
});
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
/** Return `true` iff the template is registered. */
|
||||
public has(id: string): boolean {
|
||||
return this.templates.has(id);
|
||||
}
|
||||
|
||||
/** Unregister a template; returns `true` iff a template was removed. */
|
||||
public unregister(id: string): boolean {
|
||||
return this.templates.delete(id);
|
||||
}
|
||||
|
||||
/** Snapshot every registered template. */
|
||||
public list(): ReadonlyArray<SmsTemplate> {
|
||||
return [...this.templates.values()];
|
||||
}
|
||||
|
||||
/** Render a registered template; throws if id is unknown. */
|
||||
public render(
|
||||
id: string,
|
||||
vars: Record<string, string>,
|
||||
defaults?: Record<string, string>
|
||||
): string {
|
||||
return renderTemplate(this.require(id), vars, defaults);
|
||||
}
|
||||
|
||||
/** Drop every registered template. */
|
||||
public clear(): void {
|
||||
this.templates.clear();
|
||||
}
|
||||
}
|
||||
114
packages/communication/messaging/sms/src/types/sms.types.md
Normal file
114
packages/communication/messaging/sms/src/types/sms.types.md
Normal file
@@ -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`.
|
||||
201
packages/communication/messaging/sms/src/types/sms.types.ts
Normal file
201
packages/communication/messaging/sms/src/types/sms.types.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Public type contracts for `@suaveplan/sms`. Kept dependency-free so that
|
||||
* consumers can import the contracts without paying for the implementation.
|
||||
*
|
||||
* @module types/sms.types
|
||||
*/
|
||||
|
||||
import type { Clock, IdGenerator, TimersLike } from "@suaveplan/types";
|
||||
|
||||
/** Delivery status of an SMS message. */
|
||||
export type SmsDeliveryStatus =
|
||||
| "queued"
|
||||
| "sent"
|
||||
| "delivered"
|
||||
| "failed"
|
||||
| "undelivered";
|
||||
|
||||
/** Outgoing SMS message. */
|
||||
export interface SmsMessage {
|
||||
/** Recipient phone number in E.164 format. */
|
||||
to: string;
|
||||
/** Sender phone number or alphanumeric ID. */
|
||||
from?: string;
|
||||
/** Message body text. */
|
||||
body: string;
|
||||
/** Template ID for rendering. */
|
||||
templateId?: string;
|
||||
/** Variables for template rendering. */
|
||||
templateVars?: Record<string, string>;
|
||||
/** Arbitrary metadata. */
|
||||
metadata?: Record<string, unknown>;
|
||||
/** Optional channel name for rate-limiter bucketing. */
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
/** Report of an SMS delivery attempt. */
|
||||
export interface SmsDeliveryReport {
|
||||
/** Unique identifier for the message. */
|
||||
messageId: string;
|
||||
/** Recipient phone number. */
|
||||
to: string;
|
||||
/** Current delivery status. */
|
||||
status: SmsDeliveryStatus;
|
||||
/** Timestamp of the status update. */
|
||||
timestamp: Date;
|
||||
/** Provider-specific error code. */
|
||||
errorCode?: string;
|
||||
/** Human-readable error message. */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/** Options forwarded to a provider's send method. */
|
||||
export interface SmsSendCallOptions {
|
||||
/** Optional cancellation signal. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Interface that all SMS providers must implement. */
|
||||
export interface SmsProvider {
|
||||
/** Provider name identifier. */
|
||||
readonly name: string;
|
||||
/** Send an SMS message. */
|
||||
send(
|
||||
message: SmsMessage,
|
||||
options?: SmsSendCallOptions
|
||||
): Promise<SmsDeliveryReport>;
|
||||
/** Get delivery status for a message. */
|
||||
getStatus(messageId: string): Promise<SmsDeliveryReport>;
|
||||
}
|
||||
|
||||
/** Opt-out registry entry. */
|
||||
export interface OptOutEntry {
|
||||
/** Phone number that opted out. */
|
||||
phoneNumber: string;
|
||||
/** When the opt-out was recorded. */
|
||||
optedOutAt: Date;
|
||||
/** Optional channel identifier. */
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
/** SMS template definition. */
|
||||
export interface SmsTemplate {
|
||||
/** Unique template identifier. */
|
||||
id: string;
|
||||
/** Template body with {{variable}} placeholders. */
|
||||
body: string;
|
||||
/** List of variable names used in the template. */
|
||||
variables: string[];
|
||||
/** Optional default values applied when `templateVars` omits a key. */
|
||||
defaults?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Retry payload emitted to the bus for observability. */
|
||||
export interface SmsRetryEvent {
|
||||
/** Recipient (echo of the message.to). */
|
||||
to: string;
|
||||
/** Zero-based retry attempt index. */
|
||||
attempt: number;
|
||||
/** Error that triggered the retry. */
|
||||
error: Error;
|
||||
}
|
||||
|
||||
/** Rate-limit payload emitted to the bus. */
|
||||
export interface SmsRateLimitedEvent {
|
||||
/** Recipient that hit the limit. */
|
||||
to: string;
|
||||
/** Bucket key the limiter rejected. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** Scheduled-message payload emitted to the bus. */
|
||||
export interface SmsScheduledEvent {
|
||||
/** Recipient. */
|
||||
to: string;
|
||||
/** Target instant for delivery. */
|
||||
sendAt: Date;
|
||||
}
|
||||
|
||||
/** Event map for SMS service events. */
|
||||
export interface SmsEventMap {
|
||||
"sms.sent": [SmsDeliveryReport];
|
||||
"sms.delivered": [SmsDeliveryReport];
|
||||
"sms.failed": [SmsDeliveryReport];
|
||||
"sms.opted_out": [OptOutEntry];
|
||||
"sms.opted_in": [{ phoneNumber: string }];
|
||||
"sms.scheduled": [SmsScheduledEvent];
|
||||
"sms.retry": [SmsRetryEvent];
|
||||
"sms.rate_limited": [SmsRateLimitedEvent];
|
||||
[key: string]: unknown[];
|
||||
[key: symbol]: unknown[];
|
||||
}
|
||||
|
||||
/** Rate-limiter options. */
|
||||
export interface RateLimiterOptions {
|
||||
/** Token-bucket capacity (defaults to 60). */
|
||||
capacity?: number;
|
||||
/** Tokens refilled per second (defaults to 1). */
|
||||
refillPerSecond?: number;
|
||||
/** Injectable monotonic clock (defaults to `Date.now`). */
|
||||
clock?: Clock;
|
||||
}
|
||||
|
||||
/** Retry-policy options. */
|
||||
export interface RetryPolicyOptions {
|
||||
/** Maximum number of attempts (defaults to 3). */
|
||||
maxAttempts?: number;
|
||||
/** Initial delay between attempts in ms (defaults to 50). */
|
||||
initialDelayMs?: number;
|
||||
/** Upper bound on backoff delay in ms (defaults to 5000). */
|
||||
maxDelayMs?: number;
|
||||
/** Injectable timers seam (defaults to a `globalThis.setTimeout` adapter). */
|
||||
timers?: TimersLike;
|
||||
/** Injectable random source for jitter (defaults to `Math.random`). */
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
/** Scheduler options. */
|
||||
export interface MessageSchedulerOptions {
|
||||
/** Injectable timers seam (defaults to a `globalThis.setTimeout` adapter). */
|
||||
timers?: TimersLike;
|
||||
/** Injectable clock (defaults to `Date.now`). */
|
||||
clock?: Clock;
|
||||
}
|
||||
|
||||
/** Delivery-tracker options. */
|
||||
export interface DeliveryTrackerOptions {
|
||||
/** Maximum cached receipts before LRU eviction (defaults to 1024). */
|
||||
maxEntries?: number;
|
||||
}
|
||||
|
||||
/** Options to construct an `SmsService`. */
|
||||
export interface SmsServiceOptions {
|
||||
/** Optional provider to wire eagerly. */
|
||||
provider?: SmsProvider;
|
||||
/** Custom ID generator for retry-fallback message IDs. */
|
||||
idGenerator?: IdGenerator;
|
||||
/** Rate-limiter configuration. */
|
||||
rateLimiter?: RateLimiterOptions;
|
||||
/** Retry-policy configuration. */
|
||||
retry?: RetryPolicyOptions;
|
||||
/** Scheduler configuration. */
|
||||
scheduler?: MessageSchedulerOptions;
|
||||
/** Delivery-tracker configuration. */
|
||||
deliveryTracker?: DeliveryTrackerOptions;
|
||||
}
|
||||
|
||||
/** Handle for a scheduled message. */
|
||||
export interface ScheduleHandle {
|
||||
/** Cancel the pending schedule; idempotent. */
|
||||
cancel(): void;
|
||||
/** Target instant. */
|
||||
readonly sendAt: Date;
|
||||
}
|
||||
|
||||
/** Scheduled-message envelope. */
|
||||
export interface ScheduledMessage {
|
||||
/** Target instant. */
|
||||
sendAt: Date;
|
||||
/** Message to deliver. */
|
||||
message: SmsMessage;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { E164_REGEX, isValidE164 } from "./phone.js";
|
||||
70
packages/communication/messaging/sms/src/validation/phone.md
Normal file
70
packages/communication/messaging/sms/src/validation/phone.md
Normal file
@@ -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,26 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { E164_REGEX, isValidE164 } from "./phone.js";
|
||||
|
||||
describe("isValidE164", () => {
|
||||
it("accepts E.164 numbers", { timeout: 5000 }, () => {
|
||||
expect(isValidE164("+15551234567")).toBe(true);
|
||||
expect(isValidE164("+447911123456")).toBe(true);
|
||||
expect(isValidE164("+12")).toBe(true);
|
||||
expect(isValidE164("+123456789012345")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-E.164 inputs", { timeout: 5000 }, () => {
|
||||
expect(isValidE164("15551234567")).toBe(false);
|
||||
expect(isValidE164("+")).toBe(false);
|
||||
expect(isValidE164("+0123456789")).toBe(false);
|
||||
expect(isValidE164("+1234567890123456")).toBe(false);
|
||||
expect(isValidE164("invalid")).toBe(false);
|
||||
expect(isValidE164("")).toBe(false);
|
||||
expect(isValidE164("+1abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes the regex constant", { timeout: 5000 }, () => {
|
||||
expect(E164_REGEX).toBeInstanceOf(RegExp);
|
||||
expect(E164_REGEX.test("+15551234567")).toBe(true);
|
||||
});
|
||||
});
|
||||
14
packages/communication/messaging/sms/src/validation/phone.ts
Normal file
14
packages/communication/messaging/sms/src/validation/phone.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* E.164 phone-number validation. Strict per ITU-T E.164: a leading `+`,
|
||||
* a first digit in `[1-9]`, then 1 to 14 trailing decimal digits.
|
||||
*
|
||||
* @module validation/phone
|
||||
*/
|
||||
|
||||
/** Strict E.164 regular expression. */
|
||||
export const E164_REGEX = /^\+[1-9]\d{1,14}$/;
|
||||
|
||||
/** Return `true` iff `value` is a syntactically valid E.164 number. */
|
||||
export function isValidE164(value: string): boolean {
|
||||
return E164_REGEX.test(value);
|
||||
}
|
||||
12
packages/communication/messaging/sms/tsconfig.lib.json
Normal file
12
packages/communication/messaging/sms/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/communication/messaging/sms/vite.config.ts
Normal file
37
packages/communication/messaging/sms/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/sms",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
3
packages/communication/messaging/sms/vitest.config.ts
Normal file
3
packages/communication/messaging/sms/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
Reference in New Issue
Block a user