Files
2026-05-27 23:29:53 +01:00

184 lines
6.2 KiB
Markdown

# Design — `@suaveplan/sms`
Universal-tier SMS core, provider-agnostic, no Node/Bun-only APIs.
## Module layout
```text
src/
index.ts # root barrel (named re-exports)
error-codes/
error-codes.ts # defineCodes("SMS", [...])
error-codes.test.ts
error-codes.md
index.ts
legacy-errors/
legacy-errors.ts # back-compat SmsError + factories
legacy-errors.test.ts
legacy-errors.md
index.ts
legacy-metadata/
legacy-metadata.ts # SMS_ERROR_METADATA + registerSmsErrorCodes
legacy-metadata.test.ts
legacy-metadata.md
index.ts
schemas/
schemas.ts # Zod for SmsMessage/Template/Provider/etc.
schemas.test.ts
schemas.md
index.ts
types/
sms.types.ts # public type contracts
validation/
phone.ts # isValidE164 helper
phone.test.ts
phone.md
index.ts
templates/
templates.ts # TemplateRegistry + renderTemplate
templates.test.ts
templates.md
index.ts
opt-out/
opt-out.ts # OptOutRegistry
opt-out.test.ts
opt-out.md
index.ts
rate-limiter/
rate-limiter.ts # token-bucket
rate-limiter.test.ts
rate-limiter.md
index.ts
retry/
retry.ts # exponential backoff w/ jitter
retry.test.ts
retry.md
index.ts
scheduler/
scheduler.ts # MessageScheduler (Clock + Timers)
scheduler.test.ts
scheduler.md
index.ts
delivery-tracker/
delivery-tracker.ts # ingest + lookup + cache
delivery-tracker.test.ts
delivery-tracker.md
index.ts
in-memory-provider/
in-memory-provider.ts # InMemorySmsProvider (test prov)
in-memory-provider.test.ts
in-memory-provider.md
index.ts
service/
sms-service.ts # SmsService — orchestrator
sms-service.test.ts
sms-service.md
index.ts
routes/
problem-json.ts # respondProblemJson Hono helper
problem-json.test.ts
problem-json.md
index.ts
otel/
metrics.ts # 4 instruments
metrics.test.ts
metrics.md
index.ts
```
## Public contracts
- Types: `SmsDeliveryStatus`, `SmsMessage`, `SmsDeliveryReport`,
`SmsProvider`, `OptOutEntry`, `SmsTemplate`, `SmsEventMap`,
`SmsServiceOptions`, `ScheduledMessage`, `ScheduleHandle`,
`RetryPolicyOptions`, `RateLimiterOptions`, `DeliveryTrackerOptions`.
- Classes: `SmsService`, `TemplateRegistry`, `OptOutRegistry`,
`RateLimiter`, `RetryPolicy`, `MessageScheduler`,
`DeliveryTracker`, `InMemorySmsProvider`.
- Errors: `SMS_CODES`, `SmsError` (legacy back-compat),
`SmsSendFailedError`, `SmsProviderError`, `SmsInvalidNumberError`,
`SmsRateLimitedError`, `SmsConfigMissingError`,
`SmsForbiddenError`, `SmsTemplateNotFoundError`,
`SmsRecipientOptedOutError`, `SmsProviderNotConfiguredError`,
`SmsScheduleCancelledError`, `SmsAbortedError`,
`SmsInvalidConfigError`.
- Helpers: `respondProblemJson`, `PROBLEM_JSON_CONTENT_TYPE`,
`registerSmsErrorCodes`, `SMS_ERROR_METADATA`, `isValidE164`,
`renderTemplate`.
- OTel: `recordSmsSent`, `recordSmsFailed`, `recordSmsRetry`,
`recordSmsSendDuration`, `refreshSmsMeters`.
## Key behaviours
- **Phone validation** — strict E.164 (`^\+[1-9]\d{1,14}$`).
- **Template render** — `{{var}}` substitution; missing vars → empty
string; supports `defaults` map. Brace literal escape via `{{{`.
- **Opt-out** — set/clear/check, channel-scoped, bulk import/export.
- **Rate limiter** — token bucket per `(channel, recipient)` key.
Configurable capacity + refill-per-second; uses injectable `Clock`.
- **Retry** — exponential backoff with full-jitter; `maxAttempts`,
`initialDelayMs`, `maxDelayMs` configurable; abort-aware.
- **Scheduler** — `sendAt(date, message)` enqueues against an
injectable `Timers`-like seam; returns a `ScheduleHandle` with
`cancel()`. On fire, the message goes through the full pipeline
(validation → rate-limit → retry → provider send → events).
- **Delivery tracker** — `ingestReceipt(report)` records the latest
status keyed by `messageId`; `getStatus(messageId)` returns the
cached entry or `undefined`. Cache size bounded; LRU eviction.
- **Events** — typed `EventBus<SmsEventMap>` on `service.bus`; legacy
`on/off/emit` mirrors against the bus.
- **Abort** — `service.send(msg, { signal })` propagates through the
rate limiter, retry, scheduler timer, and the provider's send call
(when the provider accepts a signal). Aborted operations throw
`SmsAbortedError` with code `SMS.ABORTED`.
## OTel instruments
- Counter `sms.sent_total{channel,from_me}`
- Counter `sms.failed_total{code}`
- Counter `sms.retry_total{attempt}`
- Histogram `sms.send_duration_ms{outcome}`
## Error codes (genesis-only registry)
`defineCodes("SMS", [...])` — 13 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`
The upstream `SMS_*` runtime keys (used by `SMS_ERROR_METADATA` and
the back-compat `SmsError` class — `SMS_SEND_FAILED`,
`SMS_PROVIDER_ERROR`, `SMS_INVALID_NUMBER`, `SMS_RATE_LIMITED`,
`SMS_CONFIG_MISSING`, `SMS_FORBIDDEN`, `SMS_INVALID_PHONE_NUMBER`,
`SMS_TEMPLATE_NOT_FOUND`, `SMS_RECIPIENT_OPTED_OUT`,
`SMS_PROVIDER_NOT_CONFIGURED`) are preserved verbatim so legacy
callers keep compiling.
## Subpath exports
Every feature module gets a subpath: `./error-codes`, `./schemas`,
`./service`, `./templates`, `./opt-out`, `./rate-limiter`, `./retry`,
`./scheduler`, `./delivery-tracker`, `./in-memory-provider`,
`./routes/problem-json`, `./otel`, `./types`, `./validation/phone`.
## Tier — universal
The package consumes only:
- `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`,
`@opentelemetry/api`, `tslib`, `zod`.
No Node/Bun-only APIs; uses `globalThis.setTimeout` /
`clearTimeout` (universally available) via injectable `Timers` seam.