@nolag/chat
Multi-room chat with presence, typing indicators, unread counts and user mapping.
Overview
@nolag/chat turns a NoLag connection into a chat client. It handles rooms, the users in them, typing indicators and unread counts, all on top of @nolag/js-sdk. Your app owns one core NoLag client and injects it into NoLagChat; the wrapper attaches its chat behaviour to that connection and never opens or closes the socket itself.
Key features
- Several rooms per connection, with one active room holding your presence
- Typing indicators with an automatic timeout
- User mapping: a stable
userId, display name, avatar and metadata per user - Unread counts per room
- Optional subscription filters per room
How it works
NoLagChat attaches to the injected client and subscribes to the online lobby to track who is online. joinRoom(name) returns a ChatRoom that subscribes to three topics in that room. A MessageStore in each room keeps the messages this client has seen, and a presence manager tracks the users in the active room.
| Topic | Purpose |
|---|---|
messages | Finished chat messages: text, optional data, sender info |
_stream | Partial messages published with startStream(); subscribed in every room |
_typing | Typing start and stop signals |
The wrapper publishes every message with echo: false, so the broker does not send your own messages back to you. sendMessage() adds the message to your local store and emits messageSent instead.
A room starts empty for every client. kraken offers clients no message history, so getMessages() returns only what this client received since it joined. Keep the conversation of record in your own database.
Topics to grant
The wrapper expects these names in its app (the nolag-chat-sdk blueprint):
| Kind | Names |
|---|---|
| Topics | messages, _stream, _typing (all three are subscribed in every joined room) |
| Rooms | general, random, help, dev, plus any room you pass to joinRoom() |
| Lobby | online (subscribed on every connect) |
Every actor needs pubSub on all three topics in the rooms it joins. _stream must be granted even if you never call startStream(), because the subscribe is refused otherwise. If the online lobby is not granted, ready() resolves only after the 10 second lobby subscribe timeout, and online-user tracking stays empty.
With kraken's static auth file (format). Give each room its own rule carrying room_slug and room_id; presence only works in rooms that have one. Every token that should talk to the others must use the same appId and the same room_id per room.
{
"tokens": {
"dev-token-alice": {
"actorTokenId": "alice",
"projectId": "demo-project",
"actorType": "user",
"appId": "chat-app",
"appName": "chat",
"allowedTopics": [
{ "pattern": "chat/general/#", "permission": "pubSub", "room_id": "chat-general", "room_slug": "general" },
{ "pattern": "chat/random/#", "permission": "pubSub", "room_id": "chat-random", "room_slug": "random" },
{ "pattern": "chat/help/#", "permission": "pubSub", "room_id": "chat-help", "room_slug": "help" },
{ "pattern": "chat/dev/#", "permission": "pubSub", "room_id": "chat-dev", "room_slug": "dev" }
],
"allowedLobbies": [{ "lobby_slug": "online", "lobby_id": "chat-online" }]
}
}
}
A single chat/+/# rule also grants pub/sub in any room, but gives no presence, because kraken cannot map an arbitrary room slug to a room id.
With a @nolag/core project document (format), as one entry in apps:
{
"slug": "chat",
"name": "Chat",
"accessMode": "open",
"topics": ["messages", "_stream", "_typing"],
"rooms": [
{ "slug": "general", "name": "General" },
{ "slug": "random", "name": "Random" },
{ "slug": "help", "name": "Help" },
{ "slug": "dev", "name": "Dev" }
],
"lobbies": [
{ "slug": "online", "name": "Online", "rooms": ["general", "random", "help", "dev"] }
]
}
Rooms are never created by the broker. A room you pass to joinRoom() must exist in core (or match a wildcard rule in the static file) before anyone uses it; see Rooms.
Installation
npm install @nolag/chat @nolag/js-sdk
@nolag/chat peer-depends on @nolag/js-sdk ^1.12.0. Both packages are MIT licensed.
Quick start
import { NoLag } from '@nolag/js-sdk'
import { NoLagChat } from '@nolag/chat'
// The app owns one core client. `token` is an actor token (for example one
// from kraken's auth.json) or a TokenProvider that fetches a client token.
const client = NoLag(token, { url: 'ws://localhost:8080/ws' })
// Inject the client into the chat wrapper
const chat = new NoLagChat({
client,
appName: 'chat', // the App Slug the topics are granted under
username: 'Alice',
avatar: '/img/alice.png',
})
await client.connect() // the app owns the connection
await chat.ready() // wrapper setup complete (identity, lobby, rooms)
// Join a room. The joined room becomes the active room and holds your presence.
const room = chat.joinRoom('general')
// Messages from other users
room.on('message', (msg) => {
console.log(`[${msg.username}]: ${msg.text}`, new Date(msg.timestamp))
})
// Send a message. It is added to your local store and emitted as `messageSent`;
// the broker does not echo it back.
room.sendMessage('Hello everyone!')
// Typing indicators
room.on('typing', ({ users }) => {
console.log('Typing:', users.map((u) => u.username).join(', '))
})
room.startTyping()
room.stopTyping()
// Users present in the active room, and users online in the lobby
console.log('In room:', room.getUsers().length)
console.log('Online:', chat.getOnlineUsers().length)
// Teardown: the wrapper releases its handlers and topics; the app closes the
// socket (never the other way around).
chat.detach()
client.disconnect()
Shared connection. One core client can back several Blueprint SDKs at once, for example chat, notify and a dashboard on one socket, as long as each wrapper uses a different appName. Each wrapper attaches its handlers on construction and releases them with detach().
Reconnects
After the core client reconnects, the wrapper subscribes to the online lobby again, re-sends presence for the active room and emits reconnected. It does not subscribe to its room 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 room messages stop after a reconnect unless you subscribe again (see Production).
In 1.2.0 the wrapper's message 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, which fires after every successful authentication. Do not use the reconnect event for this: it fires when a retry is scheduled, 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 joined rooms after a reconnect
for (const room of chat.getRooms()) {
const ctx = client.setApp('chat').setRoom(room.name)
const opts = room.filters.length > 0 ? { filters: room.filters } : {}
ctx.subscribe('messages', opts)
ctx.subscribe('_stream', opts)
ctx.subscribe('_typing')
}
})
Messages published while a client was disconnected are not delivered to it later.
Presence and the online lobby
Room presence is set on the active room only, and kraken holds one presence room per connection, which matches how the wrapper uses it. Joining a second room moves your presence there.
Online-user tracking (getOnlineUsers(), userOnline, userOffline) comes from the online lobby. At kraken v0.9.0 a lobby's room list is built from the rooms the subscribing actor itself can reach, cached per broker node for 5 minutes after the last lobby subscribe, after which lobby events stop until some client subscribes to the lobby again. The wrapper subscribes only when it connects, so on a long-lived connection userOffline can stop firing. See Lobbies for the details.
Filters
joinRoom(name, { filters }) and room.setFilters() narrow the messages and _stream subscriptions to messages published with one of the given filter values (sendMessage(text, { filter })). Typing stays unfiltered. Subscribers without filters still receive filtered messages, so a filter is routing, not a privacy boundary: use a separate room with its own grants for private conversations. See Filters.
API reference
NoLagChat
The main class. Attaches to the injected core client and manages global presence and the room lifecycle.
Constructor options
| Option | Type | Description |
|---|---|---|
client | NoLagSocket | Required. The injected core client the app owns and connects. |
username | string | Required. Display name for this user. |
avatar | string | Optional avatar URL. |
metadata | Record<string, unknown> | Optional custom user data carried in presence. |
appName | string | The App Slug used as the topic prefix (default 'chat'). |
rooms | string[] | Rooms to subscribe to once the wrapper is ready (messages only; presence follows the active room). |
typingTimeout | number | Ms before a typing indicator clears itself (default 3000). |
maxMessageCache | number | Max messages kept in memory per room (default 500). |
debug | boolean | Enable wrapper debug logging (default false). |
| Method / property | Returns | Description |
|---|---|---|
ready() | Promise<void> | Resolves once the first setup completed (identity, lobby, configured rooms). Rejects only if detach() is called first. |
detach() | void | Release this wrapper's handlers, topics and lobby. Terminal; never closes the socket. |
joinRoom(name, opts?) | ChatRoom | Join a room and make it the active room. opts.filters sets its filters. Throws before ready() resolves. |
leaveRoom(name) | void | Leave a room and unsubscribe its topics. |
getRooms() | ChatRoom[] | All joined rooms. |
rooms | Map<string, ChatRoom> | Joined rooms by name. |
localUser | ChatUser | null | The local user, available after ready(). |
getOnlineUsers() | ChatUser[] | Users currently online according to the lobby and the active room. |
setStatus(status) | void | Set your status ('online', 'away', 'busy', 'offline'), sent through room presence. |
updateProfile(updates) | void | Update username, avatar or metadata, sent through room presence. |
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 (lobby and presence; see Reconnects). |
error | error: Error | An error from the core client, including broker refusals. |
userOnline | user: ChatUser | A user came online. |
userOffline | user: ChatUser | A user went offline (from the lobby). |
userUpdated | user: ChatUser | A user changed their profile or status. |
ChatRoom
Returned by joinRoom(). Scoped to one room.
| Method / property | Returns | Description |
|---|---|---|
sendMessage(text, opts?) | ChatMessage | Publish a message; returns the local copy. opts is { data?, filter?, filters? }. |
startStream(opts?) | MessageStream | Begin a message that is published in parts; call append(text), then complete() or abort(). |
getMessages() | ChatMessage[] | Messages in the local store, in timestamp order. |
startTyping() / stopTyping() | void | Send a typing signal; it clears itself after typingTimeout. |
typingUsers | ChatUser[] | Users currently typing. |
getUsers() | ChatUser[] | Remote users present in this room (active room only). |
getUser(userId) | ChatUser | undefined | One remote user. |
unreadCount / markRead() | number / void | Unread count while the room is not active, and its reset. |
setFilters(values) / addFilters(values) / removeFilters(values) | void | Change the room's message filters. An empty set restores the unfiltered subscription. |
Events
| Event | Payload | Description |
|---|---|---|
message | ChatMessage | A message from another user arrived. |
messageSent | ChatMessage | Your own message was added to the local store and published. |
userJoined | ChatUser | A user joined this room. |
userLeft | ChatUser | A user left this room. |
typing | { users: ChatUser[] } | The set of users typing changed. |
unreadChanged | { room: string, count: number } | The room's unread count changed. |
streamStart, streamChunk, streamEnd, streamAbort | see the d.ts | Progress of a message another user is publishing with startStream(). |
Types
| Type | Shape |
|---|---|
ChatUser | { userId, actorTokenId, username, avatar?, metadata?, status, joinedAt, isLocal } |
ChatMessage | { id, userId, username, avatar?, text, data?, timestamp, status } |