Notifications
@bext-stack/framework/notifications turns one event into messages on many
channels. You describe a Notification once — with a toMail, toSms,
toSlack, toDatabase or toWebhook method — and notifier.send() fans it
out to every channel the notification's via() lists, returning one result per
channel. It's the app-developer generalisation of the Rust
Mailer capability, modelled on Laravel's Notification +
Channels.
When To Use It
- Any "tell the user something" moment: a receipt, an alert, a password reset.
- When the same event should reach more than one place (email and an in-app feed and a Slack ops channel).
- When you want channels and their providers to be swappable and testable.
Sending
import { createNotifier, mailChannel, smsChannel, databaseChannel } from "@bext-stack/framework/notifications";
const notifier = createNotifier([
mailChannel({ appId: "my-site" }), // real: bext's SDK email endpoint
smsChannel({ send: twilioSend }), // bext ships no SMS provider — inject one
databaseChannel({ model: Notifications }), // in-app feed via @bext-stack/framework/orm
]);
const InvoicePaid = {
via: () => ["mail", "database"],
toMail: (u) => ({ subject: "Paid", html: `<p>Thanks ${u.name}</p>` }),
toDatabase: (u) => ({ type: "invoice.paid", user_id: u.id, read: 0 }),
};
const results = await notifier.send(user, InvoicePaid);
// → { mail: { ok: true }, database: { ok: true, id: "42" } }
send() never rejects — a channel that throws or fails becomes a
{ ok: false, error } result without stopping the others. A channel listed in
via() but not registered is reported; a channel with no matching
to<Channel>() method is skipped ({ ok: true, skipped: true }).
Channels
| Channel | Factory | Notes |
|---|---|---|
mailChannel({ appId, deliver? }) |
POSTs to bext's loopback SDK email endpoint; inject deliver for tests/custom transport |
|
| SMS | smsChannel({ send }) |
no built-in provider — you inject send (Twilio, Vonage, …). This module is the abstraction; the provider is yours |
| Slack | slackChannel({ webhookUrl?, deliver? }) |
posts to an incoming webhook |
| In-app | databaseChannel({ model }) |
persists a row through any create(data) — composes with the ORM for a notification feed |
| Webhook | webhookChannel({ url?, deliver? }) |
POSTs a JSON payload |
| Custom | defineChannel(name, send) |
build your own from one async fn |
Each channel resolves its destination from the message (toMail().to) or the
recipient (recipient.email / recipient.phone), so a Notification stays
transport-agnostic.
bext deliberately has no SMS provider. smsChannel exists so your code depends
on the abstraction, not on Twilio — swap the injected send for a fake in
tests and for the real client in production without touching a single call site.
Testing
Because every channel's transport is injectable, the whole thing is unit-testable
with no network — pass a deliver/send that records calls, and use a
bun:sqlite-backed ORM model for the database channel.
Try It
The live notifications demo fans
a message out to email + Slack (simulated) + a real persisted in-app feed —
send one and watch the per-channel results and the feed update. Source in
sites/demo/src/app/examples/notifications/page.tsx.
See Also
- Mailer — the Rust email capability this generalises.
- Data & Migrations — the ORM the in-app channel persists through.
- Application Toolkit — the rest of the TypeScript app layer.