first commit
This commit is contained in:
133
docs/communication/messaging/sms/README.md
Normal file
133
docs/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
|
||||
|
||||
@@ -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.
|
||||
110
docs/communication/messaging/sms/src/error-codes/error-codes.md
Normal file
110
docs/communication/messaging/sms/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The error-codes module owns the canonical `SMS.*` registry for
|
||||
`@suaveplan/sms`. Every error surfaced by the package — invalid
|
||||
phone number, missing provider, exhausted retries, opted-out
|
||||
recipient, rate limit, scheduled-message cancellation, abort —
|
||||
carries one of the thirteen stable codes registered here. This
|
||||
keeps downstream consumers (loggers, problem+json responders,
|
||||
retry wrappers, dashboards) free from re-implementing the
|
||||
classifier.
|
||||
|
||||
The thirteen codes are declared with the
|
||||
`defineCodes("SMS", [...])` helper, which produces a frozen
|
||||
`Namespace.Reason` string union. Each code has a dedicated
|
||||
leaf-error class extending the package-local abstract
|
||||
`BaseSmsError` (itself a thin `SuaveplanError<SmsCode>`
|
||||
subclass), so consumers can `instanceof` the class as well as
|
||||
match on `.code`.
|
||||
|
||||
## Features
|
||||
|
||||
- Thirteen `SMS.*` codes: `INVALID_NUMBER`, `INVALID_CONFIG`,
|
||||
`PROVIDER_NOT_CONFIGURED`, `PROVIDER_ERROR`, `SEND_FAILED`,
|
||||
`TEMPLATE_NOT_FOUND`, `RECIPIENT_OPTED_OUT`, `RATE_LIMITED`,
|
||||
`FORBIDDEN`, `CONFIG_MISSING`, `SCHEDULE_CANCELLED`, `ABORTED`,
|
||||
`RECEIPT_NOT_FOUND`.
|
||||
- One leaf class per code, all extending `BaseSmsError`.
|
||||
- `BaseOpts`-typed constructors that accept `message`,
|
||||
`context`, `cause`, `traceId`, with `pickOpts` stripping
|
||||
undefined keys so the `exactOptionalPropertyTypes`
|
||||
contract holds.
|
||||
- `SmsInvalidConfigError` merges Zod issues into
|
||||
`context.issues`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SMS_CODES,
|
||||
SmsRateLimitedError,
|
||||
} from "@suaveplan/sms/error-codes";
|
||||
|
||||
try {
|
||||
throw new SmsRateLimitedError({
|
||||
context: { phoneNumber: "+15551234567" },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof SmsRateLimitedError) {
|
||||
console.log(err.code === SMS_CODES.RATE_LIMITED);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
BaseSmsError,
|
||||
SmsInvalidConfigError,
|
||||
} from "@suaveplan/sms/error-codes";
|
||||
|
||||
function classifyAndLog(err: unknown): void {
|
||||
if (err instanceof SmsInvalidConfigError) {
|
||||
for (const issue of (err.context?.issues ?? []) as ReadonlyArray<
|
||||
Record<string, unknown>
|
||||
>) {
|
||||
console.error("zod issue", issue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (err instanceof BaseSmsError) {
|
||||
console.error(`${err.code}: ${err.message}`, err.context ?? {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SMS_CODES: Readonly<Record<Reason, "SMS.Reason">>` — frozen
|
||||
registry returned from `defineCodes("SMS", [...])`.
|
||||
- `SmsCode = (typeof SMS_CODES)[keyof typeof SMS_CODES]` — union
|
||||
of every fully-qualified code.
|
||||
- `abstract class BaseSmsError extends SuaveplanError<SmsCode>` —
|
||||
base class for every typed leaf error.
|
||||
- Leaf classes (each takes a `BaseOpts` argument and forwards a
|
||||
default messageKey for the i18n resolver):
|
||||
`SmsInvalidNumberError`, `SmsInvalidConfigError`,
|
||||
`SmsProviderNotConfiguredError`, `SmsProviderError`,
|
||||
`SmsSendFailedError`, `SmsTemplateNotFoundError`,
|
||||
`SmsRecipientOptedOutError`, `SmsRateLimitedError`,
|
||||
`SmsForbiddenError`, `SmsConfigMissingError`,
|
||||
`SmsScheduleCancelledError`, `SmsAbortedError`,
|
||||
`SmsReceiptNotFoundError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The leaf-class pattern (one class per code) was chosen over a
|
||||
single generic class so callers can `instanceof Xxx` to discriminate
|
||||
without inspecting `.code`. The `pickOpts` helper guards against
|
||||
the `exactOptionalPropertyTypes` contract — copying a
|
||||
`cause: undefined` into the super options table would fail to
|
||||
typecheck against `SuaveplanErrorOptions`. Codes are reserved at
|
||||
module load via `defineCodes` (which returns a frozen object) so
|
||||
the registry cannot drift across module reloads.
|
||||
@@ -0,0 +1,101 @@
|
||||
# `in-memory-provider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`InMemorySmsProvider` is the production-quality in-process
|
||||
`SmsProvider` implementation that ships with `@suaveplan/sms`.
|
||||
It satisfies the abstract `SmsProvider` contract — `name`,
|
||||
`send`, `getStatus` — by recording every message into an
|
||||
in-memory log and returning configurable delivery receipts. It
|
||||
is the canonical test double for integration tests, demo apps,
|
||||
and host applications that want to exercise the send pipeline
|
||||
without a real SMS carrier.
|
||||
|
||||
This module replaces the upstream pattern of ad-hoc anonymous
|
||||
mocks. Every test in the package uses this provider — there are
|
||||
no `vi.fn()` stubs of the `SmsProvider` contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the full `SmsProvider` contract (`name`, `send`,
|
||||
`getStatus`).
|
||||
- `sent: SmsMessage[]` — log of every outgoing message, in
|
||||
order.
|
||||
- `reports: SmsDeliveryReport[]` — parallel log of every receipt
|
||||
returned.
|
||||
- Configurable `defaultStatus` (`queued` / `sent` / `delivered`
|
||||
/ `failed` / `undelivered`) returned by `send`.
|
||||
- `setStatus(messageId, status)` — transitions a previously-sent
|
||||
message's status for `getStatus` callers.
|
||||
- `failOn(predicate, error?)` — configure a per-message failure
|
||||
predicate so tests simulate provider rejections without
|
||||
swapping the implementation.
|
||||
- Injectable `Clock` for deterministic receipt timestamps.
|
||||
- Injectable `IdGenerator` for predictable message IDs.
|
||||
- AbortSignal-aware — a pre-aborted signal rejects with
|
||||
`Error("aborted")` so the upstream retry can wrap it.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { InMemorySmsProvider } from "@suaveplan/sms/in-memory-provider";
|
||||
|
||||
const provider = new InMemorySmsProvider();
|
||||
const report = await provider.send({ to: "+15551234567", body: "hi" });
|
||||
console.log(provider.sent[0]?.body); // "hi"
|
||||
console.log(report.status); // "sent"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { InMemorySmsProvider } from "@suaveplan/sms/in-memory-provider";
|
||||
|
||||
let n = 0;
|
||||
const provider = new InMemorySmsProvider({
|
||||
name: "twilio-fake",
|
||||
defaultStatus: "queued",
|
||||
idGenerator: () => `seed_${n++}`,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
provider.failOn(
|
||||
(msg) => msg.to === "+15550000000",
|
||||
new Error("blocked-by-carrier")
|
||||
);
|
||||
|
||||
const r = await provider.send({ to: "+15551234567", body: "ping" });
|
||||
provider.setStatus(r.messageId, "delivered");
|
||||
console.log((await provider.getStatus(r.messageId)).status); // "delivered"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class InMemorySmsProvider implements SmsProvider`
|
||||
- `readonly name: string`
|
||||
- `readonly sent: SmsMessage[]`
|
||||
- `readonly reports: SmsDeliveryReport[]`
|
||||
- `send(message, options?): Promise<SmsDeliveryReport>`
|
||||
- `getStatus(messageId): Promise<SmsDeliveryReport>`
|
||||
- `setStatus(messageId, status): void` (throws on unknown id)
|
||||
- `failOn(predicate, error?): void`
|
||||
- `clearFailures(): void`
|
||||
- `clear(): void`
|
||||
- `interface InMemorySmsProviderOptions`
|
||||
- `name?`, `defaultStatus?`, `idGenerator?`, `clock?`
|
||||
- `type SmsFailurePredicate = (m: SmsMessage) => boolean`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The provider stores two parallel arrays — `sent` (the incoming
|
||||
`SmsMessage`) and `reports` (the outgoing receipts) — so test
|
||||
assertions on the rendered body work even when the service has
|
||||
substituted template variables. The default `idGenerator`
|
||||
combines a monotonic counter and the clock so collisions are
|
||||
impossible across instances. AbortSignal rejection uses a plain
|
||||
`Error("aborted")`; `SmsService.send` wraps it into
|
||||
`SmsAbortedError` after rate-limiter / retry layers run.
|
||||
@@ -0,0 +1,93 @@
|
||||
# `legacy-errors`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`legacy-errors` preserves the upstream `@suaveplan/sms` v1
|
||||
public surface — the `SmsError` class, the `SmsErrorCode`
|
||||
string-union, the five static factory methods, the
|
||||
`SmsErrorContext` shape, the `SmsErrorOptions` shape, and the
|
||||
`isSmsError` type guard — so existing callers compile against
|
||||
the package with zero source changes. Internally the
|
||||
class extends `SuaveplanError` so it integrates with the
|
||||
error stack (cause walking, OTel span events,
|
||||
redaction, the i18n resolver hook).
|
||||
|
||||
## Features
|
||||
|
||||
- Upstream-compatible `SmsErrorCode` union with the five legacy
|
||||
string codes (`SMS_INVALID_PHONE_NUMBER`, `SMS_SEND_FAILED`,
|
||||
`SMS_TEMPLATE_NOT_FOUND`, `SMS_RECIPIENT_OPTED_OUT`,
|
||||
`SMS_PROVIDER_NOT_CONFIGURED`).
|
||||
- `SmsError` class extending `SuaveplanError<SmsErrorCode>`
|
||||
so it picks up cause walking, OTel adapter, the i18n
|
||||
resolver, and `WireError` serialization.
|
||||
- Five static factory methods, each populating `context` with
|
||||
the upstream field shape (`phoneNumber`, `messageId`,
|
||||
`templateId`).
|
||||
- `isSmsError` instance check.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SmsError } from "@suaveplan/sms/legacy-errors";
|
||||
|
||||
try {
|
||||
throw SmsError.invalidPhoneNumber("not-a-number");
|
||||
} catch (err) {
|
||||
if (SmsError.isSmsError(err)) {
|
||||
console.log(err.code, err.context?.phoneNumber);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SmsError } from "@suaveplan/sms/legacy-errors";
|
||||
|
||||
async function sendOnce(): Promise<void> {
|
||||
try {
|
||||
// ... call provider ...
|
||||
throw new Error("network");
|
||||
} catch (cause) {
|
||||
throw SmsError.sendFailed("msg_42", cause as Error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sendOnce();
|
||||
} catch (err) {
|
||||
if (SmsError.isSmsError(err) && err.code === "SMS_SEND_FAILED") {
|
||||
console.error("send failed:", err.context?.messageId, err.cause);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SmsErrorCode` — union of the five legacy string codes.
|
||||
- `interface SmsErrorContext` — `phoneNumber?`, `templateId?`,
|
||||
`providerId?`, `messageId?`, plus an open `Record<string, unknown>`
|
||||
index signature.
|
||||
- `interface SmsErrorOptions` — `message`, `code`, `context?`,
|
||||
`cause?`.
|
||||
- `class SmsError extends SuaveplanError<SmsErrorCode>` — public
|
||||
constructor accepting `SmsErrorOptions`.
|
||||
- Static factories: `invalidPhoneNumber(phoneNumber)`,
|
||||
`sendFailed(messageId, cause?)`, `templateNotFound(templateId)`,
|
||||
`recipientOptedOut(phoneNumber)`, `providerNotConfigured()`.
|
||||
- Static type guard: `isSmsError(value): value is SmsError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`@suaveplan/error` has a `protected` constructor on
|
||||
`SuaveplanError`; subclasses must declare a `public` constructor
|
||||
that calls `super(code, message, options)`. The legacy class
|
||||
preserves the upstream argument order (`{message, code, ...}`)
|
||||
so existing call sites remain identical, then forwards to the
|
||||
standard super constructor. `sendFailed` includes a conditional
|
||||
add of `cause` to satisfy `exactOptionalPropertyTypes`.
|
||||
@@ -0,0 +1,85 @@
|
||||
# `legacy-metadata`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`legacy-metadata` preserves the upstream `SMS_ERROR_METADATA`
|
||||
table — the per-code mapping of `httpStatus`, `severity`, retry
|
||||
hint, `userMessageKey`, `category`, and `description` — so
|
||||
existing callers that probe the table to drive their own
|
||||
problem+json responses, retry policies, or i18n catalogues
|
||||
continue to work. The upstream package registered the table on a
|
||||
process-wide `ErrorCodes` singleton; this package carries no such
|
||||
singleton, so `registerSmsErrorCodes()` is reframed as a
|
||||
no-op-friendly getter that returns the frozen table. Callers can
|
||||
plug the returned table into whatever registry their host wires
|
||||
up.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `SMS_ERROR_METADATA` mapping with the six upstream
|
||||
entries (`SMS_SEND_FAILED`, `SMS_PROVIDER_ERROR`,
|
||||
`SMS_INVALID_NUMBER`, `SMS_RATE_LIMITED`, `SMS_CONFIG_MISSING`,
|
||||
`SMS_FORBIDDEN`).
|
||||
- `SmsCodeMetadata` / `SmsCodeSeverity` / `SmsRetryHint` shapes
|
||||
exposed as type contracts.
|
||||
- `registerSmsErrorCodes()` idempotent helper returning the
|
||||
table.
|
||||
- Eagerly evaluates on import to mirror the upstream load-time
|
||||
side effect.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SMS_ERROR_METADATA } from "@suaveplan/sms/legacy-metadata";
|
||||
|
||||
const meta = SMS_ERROR_METADATA.SMS_RATE_LIMITED;
|
||||
console.log(meta.httpStatus); // 429
|
||||
console.log(meta.retry?.maxAttempts); // 5
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerSmsErrorCodes,
|
||||
type SmsCodeMetadata,
|
||||
} from "@suaveplan/sms/legacy-metadata";
|
||||
|
||||
// Host wires the package metadata into its own registry
|
||||
const table = registerSmsErrorCodes();
|
||||
for (const [code, meta] of Object.entries(table) as [
|
||||
string,
|
||||
SmsCodeMetadata
|
||||
][]) {
|
||||
myHostRegistry.upsert(code, meta);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SmsCodeSeverity = "info" | "warning" | "error" | "fatal"`.
|
||||
- `interface SmsRetryHint` — `retryable: boolean`,
|
||||
`strategy?: "linear" | "exponential"`, `maxAttempts?: number`.
|
||||
- `interface SmsCodeMetadata` — `httpStatus`, `severity`,
|
||||
`retry?`, `retryable?`, `userMessageKey`, `category`,
|
||||
`description`.
|
||||
- `type SmsRegisteredErrorCode` — the six upstream string codes.
|
||||
- `const SMS_ERROR_METADATA: Readonly<Record<SmsRegisteredErrorCode,
|
||||
SmsCodeMetadata>>` — frozen mapping.
|
||||
- `function registerSmsErrorCodes(): Readonly<Record<SmsRegisteredErrorCode,
|
||||
SmsCodeMetadata>>` — returns the table; idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The table is frozen via `Object.freeze` so neither the
|
||||
top-level dictionary nor the eager-registration step can mutate
|
||||
entries downstream. The function returns the same table by
|
||||
reference — callers needing a defensive copy should clone
|
||||
explicitly. The eager call at module load mirrors the upstream
|
||||
`registerSmsErrorCodes()` side effect that ran at v1 import
|
||||
time, so a consumer that previously relied on the function being
|
||||
called for them still sees the same behaviour .
|
||||
87
docs/communication/messaging/sms/src/opt-out/opt-out.md
Normal file
87
docs/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.
|
||||
98
docs/communication/messaging/sms/src/otel/metrics.md
Normal file
98
docs/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,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`.
|
||||
93
docs/communication/messaging/sms/src/retry/retry.md
Normal file
93
docs/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.
|
||||
86
docs/communication/messaging/sms/src/routes/problem-json.md
Normal file
86
docs/communication/messaging/sms/src/routes/problem-json.md
Normal file
@@ -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.
|
||||
97
docs/communication/messaging/sms/src/scheduler/scheduler.md
Normal file
97
docs/communication/messaging/sms/src/scheduler/scheduler.md
Normal file
@@ -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`.
|
||||
101
docs/communication/messaging/sms/src/schemas/schemas.md
Normal file
101
docs/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.
|
||||
71
docs/communication/messaging/sms/src/service/bus-map.md
Normal file
71
docs/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.
|
||||
@@ -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.
|
||||
119
docs/communication/messaging/sms/src/service/sms-service.md
Normal file
119
docs/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.
|
||||
99
docs/communication/messaging/sms/src/templates/templates.md
Normal file
99
docs/communication/messaging/sms/src/templates/templates.md
Normal file
@@ -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.
|
||||
114
docs/communication/messaging/sms/src/types/sms.types.md
Normal file
114
docs/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`.
|
||||
70
docs/communication/messaging/sms/src/validation/phone.md
Normal file
70
docs/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.
|
||||
Reference in New Issue
Block a user