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