@setu-ts/notification-plugin
Multi-channel notifications — email, SMS, push, Slack. Registers an INotifier under
CAPABILITIES.NOTIFICATION ('notification').
Two layers: channels own address extraction and payload shaping, providers own transport.
Every provider speaks web-standard fetch through one injectable INotificationHttp seam, so there
is no npm: import anywhere and all channels are Workers-portable.
Installation
import { NotificationPlugin } from '@setu-ts/notification-plugin';
Usage
import { NotificationPlugin } from '@setu-ts/notification-plugin';
import { CAPABILITIES, type INotifier } from '@setu-ts/common';
app.register(
NotificationPlugin({
channels: {
email: { provider: 'mail' },
sms: {
provider: 'twilio',
options: { accountSid: '…', authToken: '…', from: '+15550100' },
},
slack: { provider: 'slack', options: { webhookUrl: 'https://hooks.slack.com/…' } },
},
}),
);
const notifier = app.services.get<INotifier>(CAPABILITIES.NOTIFICATION);
await notifier.send({
channels: ['email', 'sms', 'slack'],
to: { email: 'ada@example.com', phone: '+15550123' },
subject: 'Deploy finished',
body: 'Build 412 is live.',
});
Options
NotificationPluginOptions takes one option:
| Option | Type | Default | Description |
|---|---|---|---|
channels | ChannelsMap | — | Channel definitions keyed by dispatch name. Required. |
Each entry is a ChannelConfig, a union discriminated on provider, so a missing credential is a
compile error rather than a startup throw. Configuring email without a registered mail
capability throws during register(). See Channels for what each one reads.
Channels
| Channel | Reads from to | Transport |
|---|---|---|
email | to.email | the IMailer resolved from CAPABILITIES.MAIL |
sms | to.phone | TwilioProvider |
push | to.token | FcmProvider (subject becomes the title) |
slack | to.channel * | SlackProvider (incoming webhook) |
* optional.
Semantics
sendfans out withPromise.allSettled— one failing channel never aborts the others. Failures surface together as anAggregateErrorwhoseerrorsare coerced toError.ChannelConfigis a union discriminated onprovider, so a missing credential is a compile error, not a startup throw.- Configuring
emailwithout amailcapability registered throws duringregister— fail fast, ordered viaoptionalDependencies: ['mail'].
Health
Registers a notification health indicator reporting { channels, reachable }, where reachable
maps each configured channel name to true, false, or 'unknown'. One channel that was CONTACTED
and did not answer takes the indicator down.
| Channel | Probe | Reports |
|---|---|---|
mail | the resolved IMailer’s own isHealthy?() | true/false/unknown |
twilio | none | 'unknown' |
fcm | none | 'unknown' |
slack | none | 'unknown' |
'unknown' for the send-only transports is the honest answer rather than a gap: a health probe may
not deliver a notification to a real person, and none of them offers a side-effect-free alternative
this plugin can reach. A Slack incoming webhook has no read endpoint at all; Twilio and FCM have
one, but reaching it needs a GET that the INotificationHttp seam does not expose. Crucially,
'unknown' never reads as healthy, where the hardcoded up this replaced did.
Probes are cached for 5 s and bounded at 2 s on the runtime’s own clock and timers.
Push (FCM HTTP v1)
FcmProvider speaks FCM HTTP v1, authenticating with a short-lived OAuth2 token minted from a
service account — it signs an RS256 JWT assertion with runtime.subtle (no npm dependency, works on
Workers) and caches the token until shortly before it expires.
import { NotificationPlugin } from '@setu-ts/notification-plugin';
app.register(
NotificationPlugin({
channels: {
push: {
provider: 'fcm',
options: {
projectId: config.get('FCM_PROJECT_ID'),
clientEmail: config.get('FCM_CLIENT_EMAIL'),
privateKey: config.get('FCM_PRIVATE_KEY'), // PEM PKCS#8 from the service-account JSON
},
},
},
}),
);
The three fields come from the service-account JSON you download from the Firebase console
(project_id, client_email, private_key). Because the default signer needs Web Crypto and the
wall clock, a push channel configured this way requires RuntimePlugin and throws during
register without it.
To source tokens elsewhere — a GCP metadata server, or a broker that holds the key outside the
application — supply a tokenSource implementing FcmTokenSource; the credential fields are then
unused and the runtime requirement does not apply.
Exports
| Export | Kind |
|---|---|
createChannel | function |
createDefaultNotificationHttp | function |
createProvider | function |
NotificationPlugin | function |
EmailChannel | class |
FcmProvider | class |
NotificationService | class |
PushChannel | class |
SlackChannel | class |
SlackProvider | class |
SmsChannel | class |
TwilioProvider | class |
FcmChannelConfig | interface |
FcmProviderOptions | interface |
FcmTokenSource | interface |
INotificationHttp | interface |
INotifier | interface |
MailChannelConfig | interface |
NotificationHttpResponse | interface |
NotificationMessage | interface |
NotificationPluginOptions | interface |
PushMessage | interface |
PushTransport | interface |
SlackChannelConfig | interface |
SlackMessage | interface |
SlackProviderOptions | interface |
SlackTransport | interface |
SmsMessage | interface |
SmsTransport | interface |
TwilioChannelConfig | interface |
TwilioProviderOptions | interface |
ChannelConfig | type |
ChannelsMap | type |
NotificationTransport | type |
ProviderType | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Full API
Every export and option is documented in PUBLIC_API.md.
API reference
Notifications (@setu-ts/notification-plugin)
Provides multi-channel notifications.
Registration
import { NotificationPlugin } from '@setu-ts/notification-plugin';
app.register(
NotificationPlugin({
channels: {
// Email delegates to the `IMailer` from MailPlugin (M29) — no options here.
email: { provider: 'mail' },
sms: {
provider: 'twilio',
options: {
accountSid: config.get('TWILIO_SID'),
authToken: config.get('TWILIO_TOKEN'),
from: config.get('TWILIO_FROM'), // required — the sender number
},
},
push: {
provider: 'fcm',
options: {
projectId: config.get('FCM_PROJECT_ID'),
clientEmail: config.get('FCM_CLIENT_EMAIL'),
privateKey: config.get('FCM_PRIVATE_KEY'), // PEM PKCS#8, from the service-account JSON
},
},
slack: { provider: 'slack', options: { webhookUrl: config.get('SLACK_WEBHOOK') } },
},
}),
);
ChannelConfig is discriminated on provider, so each entry’s options are checked against that
provider’s option type: omitting from above is a compile error, not a startup throw.
Usage
app.router.post('/orders', async (ctx) => {
const notifier = ctx.services.get<INotifier>('notification');
const order = await createOrder(ctx.request.body);
// Multi-channel
await notifier.send({
channels: ['email', 'sms'],
to: { email: order.customerEmail, phone: order.customerPhone },
subject: 'Order Confirmed',
body: `Your order ${order.id} has been confirmed.`,
});
// Slack-only
await notifier.send({
channels: ['slack'],
to: { channel: '#orders' },
body: `New order: ${order.id}`,
});
return ctx.response.status(201).json(order);
});
Options
| Option | Provider | Description |
|---|---|---|
channels | — | Required map of dispatch name → ChannelConfig. Keys are the names a caller passes in send({ channels: [...] }). |
channels.*.provider | — | 'mail' | 'twilio' | 'fcm' | 'slack' — selects the channel class and transport. |
options.accountSid / authToken / from | twilio | Twilio credentials and sender number; all three required (construction throws). |
options.projectId | fcm | Firebase project id, addressed by the v1 messages:send URL; required (construction throws). |
options.clientEmail / privateKey | fcm | Service-account email and PEM PKCS#8 key that sign the OAuth2 assertion; required unless tokenSource is supplied. |
options.tokenSource | fcm | Overrides token acquisition (e.g. a GCP metadata server); when set the credential fields above are unused. |
options.webhookUrl | slack | Slack incoming-webhook URL; required (construction throws). |
options.http | twilio, fcm, slack | Injected INotificationHttp (defaults to createDefaultNotificationHttp(), i.e. global fetch). |
The mail arm takes no options: email transport is configured on MailPlugin (M29) and resolved
through CAPABILITIES.MAIL.
Exports
NotificationPlugin(options)— plugin factory.createChannel(name, config, ctx?)— channel builder.createProvider(config, ctx?)— transport builder, overloaded so eachChannelConfigarm returns its own transport port (mail→IMailer,twilio→SmsTransport,fcm→PushTransport,slack→SlackTransport).NotificationService— theINotifierimplementation (parallel fan-out +AggregateError).EmailChannel,SmsChannel,PushChannel,SlackChannel— channel classes (address extraction and payload shaping).TwilioProvider,FcmProvider,SlackProvider— zero-dependency HTTP transports.createDefaultNotificationHttp(fetchImpl?)— thefetch-backed defaultINotificationHttp.NotificationPluginOptions,ChannelsMap,ChannelConfig,MailChannelConfig,TwilioChannelConfig,FcmChannelConfig,SlackChannelConfig,ProviderType,NotificationTransport,TwilioProviderOptions,FcmProviderOptions,SlackProviderOptions— configuration types.INotificationHttp,NotificationHttpResponse,SmsTransport,SmsMessage,PushTransport,PushMessage,SlackTransport,SlackMessage— injection/transport types.INotifier,NotificationMessage— re-exported from@setu-ts/common.
Notes
INotifierhas exactly one method,send. There is nosendEmail/sendSms/sendSlack— single-channel dispatch issend({ channels: ['sms'], … }).- Channels dispatch in parallel via
Promise.allSettled: one failing channel never aborts the others. When any fail,sendthrows anAggregateError(message'One or more notification channels failed') whoseerrorsare alwaysErrorinstances, one per failed channel, including unknown channel names. An emptychannelsarray resolves without dispatching. - Each channel reads its own address key out of
toand throws when it is absent:to.email(email),to.phone(sms),to.token(push). Slack’sto.channelis optional — omitted, the webhook’s default channel applies.subjectbecomes the mail subject (defaulting to'(no subject)') and the push notification title; SMS and Slack ignore it. Built-in channels do not readmetadata— it is there for customNotificationChannelimplementations. - Configuring an
emailchannel without a registeredmailcapability throws duringregister, so the misconfiguration surfaces at startup rather than at first send. - All three HTTP providers are zero-dependency (web-standard
fetch) and Workers-portable; the email channel inherits M29’s provider constraints (SmtpProviderneeds raw sockets).FcmProvidertargets FCM HTTP v1 (POST /v1/projects/{projectId}/messages:send), authenticating with an OAuth2 bearer token minted from a service account: it signs an RS256 JWT assertion withruntime.subtleand caches the resulting token until shortly before it expires, so a send costs one request in the steady state. Apushchannel using the default signer therefore requiresCAPABILITIES.RUNTIMEand throws duringregisterwithout it; supplyingtokenSourceremoves that requirement. - A
notificationhealth indicator reports'up'with the configured channel names. There is noonClose: the providers hold no socket, timer, or connection.