Stone.jsDocs
Paradigm

Extensions

Notifications

One declaration reaches a person wherever they are: the mailbox, the phone, and the tab they already have open. Delivered out of band, in their own language, without the application wiring the three together.

#Install

terminalbash
npm i @stone-js/notifications

# optional, and each earns its keep:
npm i @stone-js/queue      # deliver out of band rather than in the request
npm i @stone-js/realtime   # reach the tab someone already has open
npm i @stone-js/i18n       # write in the recipient's own language
npm i nodemailer           # the SMTP channel

#Enable it

The principle

Reaching someone is one intention, not three integrations. Where they are reached, and in which language, is a property of the person rather than of the code that decided to tell them.

In Stone.js

A notifier resolves who the person is, renders a template key in their locale, and hands the delivery to a queue. Channels are drivers behind one port, so adding the phone changes configuration rather than callers.

app/Application.tsdeclarativeimperative
import { StoneApp } from '@stone-js/core'
import { Notifications } from '@stone-js/notifications'

@Notifications({
  default: ['smtp', 'in-app'],
  channels: [{ name: 'smtp', driver: 'smtp', from: 'App <no-reply@example.test>' }]
})
@StoneApp({ name: 'app' })
export class Application {}
import { defineConfig, defineStoneApp } from '@stone-js/core'
import { notificationsBlueprint } from '@stone-js/notifications'

// Enable the module on the manifest, exactly where the decorator sits
export const App = defineStoneApp({ name: 'app' }, [notificationsBlueprint])

// Then configure it
export const AppConfig = defineConfig((blueprint) => blueprint.set('stone.notifications', {
  default: ['smtp', 'in-app'],
  channels: [{ name: 'smtp', driver: 'smtp', from: 'App <no-reply@example.test>' }],
  recipients: async (id) => await accounts.contactFor(id)
}))

#A notice: what someone receives

The decorator carries the metadata. The class carries the content.

There is no content option, and that is deliberate: text in a decorator is text that cannot be translated, formatted, or read off the event. The class is built through the container, so it asks for whatever it needs.

app/ConsentNeeded.tsdeclarativeimperative
import { Notice } from '@stone-js/notifications'

@Notice({
  name: 'guardianship.consent_needed',
  on: 'identity.guardian.invited.v1',
  channels: ['smtp', 'in-app']
})
export class ConsentNeeded {
  constructor ({ i18n }) { this.i18n = i18n }

  // Who learns about it. Required when the notice reacts to an event.
  recipients (event) { return event.guardianId }

  // What it says, per channel. Asked once per recipient.
  notify (event, { locale }) {
    return {
      smtp: {
        subject: this.i18n.t('consent.subject', { lng: locale }),
        body: this.i18n.t('consent.body', { lng: locale, child: event.childHandle })
      },
      'in-app': { body: this.i18n.t('consent.short', { lng: locale }) }
    }
  }
}
import { defineNotice } from '@stone-js/notifications'

blueprint.set('stone.notifications.notices', [
  defineNotice(ConsentNeeded, {
    name: 'guardianship.consent_needed',
    on: 'identity.guardian.invited.v1',
    channels: ['smtp', 'in-app']
  })
])

#Nobody calls the notifier

the pathts
identity emits  ->  identity.guardian.invited.v1  ->  the notice that named it
                                                     renders, chooses its channels, delivers

The notice subscribes through the light key router, the same one @stone-js/event-busroutes domain events through, so the emitting module imports nothing and is never reopened when a channel is added. That is the whole reason this is a declaration rather than a service call: a coupling that does not exist cannot become a cycle.

#Tell someone something

app/GuardianshipService.tsts
export class GuardianshipService {
  constructor ({ notifier }) { this.notifier = notifier }

  async invite (guardianId, child) {
    await this.notifier.notify(guardianId, 'guardianship.consent_needed', { child })
  }
}

A template key, never a rendered body. What is not copied does not have to be erased, and a message queued before a translation was fixed goes out fixed.

app/anywhere.tsts
await notifier.notify({ id: 'u1', email: 'a@example.test', locale: 'fr' }, 'welcome')
await notifier.notify(['u1', 'u2'], 'edition.opened', { edition: 'Spring' })
await notifier.notify(user, 'welcome', {}, { channels: ['in-app'], inline: true })

#What this module decides, and what it does not

It decides who learns what, through which channel, and in which language. It never decides whether to send.

Consent, preferences, quiet hours and audiences are yours, because the rules that matter there are about your own people: a framework imposing them would be wrong for the first application whose rules differ. Make that call, then call here.

#Channels

channelTypeDescription
logbuilt-inThe zero-config default. Writes the message where you write everything else and reaches nobody. Right in development and in tests, and not a channel anywhere else.
in-apprealtimeBroadcasts on the recipient's own realtime channel, so an open tab receives it. Needs @stone-js/realtime.
smtpnodemailerEmail over SMTP. SMTP rather than a provider's API, so one channel reaches all of them without this package choosing a vendor for you.

