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

TopicPurpose
notificationsNotification payloads: title, body, icon and custom data
_readRead 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() or markAllRead() receives its own receipt back, so its read or readAll event (and badgeUpdated) 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):

KindNames
Topicsnotifications, _read (both subscribed in every subscribed channel)
Roomsalerts, updates, plus every channel name you pass to subscribe()
Lobbyonline (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

OptionTypeDescription
clientNoLagSocketRequired. The injected core client the app owns and connects.
appNamestringThe App Slug used as the topic prefix (default 'notify').
channelsstring[]Channels to subscribe to once the wrapper is ready.
maxNotificationCachenumberMax notifications kept in memory per channel (default 500).
metadataRecord<string, unknown>Accepted and stored, but not sent anywhere in 1.2.0 (the wrapper sets no presence).
debugbooleanEnable wrapper debug logging (default false).
Method / propertyReturnsDescription
ready()Promise<void>Resolves once the first setup completed. subscribe() throws before this.
detach()voidRelease this wrapper's handlers and topics. Terminal; never closes the socket.
subscribe(channel, opts?)NotifyChannelSubscribe to a channel (idempotent). opts.filters limits delivery to matching notifications.
unsubscribe(channel)voidUnsubscribe from a channel and drop it from badge counts.
channelsMap<string, NotifyChannel>Subscribed channels by name.
getBadgeCounts()BadgeCounts{ total, byChannel }: unread counts per channel and in total.
markAllRead()voidMark every notification in every subscribed channel read.

Events

EventPayloadDescription
connectednoneFirst setup completed.
disconnectedreason: stringThe connection closed.
reconnectingnoneThe core client scheduled a reconnect attempt.
reconnectednoneSetup ran again after a reconnect (see Reconnects).
errorerror: ErrorAn error from the core client, including broker refusals.
notificationNotificationA notification arrived on any subscribed channel (notification.channel says which).
badgeUpdatedBadgeCountsUnread counts changed.

NotifyChannel

Method / propertyReturnsDescription
send(title, opts?)voidPublish a notification; opts is { body?, icon?, data?, filter?, filters? }.
markRead(id)voidMark one notification read and publish a receipt.
markAllRead()voidMark every notification in the channel read and publish a receipt.
getNotifications()Notification[]Notifications in the local store.
getUnread()Notification[]Unread notifications only.
unreadCountnumberThe number of unread notifications.
filtersFilterValue[]The current notification filters.
setFilters(values) / addFilters(values) / removeFilters(values)voidChange the notification filters. An empty set restores the unfiltered subscription.

A Notification has id, channel, title, body?, icon?, data?, timestamp and read.

Events

EventPayloadDescription
notificationNotificationA notification arrived on this channel.
readid: stringA notification was marked read (locally or by a receipt from the room).
readAllnoneAll notifications in the channel were marked read (locally or by a receipt).