@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:

OptionTypeDefaultDescription
channelsChannelsMap—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

ChannelReads from toTransport
emailto.emailthe IMailer resolved from CAPABILITIES.MAIL
smsto.phoneTwilioProvider
pushto.tokenFcmProvider (subject becomes the title)
slackto.channel *SlackProvider (incoming webhook)

* optional.

Semantics

  • send fans out with Promise.allSettled — one failing channel never aborts the others. Failures surface together as an AggregateError whose errors are coerced to Error.
  • ChannelConfig is a union discriminated on provider, so a missing credential is a compile error, not a startup throw.
  • Configuring email without a mail capability registered throws during register — fail fast, ordered via optionalDependencies: ['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.

ChannelProbeReports
mailthe resolved IMailer’s own isHealthy?()true/false/unknown
twilionone'unknown'
fcmnone'unknown'
slacknone'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

ExportKind
createChannelfunction
createDefaultNotificationHttpfunction
createProviderfunction
NotificationPluginfunction
EmailChannelclass
FcmProviderclass
NotificationServiceclass
PushChannelclass
SlackChannelclass
SlackProviderclass
SmsChannelclass
TwilioProviderclass
FcmChannelConfiginterface
FcmProviderOptionsinterface
FcmTokenSourceinterface
INotificationHttpinterface
INotifierinterface
MailChannelConfiginterface
NotificationHttpResponseinterface
NotificationMessageinterface
NotificationPluginOptionsinterface
PushMessageinterface
PushTransportinterface
SlackChannelConfiginterface
SlackMessageinterface
SlackProviderOptionsinterface
SlackTransportinterface
SmsMessageinterface
SmsTransportinterface
TwilioChannelConfiginterface
TwilioProviderOptionsinterface
ChannelConfigtype
ChannelsMaptype
NotificationTransporttype
ProviderTypetype

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

OptionProviderDescription
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 / fromtwilioTwilio credentials and sender number; all three required (construction throws).
options.projectIdfcmFirebase project id, addressed by the v1 messages:send URL; required (construction throws).
options.clientEmail / privateKeyfcmService-account email and PEM PKCS#8 key that sign the OAuth2 assertion; required unless tokenSource is supplied.
options.tokenSourcefcmOverrides token acquisition (e.g. a GCP metadata server); when set the credential fields above are unused.
options.webhookUrlslackSlack incoming-webhook URL; required (construction throws).
options.httptwilio, fcm, slackInjected 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 each ChannelConfig arm returns its own transport port (mail → IMailer, twilio → SmsTransport, fcm → PushTransport, slack → SlackTransport).
  • NotificationService — the INotifier implementation (parallel fan-out + AggregateError).
  • EmailChannel, SmsChannel, PushChannel, SlackChannel — channel classes (address extraction and payload shaping).
  • TwilioProvider, FcmProvider, SlackProvider — zero-dependency HTTP transports.
  • createDefaultNotificationHttp(fetchImpl?) — the fetch-backed default INotificationHttp.
  • 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

  • INotifier has exactly one method, send. There is no sendEmail / sendSms / sendSlack — single-channel dispatch is send({ channels: ['sms'], … }).
  • Channels dispatch in parallel via Promise.allSettled: one failing channel never aborts the others. When any fail, send throws an AggregateError (message 'One or more notification channels failed') whose errors are always Error instances, one per failed channel, including unknown channel names. An empty channels array resolves without dispatching.
  • Each channel reads its own address key out of to and throws when it is absent: to.email (email), to.phone (sms), to.token (push). Slack’s to.channel is optional — omitted, the webhook’s default channel applies. subject becomes the mail subject (defaulting to '(no subject)') and the push notification title; SMS and Slack ignore it. Built-in channels do not read metadata — it is there for custom NotificationChannel implementations.
  • Configuring an email channel without a registered mail capability throws during register, 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 (SmtpProvider needs raw sockets). FcmProvider targets 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 with runtime.subtle and caches the resulting token until shortly before it expires, so a send costs one request in the steady state. A push channel using the default signer therefore requires CAPABILITIES.RUNTIME and throws during register without it; supplying tokenSource removes that requirement.
  • A notification health indicator reports 'up' with the configured channel names. There is no onClose: the providers hold no socket, timer, or connection.

Agent card · TypeDoc API reference