184 lines
6.2 KiB
Markdown
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.
|