@aweftjs/notify
The notify battery: one send to a person, over the channels its level picks. An inbox per user in the application's own store, shared live on every connection of theirs; email through Resend; push through Firebase Cloud Messaging; what each channel did written into the answer and onto the item, and a channel that fails never losing the message. @aweftjs/notify/client is the browser half: the inbox as a live list, an unread count, marking read, and registering the device.
It decides nothing about who may send to whom. That rule, and every cap on a sender, is the application's, in the module that calls send.
Quickstart
The server side is three modules in one source, configured by a file of the same name in your own source:
import { auth, paths } from '@aweftjs/auth';
import { fromDirectory } from '@aweftjs/modules/node';
import { notify } from '@aweftjs/notify';
import { createServer } from '@aweftjs/server';
import { node } from '@aweftjs/server/node';
import { createStore, memoryDriver } from '@aweftjs/store';
const store = createStore({ driver: memoryDriver(), declare: { ...paths } });
const server = createServer({
sources: [fromDirectory('./modules'), notify, auth],
store,
gate: 'auth/Gate',
listener: node({ port: 8080 }),
});
await server.start();// modules/notify/Send.ts, in your own source: where email and push go out
export const config = {
email: { resend: { key: process.env.RESEND_KEY, from: 'Acme <hello@acme.test>' } },
push: { fcm: { account: process.env.FCM_ACCOUNT } },
};Any module of yours sends by naming the broker in deps:
// modules/orders/Ship.ts
export const deps = ['notify/Send'];
export default ({ imports }) => ({
call: async ({ order }, context) => imports.Send.send({
to: { user: context.user },
title: `Order ${order} shipped`,
body: 'It is on its way.',
level: 'warn',
url: `/orders/${order}`,
tag: 'orders',
}),
});The page hands its client to createInbox once it knows who it is:
import { createAuth } from '@aweftjs/auth/client';
import { createClient } from '@aweftjs/client';
import { createInbox } from '@aweftjs/notify/client';
const client = createClient();
const auth = createAuth(client);
auth.user.effect((who) => {
if (typeof who !== 'string') return;
const inbox = createInbox(client);
inbox.unread.effect((n) => { badge.textContent = n === 0 ? '' : String(n); });
inbox.ready.then((items) => render(items)); // the list is live from here on
openButton.onclick = () => inbox.read(); // every item read
});What send takes and answers
const sent = await imports.Send.send({
to: { user: id }, // or { email: 'someone@example.com' }
title: 'Backup stopped', // required, cut at 200
body: 'Nothing has run for 3 days.', // cut at 2000
level: 'error', // info (the default), warn or error
channels: ['inbox', 'email'], // in place of the level's; omit to let the level pick
url: '/settings/backups', // kept as given; whether it is a safe link is the page's call
tag: 'backups', // a label of yours: the inbox shows it, a push groups on it
html: '<p>...</p>', // the mail's html; the escaped title and body in two paragraphs by default
replyTo: 'visitor@example.com',
private: false, // let the push carry the text; the configuration's private by default
});
// sent: { id, at, delivery: { inbox: { ok: true }, email: { ok: true, id }, push: { ok: true, devices: 2 } } }The level picks the channels unless channels names them: info is the inbox, warn adds push, error adds email. The map is configuration (below).
The recipient is a user or an address. { user } is the id the gate put on the context; the user's address is read off user:<id>, where the auth battery keeps it, unless the configuration hands in address. { email } is for a server with no users, such as a site whose contact form mails its owner: the inbox and push need a user, so both are skipped for an address and the answer says so.
Each channel checks in a fixed order, and the record names the first thing that stopped it. The inbox: a user, then a store. Email: the user's address, then the email setting, then outward. Push: a user, then a store, then a device registered with an endpoint, then the push setting, then outward. So a send with no device registered says no device is registered for push even when push is not configured, and outward: false is only reached once everything local has passed, which is what keeps a test from hiding a bug in those steps.
delivery is one entry per channel tried. { ok: true, ... } (email carries the provider's id, push how many devices took it and any errors), { ok: false, error } with the provider's own message where there was one, or { skipped } with the reason: no user for the inbox and push on an address recipient, no store for both on a server with no store. A sender or pusher that throws is { ok: false, error } with its message, and one that does not answer within timeoutMs is the same with a line saying so. send itself throws only for a notification out of shape (invalid-notification: a missing title, a field of the wrong type, an unknown level or channel, a recipient of neither shape) or a recipient over the cap (capped).
The inbox is written first, before any network call, so the item is on the page while the mail goes out, and the record is written onto it once every channel has answered. A push carries the item's id and, by default, no text: the phone shows that something happened and the page reads the text from the inbox. private: false on a send, or private: false in the configuration, sends the title and body through the push service.
The inbox on the page
createInbox(client) answers { items, ready, unread, read, register, forget, stop }.
readysettles with the items once the server shares them. It asksnotify/Inboxfirst, so an anonymous page hears the gate'srefusedat once rather than waiting for a topic the server never offers, and a gate with no users answersanonymous. While the socket is still opening it waits, and a client made withtimeoutbounds that wait. Make the view onceauth.userreads a string. One client has one live view: callingcreateInboxagain hands back the same one until it is stopped or refused, because the server offers the topic once per socket. Stop the view beforeenterorleave, whose connection is another user's, and make a new one after: it pairs on the socket they open.itemsis the shared document's own list, oldest first, so a component that follows it hears every send live. It is read only. The server refuses a commit from the page withread-only, and a page that writes into it anyway holds its own version until the next connection, when the server's state wins (design 184). Mark items read withread.unreadis a read-only cell counting the items with noreadAt.read(ids?)marks those items, or every item when omitted, and answers how many changed.register({ device, platform, transport, endpoint })records this browser or app as a place the user can be reached:deviceis an id the page mints once and keeps in its own storage,platformisandroid,ios,desktoporweb,transportisfcmwith the token asendpoint, ornonefor a device that cannot be woken. Register on every launch: a token can change between them.forget(device)drops one.stop()stops following the connection and leaves the client open.
What is stored
Two kinds of document, reached by name, so nothing is declared on the store:
inbox:<user>:{ items }, the lastkeep(200) items, oldest first. An item is{ id, at, level, title, body, url, tag, readAt, delivery }, primitives anddeliveryas JSON text.notify/Inboxshares it under the topicinboxon every connection of the user and refuses every commit from the page.devices:<user>:{ devices: { <id>: { platform, transport, endpoint, seenAt } } }, at mostperUser(20). Never shared on a link: an endpoint is where a person can be reached.
The battery truncates its own documents' tails, since nothing replays them.
Configuring it
Each module is configured the way any battery module is (design 240): a same-named file exporting config in a source before this one. notify/Send holds the channels:
// modules/notify/Send.ts
export const config = {
levels: { info: ['inbox'], warn: ['inbox', 'push'], error: ['inbox', 'push', 'email'] },
email: { resend: { key: process.env.RESEND_KEY, from: 'Acme <hello@acme.test>' } },
push: { fcm: { account: process.env.FCM_ACCOUNT } },
perHour: 100, // sends one recipient may get in an hour; the next is refused `capped`
outward: true, // false refuses email and push just before the sender or pusher is called, your own function included
private: true, // a push carries no text unless the send says otherwise
address: null, // (user) => email, for users kept somewhere other than user:<id>
timeoutMs: 10_000, // per network call
};email is { resend: { key, from } }, or your own (mail) => Promise<Delivery> taking { to, subject, text, html, replyTo }, or null, in which case the channel answers { ok: false } naming the setting. push is { fcm: { account } } with the service account key file as JSON or base64 on one line (base64 survives an environment file that eats backslashes; JSON does not), or your own (data, endpoints) => Promise<PushOutcome[]> answering one entry per endpoint in the order given (an entry with stale: true forgets that device), or null. Your own function is awaited for timeoutMs and no longer. The FCM adapter forgets a device the service answers UNREGISTERED, SENDER_ID_MISMATCH or 404 for, and keeps one it answers INVALID_ARGUMENT for, since that is also what a message too large gets. A level given in levels merges over the defaults for the others. A value of the wrong type is invalid-config at load.
notify/Inbox takes keep (200) and idleMs (60000, how long an inbox is held open after the last connection or send). notify/Devices takes perUser (20) and endpointBytes (1024).
What it never does
Decide who may send to whom: an application with users who install other people's apps writes its wall, its consent and its per-sender caps in the module that calls send. Cap a sender: the one cap is per recipient, a promise to the person, and the battery cannot know who asked. Own a template. Decide whether a url is a safe link. Send without being asked. Answer a route. Share a device endpoint with a page. Store a provider key or a service account anywhere but the configuration.
Known limits
One process. The per-recipient cap is counted in memory, so a restart clears it and a second process has its own count, and the inbox is held open in one process; two processes over one store need a note of their own.
The inbox keeps
keepitems and the oldest fall off; an application that wants every notification kept writes them somewhere of its own.A page cannot yield after a refused write: the client's handle offers no resync, so a page that wrote into
itemsholds its own version until the next connection. Do not write into it.Resend and FCM are the two adapters that ship. Another provider is a function in the configuration today; one that needs a dependency, SMTP over a mailer for one, is a subpath and an optional peer when it is built (design 140).
The FCM adapter sends a data-only message the application's own service on the device draws. A device with no such service shows nothing for it;
message.notification, which the system draws, is deliberately never sent.
The design note is in docs/design/, 268. recipes/notify is the whole thing in one small application, two pages of one user in a browser against two fake services.
API
Every export of @aweftjs/notify, its signature as the compiler resolves it, and its block comment.
@aweftjs/notify
Channel
type Channel = 'inbox' | 'email' | 'push';Where a notification can go.
Delivery
type Delivery = { readonly ok: true; readonly [detail: string]: unknown; } | { readonly ok: false; readonly error: string; } | { readonly skipped: string; };What one channel did with one notification.
DeliveryRecord
type DeliveryRecord = Readonly<Partial<Record<Channel, Delivery>>>;The record of a send: one entry per channel tried.
Device
interface Device { readonly platform: 'android' | 'ios' | 'desktop' | 'web'; readonly transport: 'fcm' | 'none'; readonly endpoint: string | null; readonly seenAt: number; }A device a user registered for push.
Devices
interface Devices { call(args: unknown, context: unknown): Promise<{ devices: number; }>; list(user: string): Promise<Readonly<Record<string, Device>>>; forget(user: string, device: string): Promise<void>; }The instance: the call, and what the broker calls.
EmailSetting
type EmailSetting = { readonly resend: { readonly key: string; readonly from: string; readonly endpoint?: string | undefined; }; } | Sender | null;How email goes out: Resend with a key, the application's own sender, or not at all.
Inbox
interface Inbox { connection(connection: Connection): Promise<(() => Promise<void>) | undefined>; call(args: unknown, context: unknown): Promise<{ marked: number; }>; append(user: string, item: Item): Promise<void>; delivered(user: string, id: string, record: DeliveryRecord): Promise<void>; stop(): Promise<void>; }The instance: the share on a connection, the call, and what the broker calls.
Item
interface Item { readonly id: string; readonly at: number; readonly level: Level; readonly title: string; readonly body: string; readonly url: string | null; readonly tag: string | null; readonly readAt: number | null; readonly delivery: string; }One notification as an inbox holds it: primitives only, so a row in the store is a row.
Level
type Level = 'info' | 'warn' | 'error';How urgent a notification is, which picks its channels unless the caller names them.
Mail
interface Mail { readonly to: string; readonly subject: string; readonly text: string; readonly html: string; readonly replyTo?: string | undefined; }What the email channel hands a sender.
PushData
type PushData = Readonly<Record<string, string>>;What one push carries, every value a string because the service refuses anything else.
PushOutcome
type PushOutcome = Delivery & { readonly stale?: boolean; };One device's answer. stale says the endpoint is dead and should be forgotten.
PushSetting
type PushSetting = { readonly fcm: { readonly account: string; readonly endpoint?: string | undefined; readonly tokenUrl?: string | undefined; }; } | Pusher | null;How push goes out: FCM with a service account, the application's own pusher, or not at all.
Pusher
type Pusher = (data: PushData, endpoints: readonly string[]) => Promise<PushOutcome[]>;What the push channel hands a pusher: the data, and the endpoint of each device.
Recipient
type Recipient = { readonly user: string; } | { readonly email: string; };Who a notification is for: a user the store knows, or an address.
Send
interface Send { send(options: SendOptions): Promise<Sent>; }The instance: one method, for any module that names this one in deps.
SendOptions
interface SendOptions { readonly to: Recipient; readonly title: string; readonly body?: string | undefined; readonly level?: Level | undefined; readonly channels?: readonly Channel[] | undefined; readonly url?: string | null | undefined; readonly tag?: string | null | undefined; readonly html?: string | undefined; readonly replyTo?: string | undefined; readonly private?: boolean | undefined; }What a module hands send.
Sender
type Sender = (mail: Mail) => Promise<Delivery>;A sender: the built-in Resend one, or the application's own function in the configuration.
Sent
interface Sent { readonly id: string; readonly at: number; readonly delivery: DeliveryRecord; }What send answers: the item's id and time, and what each channel did.
notify
notify: SourceThe three modules, for sources: notify/Send is the broker any module names in deps, notify/Inbox keeps and shares each user's inbox, notify/Devices keeps where a user can be reached for push (design 268).
Returns the source. Put the application's own source first and a module of the same name there wins; a file of that name exporting only config configures it instead.
Example
const server = createServer({ sources: [own, notify, auth], store, gate: 'auth/Gate', listener });
// modules/notify/Send.ts, in `own`:
export const config = { email: { resend: { key: process.env.RESEND_KEY, from: 'Acme <hello@acme.test>' } } };
// modules/orders/Ship.ts, in `own`:
export const deps = ['notify/Send'];
export default ({ imports }) => ({
call: async ({ order, user }) => imports.Send.send({ to: { user }, title: `Order ${order} shipped`, level: 'warn' }),
});@aweftjs/notify/client
Channel
type Channel = 'inbox' | 'email' | 'push';Where a notification can go.
Delivery
type Delivery = { readonly ok: true; readonly [detail: string]: unknown; } | { readonly ok: false; readonly error: string; } | { readonly skipped: string; };What one channel did with one notification.
DeliveryRecord
type DeliveryRecord = Readonly<Partial<Record<Channel, Delivery>>>;The record of a send: one entry per channel tried.
Device
interface Device { readonly platform: 'android' | 'ios' | 'desktop' | 'web'; readonly transport: 'fcm' | 'none'; readonly endpoint: string | null; readonly seenAt: number; }A device a user registered for push.
InboxView
interface InboxView { readonly items: readonly Item[] | undefined; readonly ready: Promise<readonly Item[]>; readonly unread: Derived<number>; read(ids?: readonly string[]): Promise<number>; register(registration: Registration): Promise<number>; forget(device: string): Promise<number>; stop(): void; }A user's inbox over one connection.
Item
interface Item { readonly id: string; readonly at: number; readonly level: Level; readonly title: string; readonly body: string; readonly url: string | null; readonly tag: string | null; readonly readAt: number | null; readonly delivery: string; }One notification as an inbox holds it: primitives only, so a row in the store is a row.
Level
type Level = 'info' | 'warn' | 'error';How urgent a notification is, which picks its channels unless the caller names them.
Registration
interface Registration { readonly device: string; readonly platform: Device['platform']; readonly transport: Device['transport']; readonly endpoint?: string | null | undefined; }What a page registers: the device's own id, what it is, and how it can be woken.
createInbox
createInbox: (client: Client) => InboxViewA user's inbox over a connection.
Params
client: the connection, fromcreateClient, signed in as the user whose inbox this is
Returns items, ready, unread, read, register, forget and stop. ready asks notify/Inbox first, so an anonymous page hears the gate's refusal at once rather than waiting for a topic the server never offers; a page that follows auth.user opens the view once it reads a string. One client carries one live view: calling this again hands back the same one until it is stopped or its ready was refused. Stop it before enter or leave, because the connection after either is another user's, and make a new one after; a view stopped on a socket still open pairs again on the next socket, which enter and leave open.
Example
const inbox = createInbox(client);
inbox.unread.effect((n) => badge.textContent = n === 0 ? '' : String(n));
const items = await inbox.ready;
await inbox.read();Refusals
Every error this package raises carries a reason to switch on and a fix that says what to do. These are its reasons, from errors.txt.
| reason | fix |
|---|---|
anonymous | Sign in first; an anonymous connection has no inbox and no devices. |
capped | Forget one, or raise perUser in the module's config. |
capped | Send fewer to one person, or raise perHour in the module's config. |
invalid-call | Ask with { read: [id, ...] } or { read: 'all' }. |
invalid-config | Give address a function from a user id to their email, or null to read user documents. |
invalid-config | Give each level a list drawn from inbox, email and push. |
invalid-config | Give email { resend: { key, from } }, a function taking the mail, or null. |
invalid-config | Give email.resend a key and a from address. |
invalid-config | Give idleMs at most 2147483647 milliseconds, about 24.8 days. |
invalid-config | Give levels an object of level to channel list, such as { info: ['inbox'] }. |
invalid-config | Give push { fcm: { account } }, a function taking the data and the endpoints, or null. |
invalid-config | Give push.fcm the service account key file as JSON or base64 on one line. |
invalid-config | Give that setting a number above zero. |
invalid-config | Give that setting true or false. |
invalid-device | Register with { device, platform, transport, endpoint }: an id of letters, digits, dashes and underscores, a known platform and transport, and an endpoint under the byte cap. |
invalid-notification | Send { to: { user } | { email }, title, body?, level?: info | warn | error, channels?: [inbox | email | push] }. |
no-store | Pass store to createServer, or send with channels that need none. |
stopped | Make a new view with createInbox; a stopped one follows no connection. |
Recipes
The programs in the stack's gate that use this package, each a job someone would have.
recipes/notify: Two pages of one user hear a send live and mark it read for each other, a device registered from the page, email and push against two fakes, a failed mail kept, a forged write refused, a restart, and a server with no store sending a contact form's mail