@nolag/notify
Live notifications with channels, read and unread tracking, and badge counts.
Overview
@nolag/notify delivers notifications to connected users with a read and unread lifecycle. Notifications are grouped into channels (such as alerts, mentions or system), and each channel keeps its own unread count. Your app owns one core NoLag client and injects it into NoLagNotify; the wrapper attaches its behaviour to that connection and never opens or closes the socket.
Key features
- Channel-based delivery with an unread count per channel
- Per-notification and bulk read tracking
- Badge counts aggregated across all subscribed channels
- Payloads with title, body, icon and custom data
- Optional subscription filters per channel
How it works
NoLagNotify attaches to the injected client and subscribes to the online lobby. subscribe(channel) returns a NotifyChannel: each channel is a room, and the channel subscribes to two topics in it.
| Topic | Purpose |
|---|---|
notifications | Notification payloads: title, body, icon and custom data |
_read | Read receipts (markRead() and markAllRead()) |
A NotificationStore in each channel keeps the notifications this client received and their read state, and the main class adds the unread counts up into badge counts.
A notification reaches the users who are connected and subscribed when it is published. kraken offers clients no message history and queues nothing for offline users, so keep the notifications of record in your own database and use the channel for live delivery.
What kraken does with your own sends and receipts
The wrapper publishes notifications and read receipts with the default echo: true. kraken v0.9.0 delivers a publish back to the publishing connection when it is subscribed, so:
- A client that calls
send()on a channel it is subscribed to receives its own notification, and it counts as unread there like any other. - A client that calls
markRead()ormarkAllRead()receives its own receipt back, so itsreadorreadAllevent (andbadgeUpdated) fires twice.
Read state is shared by the room
Read receipts go to the _read topic of the channel's room, and every subscriber of that room applies them. If several users share one channel room, one user marking a notification read marks it read for all of them. For per-user read state, give each user their own room (for example user-alice) and send their notifications there.
Topics to grant
The wrapper expects these names in its app (the nolag-notify-sdk blueprint):
| Kind | Names |
|---|---|
| Topics | notifications, _read (both subscribed in every subscribed channel) |
| Rooms | alerts, updates, plus every channel name you pass to subscribe() |
| Lobby | online (subscribed on every connect) |
Receivers need subscribe on notifications and pubSub on _read (they publish read receipts). A sender that uses the wrapper calls subscribe() first, which subscribes to both topics, so it needs pubSub on the channels it sends to. The 1.2.0 wrapper exposes nothing from the online lobby, but it subscribes to it on every connect, and if the lobby is not granted ready() resolves only after the 10 second lobby subscribe timeout.
With kraken's static auth file (format). Every sender and receiver token must use the same appId:
{
"tokens": {
"dev-token-alice": {
"actorTokenId": "alice",
"projectId": "demo-project",
"actorType": "user",
"appId": "notify-app",
"appName": "notify",
"allowedTopics": [
{ "pattern": "notify/alerts/#", "permission": "pubSub", "room_id": "notify-alerts", "room_slug": "alerts" },
{ "pattern": "notify/updates/#", "permission": "pubSub", "room_id": "notify-updates", "room_slug": "updates" },
{ "pattern": "notify/user-alice/#", "permission": "pubSub", "room_id": "notify-user-alice", "room_slug": "user-alice" }
],
"allowedLobbies": [{ "lobby_slug": "online", "lobby_id": "notify-online" }]
}
}
}
A backend sender can hold a single { "pattern": "notify/+/#", "permission": "pubSub" } rule to reach every channel, including per-user rooms.
With a @nolag/core project document (format), as one entry in apps:
{
"slug": "notify",
"name": "Notify",
"accessMode": "open",
"topics": ["notifications", "_read"],
"rooms": [
{ "slug": "alerts", "name": "Alerts" },
{ "slug": "updates", "name": "Updates" }
],
"lobbies": [{ "slug": "online", "name": "Online", "rooms": ["alerts", "updates"] }]
}
For per-user rooms in core, add one room per user and grant it to that user (a room with any grant is private to its grant holders); see Access Control.
Installation
npm install @nolag/notify @nolag/js-sdk
@nolag/notify peer-depends on @nolag/js-sdk ^1.12.0. Both packages are MIT licensed.
Quick start
import { NoLag } from '@nolag/js-sdk'
import { NoLagNotify } from '@nolag/notify'
// The app owns one core client. `token` is an actor token or a TokenProvider.
const client = NoLag(token, { url: 'ws://localhost:8080/ws' })
const notify = new NoLagNotify({
client,
appName: 'notify', // the App Slug the topics are granted under
})
await client.connect() // the app owns the connection
await notify.ready() // wrapper setup complete
// Subscribe to a channel
const channel = notify.subscribe('alerts')
// New notifications; mark each one read once it is shown
channel.on('notification', (n) => {
console.log(`[${n.title}]: ${n.body}`)
channel.markRead(n.id)
})
// Badge counts across all channels
notify.on('badgeUpdated', ({ total, byChannel }) => {
console.log('Total unread:', total, byChannel)
})
// Mark everything read, in one channel or in all of them
channel.markAllRead()
notify.markAllRead()
// Send a notification (usually from a backend actor on the same app)
channel.send('Deploy finished', { body: 'v2.4.0 is live', data: { version: '2.4.0' } })
// Teardown: the wrapper releases its handlers; the app closes the socket.
notify.detach()
client.disconnect()
Reconnects
After the core client reconnects, the wrapper subscribes to the online lobby again and emits reconnected. It does not subscribe to its channel topics again: it assumes the broker restored them. kraken v0.9.0 restores subscriptions on reconnect only from what its auth backend returns, and the static auth file returns none, so on a static-auth kraken notifications stop after a reconnect unless you subscribe again (see Production).
In 1.2.0 the wrapper's handlers are registered on the core client by topic and survive the reconnect; only the broker-side subscriptions are gone. Re-issue them from a connect handler on the core client (not reconnect, which fires before the socket is back). kraken treats a repeated subscribe as a replacement, so this is also safe where the backend did restore them.
client.on('connect', () => {
// Empty on the first connect; holds the subscribed channels after a reconnect
for (const channel of notify.channels.values()) {
const ctx = client.setApp('notify').setRoom(channel.name)
ctx.subscribe('notifications', channel.filters.length > 0 ? { filters: channel.filters } : {})
ctx.subscribe('_read')
}
})
Notifications sent while a user was disconnected are not delivered to them later; load missed ones from your database after a reconnect.
Filters
subscribe(channel, { filters }) and channel.setFilters() narrow the notifications subscription to notifications sent with one of the given filter values (send(title, { filter })). Read receipts stay unfiltered. Subscribers without filters still receive filtered notifications, so a filter such as a user id routes traffic but does not keep it private: use per-user rooms with their own grants for that. See Filters.
API reference
NoLagNotify
Constructor options
| Option | Type | Description |
|---|---|---|
client | NoLagSocket | Required. The injected core client the app owns and connects. |
appName | string | The App Slug used as the topic prefix (default 'notify'). |
channels | string[] | Channels to subscribe to once the wrapper is ready. |
maxNotificationCache | number | Max notifications kept in memory per channel (default 500). |
metadata | Record<string, unknown> | Accepted and stored, but not sent anywhere in 1.2.0 (the wrapper sets no presence). |
debug | boolean | Enable wrapper debug logging (default false). |
| Method / property | Returns | Description |
|---|---|---|
ready() | Promise<void> | Resolves once the first setup completed. subscribe() throws before this. |
detach() | void | Release this wrapper's handlers and topics. Terminal; never closes the socket. |
subscribe(channel, opts?) | NotifyChannel | Subscribe to a channel (idempotent). opts.filters limits delivery to matching notifications. |
unsubscribe(channel) | void | Unsubscribe from a channel and drop it from badge counts. |
channels | Map<string, NotifyChannel> | Subscribed channels by name. |
getBadgeCounts() | BadgeCounts | { total, byChannel }: unread counts per channel and in total. |
markAllRead() | void | Mark every notification in every subscribed channel read. |
Events
| Event | Payload | Description |
|---|---|---|
connected | none | First setup completed. |
disconnected | reason: string | The connection closed. |
reconnecting | none | The core client scheduled a reconnect attempt. |
reconnected | none | Setup ran again after a reconnect (see Reconnects). |
error | error: Error | An error from the core client, including broker refusals. |
notification | Notification | A notification arrived on any subscribed channel (notification.channel says which). |
badgeUpdated | BadgeCounts | Unread counts changed. |
NotifyChannel
| Method / property | Returns | Description |
|---|---|---|
send(title, opts?) | void | Publish a notification; opts is { body?, icon?, data?, filter?, filters? }. |
markRead(id) | void | Mark one notification read and publish a receipt. |
markAllRead() | void | Mark every notification in the channel read and publish a receipt. |
getNotifications() | Notification[] | Notifications in the local store. |
getUnread() | Notification[] | Unread notifications only. |
unreadCount | number | The number of unread notifications. |
filters | FilterValue[] | The current notification filters. |
setFilters(values) / addFilters(values) / removeFilters(values) | void | Change the notification filters. An empty set restores the unfiltered subscription. |
A Notification has id, channel, title, body?, icon?, data?, timestamp and read.
Events
| Event | Payload | Description |
|---|---|---|
notification | Notification | A notification arrived on this channel. |
read | id: string | A notification was marked read (locally or by a receipt from the room). |
readAll | none | All notifications in the channel were marked read (locally or by a receipt). |