AbsoluteJS
AbsoluteJS

Dispatch

Outbound message dispatcher for Bun + Elysia — email, SMS, and push behind one interface, with vendor adapter siblings for Resend, Postmark, and Twilio. Built-in OTel spans, audit-event integration, in-memory testing adapters, and a narrow ClientLike interface per adapter so vendor SDKs stay TRUE peer deps.

#Quick Start

createDispatcher() takes one optional adapter per channel. Each channel becomes a top-level callable — dispatch.email(message), dispatch.sms(message), dispatch.push(message). Calling a channel you didn't configure throws DispatchUnsupportedError.

TS
1import { createDispatcher } from '@absolutejs/dispatch';
2import { createResendAdapter } from '@absolutejs/dispatch-resend';
3import { Resend } from 'resend';
4
5const dispatch = createDispatcher({
6  email: createResendAdapter({
7    client: new Resend(process.env.RESEND_API_KEY!),
8  }),
9  defaultFrom: { email: 'Example <[email protected]>' },
10});
11
12// Each channel is called directly — dispatch.email(...), dispatch.sms(...).
13await dispatch.email({
14  to: '[email protected]',
15  subject: 'Welcome',
16  text: 'Hi there!',
17});

#Channels

Three channels — email, SMS, push — all optional. Each message shape carries an optional tenant field that propagates to OTel spans and audit events, plus an open metadata record adapters can interpret.

MARKDOWN
1# Three channels, one dispatcher
2
3  Each channel is OPTIONAL — pass only the adapters you wire. Calling
4  a channel that wasn't configured throws DispatchUnsupportedError so
5  the omission is loud, not silent.
6
7  createDispatcher({
8    email?: EmailAdapter,
9    sms?:   SmsAdapter,
10    push?:  PushAdapter,
11    audit?: AuditLike,                  // optional @absolutejs/audit
12    tracerProvider?: TracerProvider,    // optional OTel via @absolutejs/telemetry
13    defaultFrom?: { email?: string, sms?: string },
14    onError?: (err, channel, message) => void,
15  });
16
17  The message shape per channel — every channel supports an optional
18  `tenant` field that propagates to spans + audit, and an open
19  `metadata` record adapters can interpret:
20
21    EmailMessage  → { to, subject, text?, html?, from?, replyTo?,
22                      cc?, bcc?, headers?, tenant?, metadata? }
23    SmsMessage    → { to, body, from?, tenant?, metadata? }
24    PushMessage   → { to, title?, body, data?, tenant?, metadata? }
25
26  Each call returns DispatchResult { at, id?, provider } so you can
27  correlate the send to the vendor's delivery webhook.

#Adapters

Each vendor adapter is its own npm package — install only the ones you wire. In-memory and console adapters ship with the core package for tests + local dev.

MARKDOWN
1# Vendor adapter siblings
2
3  Each adapter is its own npm package — install only the ones you
4  wire. The narrow ClientLike interfaces keep the vendor SDKs as TRUE
5  peer deps; the adapter doesn't pull in postmark or twilio
6  transitively. Bring your own client.
7
8  Bundled (Apache 2.0 Tier B):
9
10    @absolutejs/dispatch-resend     → createResendAdapter (email)
11    @absolutejs/dispatch-postmark   → createPostmarkAdapter (email)
12    @absolutejs/dispatch-twilio     → createTwilioAdapter (sms)
13
14  In-memory + console adapters ship with the core package — useful
15  for tests + local dev:
16
17    import {
18      memoryEmailAdapter, memorySmsAdapter, memoryPushAdapter,
19      consoleEmailAdapter, consoleSmsAdapter, consolePushAdapter,
20    } from '@absolutejs/dispatch';
21
22  Memory adapters keep an in-process FIFO buffer (default 1000); call
23  .inspect() to read a copy, .clear() to reset between tests. Console
24  adapters print the message as JSON and return immediately.

#Observability

Every send fans out an OTel span with ABS_ATTRS-shaped attributes plus cumulative counters via dispatcher.metrics(). Wiring { audit } from @absolutejs/audit appends a dispatch.<channel>.sent / .failed event for every send.