sms and push are not shipped, deliberately: a channel that picked a vendor would be wrong for everyone who chose a different one. Register yours, and it is a channel like any other.

app/TwilioChannel.tsdeclarativeimperative
import { NotificationChannel } from '@stone-js/notifications'

@NotificationChannel('sms')
export class TwilioChannel {
  readonly name = 'sms'

  constructor ({ twilio }) { this.twilio = twilio }

  async send (message, recipient) {
    if (recipient.phone === undefined) {
      return { status: 'unreachable', retryable: false, reason: 'No phone number.' }
    }
    await this.twilio.messages.create({ to: recipient.phone, body: message.body })
    return { status: 'sent' }
  }
}
blueprint.set('stone.notifications.channels', [
  { name: 'sms', factory: () => new TwilioChannel(twilio) }
])

#The port a channel implements

send(message, recipient) returns an outcome and does not throw, for everything it can foresee. The outcome says whether another attempt could work: a provider being down is retryable, an address that does not exist never will be, and retrying that forever is how a queue fills with work that cannot succeed.

#The language a message is written in

The recipient's, never the request's. A French-speaking guardian invited by an English-speaking member of staff reads French. Getting that backwards is invisible in every test written by one person in one language, and obvious to the person who receives it.

Keys are looked up in @stone-js/i18n under <key>.subject and <key>.body, in that person's locale. Without a catalogue, declare templates in configuration.

app/AppConfig.tsts
templates: {
  'guardianship.consent_needed': {
    subject: 'Your consent is needed',
    body: 'Please confirm for {{ child }}.'
  }
}

#Delivery is out of band

the pathts
notify()  ->  resolve the person, choose the channels, dispatch a job
          ->  a worker delivers  ->  the provider, and the open tab

Deciding who learns what is fast. Reaching a mail provider is not, and a request that waits for one is a request that times out on the endpoint the user is watching. The worker runs the same code the inline path runs, so a retry means exactly what the first attempt meant.

Without a queue, delivery happens in the request, which is right for development and says so when it was not what you asked for. And notify() never throws at its caller: a notification is almost always a side effect of something that already succeeded, and failing that operation because a provider was down would undo work that was correct.

#The same message twice

The most common production failure of any notification system: a queue is at-least-once, a retry half succeeded, or two events describe one fact. Name the occurrence and the repeat is dropped.

app/anywhere.tsts
await notifier.notify(user, 'welcome', {}, { dedupe: `welcome:${user.id}` })

A notice states its own through dedupe(event). Keys are claimed atomically in the cache store the application already chose, so this module stores nothing of its own. Without the cache module, deduplication does not happen and says so once: sending twice in silence is the failure it exists to prevent.

#Who received what

This module keeps no delivery ledger, because the answer to "why did they never receive it" belongs in whatever the application already queries. It announces instead, and you write the row you need.

app/NotificationLedger.tsts
@BusHandler()
export class NotificationLedger {
  @OnBusEvent('notification.failed')
  onFailed (event) { /* ... write your own row */ }
}

#Seeing it before sending it

app/anywhere.tsts
const previewed = await notifier.preview(guardian, 'guardianship.consent_needed', { child: 'Lea' })
// [{ recipient, channel: 'smtp', message: { subject, body, locale } }, ...]

Exactly what delivery would render, per channel, with nothing sent. For the screen that shows a member of staff what a guardian is about to receive, and for a test that checks a notice without a channel.

#Later rather than now

app/anywhere.tsts
await notifier.notify(user, 'trial.ending', {}, { delay: 86_400 })

Deferred by the queue, because a timer held in a process a cold start can end is not a reminder.

#Configuration

OptionTypeDefaultDescription
channelsChannelConfig[]·The channels this application configures.
defaultstring[]['log']The channels a notification uses when it names none.
recipientsfunction·How to turn an id into a person. The one thing this module cannot ship: the address is then read at send time rather than copied into a message.
templatesobject·Templates, for an application with no translation catalogue. Also the override when there is one.
dispatch'queue' | 'inline'·Defaults to queue when a queue is enabled, and to inline otherwise, saying so once.
queuestring·Which queue to dispatch on.
attemptsnumber·How many times a queued delivery is retried.
noticesNoticeDeclaration[]·Notices declared in configuration rather than with @Notice. Both are read.
dedupe{ ttl, store }·Where a repeated occurrence is recognised. Keys live in @stone-js/cache.
announceboolean·Emit notification.delivered and notification.failed on the bus. On when a bus is enabled.

Stone.js

Your app exists in every runtime. Until you run it.

An open-source project by Stone Foundation
Created by Mr. Stone (Evens Pierre)