first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View 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`.

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

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

View File

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

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

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

View 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`.

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