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

6.2 KiB

Design — @suaveplan/sms

Universal-tier SMS core, provider-agnostic, no Node/Bun-only APIs.

Module layout

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.