@nolag/collab
Real-time collaboration with operations, cursor tracking and idle awareness.
Overview
Add real-time collaboration to an editor or canvas. Operations (insert, delete, replace, format, or custom types of your own) are broadcast to everyone in the document and cached in memory on each client. Cursor positions travel on a separate throttled topic, so other participants see where each user is working. Each client works out idleness for the other participants locally: a remote user who has sent no cursor update for idleTimeout is reported as idle on this client, without anything being broadcast. Your app owns one core NoLag client and injects it into NoLagCollab; the wrapper attaches its behaviour to that connection and never opens or closes the socket.
Key features
- Five operation types: insert, delete, replace, format and custom
- In-memory operation cache per document (
maxOperationCache, default 1000) - Throttled cursor sync
- Locally inferred idle detection for remote users
- Explicit status (
active,idle,viewing) carried in room presence - Optional operation filters per document
How it works
NoLagCollab attaches to the injected client and subscribes to the online lobby. joinDocument(name) returns a CollabDocument: each document is a room, and the document subscribes to two topics in it.
| Topic | Purpose |
|---|---|
operations | Document operations: insert, delete, replace, format, custom |
_cursors | Cursor positions |
Operations and cursor updates are published with echo: false, so the broker does not send your own back to you. An operation you send is added to your local cache and returned; operation fires only for other users' operations.
Outbound cursor updates are throttled to cursorThrottle (default 50 ms): the first call in a window is sent at once, later calls in the same window are merged and the last one is sent when the window ends. For every remote user the document starts an idle timer when they join and resets it on each of their cursor updates; when it elapses the document emits awarenessChanged with status: 'idle'. Nothing is broadcast by that timer, and your own status changes only through setStatus(), which is sent through room presence.
getOperations() returns only what this client has seen since it joined. The broker keeps no operation log, so a late joiner needs the current document state from your own storage.
Topics to grant
The wrapper expects these names in its app (the nolag-collab-sdk blueprint):
| Kind | Names |
|---|---|
| Topics | operations, _cursors (both subscribed in every joined document) |
| Rooms | my-doc, plus every document name you pass to joinDocument() |
| Lobby | online (subscribed on every connect) |
Every participant needs pubSub on both topics in its documents. If the online lobby is not granted, ready() resolves only after the 10 second lobby subscribe timeout.
With kraken's static auth file (format). Presence (users, status) only works in rooms that have a rule carrying room_slug and room_id, and every collaborating token must use the same appId and room_id:
{
"tokens": {
"dev-token-alice": {
"actorTokenId": "alice",
"projectId": "demo-project",
"actorType": "user",
"appId": "collab-app",
"appName": "collab",
"allowedTopics": [
{ "pattern": "collab/my-doc/#", "permission": "pubSub", "room_id": "collab-my-doc", "room_slug": "my-doc" }
],
"allowedLobbies": [{ "lobby_slug": "online", "lobby_id": "collab-online" }]
}
}
}
With a @nolag/core project document (format), as one entry in apps:
{
"slug": "collab",
"name": "Collab",
"accessMode": "open",
"topics": ["operations", "_cursors"],
"rooms": [{ "slug": "my-doc", "name": "My Doc" }],
"lobbies": [{ "slug": "online", "name": "Online", "rooms": ["my-doc"] }]
}
Each document is a room, so a new document must exist as a room (in core, or under a wildcard rule in the static file) before anyone joins it. To keep a document private to its editors, give its room grants in core; see Access Control.
Installation
npm install @nolag/collab @nolag/js-sdk
@nolag/collab peer-depends on @nolag/js-sdk ^1.12.0. Both packages are MIT licensed.
Quick start
import { NoLag } from '@nolag/js-sdk'
import { NoLagCollab } from '@nolag/collab'
// The app owns one core client. `token` is an actor token or a TokenProvider.
const client = NoLag(token, { url: 'ws://localhost:8080/ws' })
const collab = new NoLagCollab({
client,
appName: 'collab', // the App Slug the topics are granted under
username: 'Alice',
color: '#ff8a00',
idleTimeout: 30_000, // report a remote user idle after 30 s without cursor activity
cursorThrottle: 50, // minimum ms between cursor broadcasts (default 50)
})
await client.connect() // the app owns the connection
await collab.ready() // wrapper setup complete
const doc = collab.joinDocument('my-doc')
// Operations from other users (your own are not echoed back)
doc.on('operation', (op) => {
console.log(`${op.username} sent ${op.type} at ${op.position}`, op.content ?? op.data)
})
// insert, delete and replace use position, length and content
doc.sendOperation('insert', { position: 42, content: 'Hello, world!' })
doc.sendOperation('delete', { position: 42, length: 13 })
doc.sendOperation('replace', { position: 0, length: 5, content: 'Hi' })
// format and custom carry their payload in `data`
doc.sendOperation('format', { position: 10, length: 5, data: { bold: true } })
doc.sendOperation('custom', { data: { kind: 'highlight', range: { start: 0, end: 20 } } })
console.log(`${doc.getOperations().length} operations cached`)
// Cursors: line/column for text editors, x/y for canvases
doc.updateCursor({ line: 3, column: 8, selection: { start: 40, end: 55 } })
doc.on('cursorMoved', (cursor) => {
console.log(cursor.username, cursor.line, cursor.column, cursor.color)
})
// Awareness
doc.setStatus('viewing') // 'active' | 'idle' | 'viewing'
doc.on('awarenessChanged', ({ userId, status }) => {
console.log(`User ${userId} is now ${status}`)
})
// Presence
doc.on('userJoined', (user) => console.log('Joined document:', user.username))
doc.on('userLeft', (user) => console.log('Left document:', user.username))
collab.on('userOnline', (user) => console.log('Online:', user.username))
// Teardown: the wrapper releases its handlers; the app closes the socket.
collab.detach()
client.disconnect()
Reconnects
After the core client reconnects, the wrapper subscribes to the online lobby again, re-sends presence for every joined document and emits reconnected. It does not subscribe to its document 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 operations and cursors 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', () => {
for (const doc of collab.getDocuments()) {
const ctx = client.setApp('collab').setRoom(doc.name)
ctx.subscribe('operations', doc.filters.length > 0 ? { filters: doc.filters } : {})
ctx.subscribe('_cursors')
}
})
Operations published while a client was disconnected are not delivered to it later, so reload the document state from your storage after a reconnect if it matters.
Limitations on kraken v0.9.0
- One presence room per connection. kraken keeps a connection's presence in one room at a time. With several documents joined on one connection, only the document whose presence was set last (the last one joined, or the last after a reconnect) shows you as present.
- Presence events are not tagged with a room. The wrapper passes every room presence event to every joined document, so with several documents joined their user lists mix. Use one joined document per connection where accurate per-document user lists matter.
- Documents in the
documentsoption are subscribed but not activated. They receive operations and cursors but do not set your presence until you callsetStatus()on them (or the wrapper re-sends presence after a reconnect). CallingjoinDocument()for a document that is already subscribed does not activate it either. - Lobby. At kraken v0.9.0 a lobby's room list comes from the rooms the subscribing actor can reach and is cached per node for 5 minutes after the last lobby subscribe; after that, lobby events stop until a client subscribes again. See Lobbies.
API reference
NoLagCollab
Constructor options
| Option | Type | Description |
|---|---|---|
client | NoLagSocket | Required. The injected core client the app owns and connects. |
username | string | Required. Display name for the local user. |
appName | string | The App Slug used as the topic prefix (default 'collab'). |
avatar | string | Optional avatar URL. |
color | string | Optional cursor and highlight colour, sent with every cursor update. |
metadata | Record<string, unknown> | Optional custom data carried in presence. |
documents | string[] | Documents to subscribe to once the wrapper is ready. |
maxOperationCache | number | Max operations cached per document (default 1000). |
idleTimeout | number | Ms without a cursor update before a remote user is reported idle on this client (default 60000). |
cursorThrottle | number | Minimum ms between cursor broadcasts (default 50). |
debug | boolean | Enable wrapper debug logging (default false). |
| Method / property | Returns | Description |
|---|---|---|
ready() | Promise<void> | Resolves once the first setup completed. Join methods throw before this. |
detach() | void | Release this wrapper's handlers and topics. Terminal; never closes the socket. |
joinDocument(name, opts?) | CollabDocument | Subscribe to a document, set presence in it and return it; returns the existing instance if already joined. opts.filters limits the operations subscription. |
leaveDocument(name) | void | Unsubscribe from a document and release its resources. |
getDocuments() | CollabDocument[] | All joined documents. |
getOnlineUsers() | CollabUser[] | Users currently online. |
localUser | CollabUser | null | The local user, available after ready(). |
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. |
userOnline | user: CollabUser | A user came online. |
userOffline | user: CollabUser | A user went offline. |
CollabDocument
| Method / property | Returns | Description |
|---|---|---|
sendOperation(type, opts?) | CollabOperation | Broadcast an operation and return it. type is 'insert' | 'delete' | 'replace' | 'format' | 'custom'; opts is { path?, position?, length?, content?, data?, filter?, filters? }. |
getOperations() | CollabOperation[] | Operations seen by this client since it joined, in timestamp order. |
updateCursor(opts) | void | Broadcast the local cursor: { x?, y?, line?, column?, selection?, path? }. Throttled. |
getCursors() | CursorPosition[] | Remote cursor positions from the local cache. |
setStatus(status) | void | Set your status ('active' | 'idle' | 'viewing'), sent through room presence. |
getUsers() / getUser(userId) | CollabUser[] / CollabUser | undefined | Remote users present in the document. |
filters | FilterValue[] | The current operation filters. |
setFilters(values) / addFilters(values) / removeFilters(values) | void | Change the operation filters. An empty set restores the unfiltered subscription. Cursors stay unfiltered. |
Events
| Event | Payload | Description |
|---|---|---|
operation | CollabOperation | Another user broadcast an operation. |
cursorMoved | CursorPosition | Another user's cursor moved. |
userJoined | CollabUser | A user joined the document. |
userLeft | CollabUser | A user left the document. |
awarenessChanged | { userId, status: 'idle' } | A remote user's idle timer elapsed on this client. Explicit setStatus() changes by others do not fire it; read them from getUser(). |
Types
| Type | Shape |
|---|---|
CollabOperation | { id, type, path?, position?, length?, content?, data?, userId, username, timestamp } |
CursorPosition | { userId, username, color?, x?, y?, line?, column?, selection?: { start, end }, path?, timestamp } |
CollabUser | { userId, actorTokenId, username, avatar?, color?, status, metadata?, joinedAt, isLocal } |
UserStatus | 'active' | 'idle' | 'viewing' |