MARKDOWN
1# Observability — spans + counters + audit
2
3  Each send fans out a single span under the channel's tracer:
4
5    dispatch.email.send
6    dispatch.sms.send
7    dispatch.push.send
8
9  with these attributes:
10
11    abs.tenant            → message.tenant when set (ABS_ATTRS.tenant)
12    dispatch.channel      → 'email' | 'sms' | 'push'
13    dispatch.provider     → adapter.name ('resend', 'postmark', 'twilio', …)
14    dispatch.recipient    → message.to (CSV-joined when to is an array)
15    dispatch.message_id   → vendor id, set post-send when adapter returns one
16
17  dispatcher.metrics() returns cumulative counters:
18
19    {
20      sent: number,
21      failed: number,
22      byChannel: {
23        email: { sent: number, failed: number },
24        sms:   { sent: number, failed: number },
25        push:  { sent: number, failed: number },
26      }
27    }
28
29  Pass an { audit } shaped like @absolutejs/audit's writer and every
30  send appends one of:
31
32    dispatch.email.sent  / dispatch.email.failed
33    dispatch.sms.sent    / dispatch.sms.failed
34    dispatch.push.sent   / dispatch.push.failed
35
36  with provider + messageId in metadata, message.tenant as actor (or
37  'system' when no tenant set), and the recipient as target.

#Postmark

Transactional + broadcast streams via messageStream. Default metadata mapping routes metadata.tag to Postmark's Tag and string-valued entries to its Metadata map. Custom headers auto-convert to Postmark's [{Name, Value}] shape.

TS
1# Postmark adapter — transactional + metadata routing
2
3  import { createPostmarkAdapter } from '@absolutejs/dispatch-postmark';
4  import { ServerClient } from 'postmark';
5
6  const email = createPostmarkAdapter({
7    client: new ServerClient(process.env.POSTMARK_TOKEN!),
8    defaultFrom: '[email protected]',
9    messageStream: 'outbound',                          // default
10  });
11
12  Postmark requires a `From` address — pass per-message or via
13  `defaultFrom`, otherwise the adapter throws a clear error.
14
15  By default the adapter extracts a `tag` field from
16  `message.metadata` into Postmark's Tag (used for analytics
17  segmentation) and routes every other string-valued metadata entry
18  into Postmark's Metadata map. Override the mapping via:
19
20    createPostmarkAdapter({
21      client,
22      mapMetadata: (metadata) => ({
23        Tag: metadata.tag as string | undefined,
24        Metadata: {
25          campaign: metadata.campaign as string,
26          tenant:   metadata.tenant   as string,
27        }
28      }),
29    });
30
31  Custom headers in the EmailMessage convert to Postmark's
32  [{Name, Value}] array shape automatically. SDK errors propagate; the
33  dispatcher's onError + span error capture kicks in.

#Twilio

SMS via single-number routing or Messaging Service SID, with sender precedence message.from > defaultFrom > messagingServiceSid. Pass statusCallback to thread delivery webhooks through every send.

TS
1# Twilio adapter — SMS with messaging-service or single-number routing
2
3  import { createTwilioAdapter } from '@absolutejs/dispatch-twilio';
4  import twilio from 'twilio';
5
6  const sms = createTwilioAdapter({
7    client: twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN),
8    defaultFrom: '+15555550100',                // OR
9    messagingServiceSid: 'MGxxxxxxxx',          // OR — at least one required
10    statusCallback: 'https://example.com/twilio/status',
11  });
12
13  Sender precedence: message.from > defaultFrom > messagingServiceSid.
14  If none resolves, the adapter throws.
15
16  statusCallback threads through to every send so Twilio's delivery
17  webhooks fire to your own audit ingest URL. The adapter also
18  normalizes Twilio's documented bulk-send response shape — an
19  errorCode != null in the response body (rare but real) throws so
20  the dispatcher's failed counter + audit failure event fire.

#Testing

The in-memory adapters keep an in-process FIFO buffer — call .inspect() to assert what would have shipped, .clear() to reset between tests. No vendor mocks needed.

TS
1# Testing — memory adapters + .inspect() assertions
2
3  No vendor mocks. The in-memory adapters are the fast path for
4  testing notification flows.
5
6  import { createDispatcher, memoryEmailAdapter } from '@absolutejs/dispatch';
7
8  const email = memoryEmailAdapter();
9  const dispatch = createDispatcher({ email });
10
11  await dispatch.email({
12    to: '[email protected]',
13    subject: 'hi',
14    text: 'hi'
15  });
16
17  const sent = email.inspect();
18  expect(sent).toHaveLength(1);
19  expect(sent[0].subject).toBe('hi');
20
21  // Between tests:
22  email.clear();
23  expect(email.inspect()).toHaveLength(0);
24
25  consoleEmailAdapter() is the sibling for local dev — prints the
26  message as JSON to stdout, returns immediately. Useful when you
27  want to eyeball what would have shipped without spinning up a real
28  vendor account.
29
30  For error-path tests, wrap an adapter and have its .send() reject —
31  the dispatcher's failed counter, OTel span error, audit failure
32  event, and onError hook all fire on the same rejection.