@nolag/collab
Real-time collaboration with operations, cursor tracking, and idle awareness.
Overview
Add real-time collaboration to any editor or canvas. Operations (insert, delete, replace, format, or custom domain types) are broadcast to all document participants and cached in memory on each client. Cursor positions are synchronised via a throttled ephemeral channel so other participants see where each user is working without generating durable storage traffic. Each client infers 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. Each joined document is an independent collaboration session; join multiple documents for multi-tab or split-pane editors. Your app owns one core NoLag client and injects it into NoLagCollab; the wrapper attaches its behaviour to that connection.
Key Features
- Five built-in operation types: insert, delete, replace, format, and custom
- In-memory operation cache per document (
maxOperationCache, default 1000) - Throttled ephemeral cursor sync with configurable interval
- Locally inferred idle detection for remote users after a configurable timeout
- Explicit status (
active,idle,viewing) carried in room presence - User online/offline presence via lobby
- Per-document user join/leave events
- Multiple documents per connection
How It Works
NoLagCollab attaches to an injected @nolag/js-sdk client and maintains a lobby for user presence. Calling joinDocument(name) returns a CollabDocument that subscribes to two topics: operations for document operations and _cursors for cursor position updates. Publishers never receive their own messages: an operation you send is added to your local cache and returned to you, and 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 coalesced 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' locally. Nothing is broadcast by that timer, and your own status never changes on its own. setStatus() is the only way to change the local user's status; it is sent through room presence, and other clients read it from getUser() and getUsers(). The app owns the socket lifecycle; the wrapper never opens or closes it.
| Topic | Purpose |
|---|---|
operations | Document operations: insert, delete, replace, format, custom |
_cursors | Cursor positions, not persisted |
A fresh subscribe or an ordinary reconnect never replays history, and this SDK never triggers replay; see Replay for the one case where the broker replays messages. getOperations() returns only what this client has seen since it joined.
Before you start. Create an app from the nolag-collab-sdk blueprint. That seeds the rooms this SDK expects (my-doc) and the online lobby, and returns an app slug with a random suffix. That slug is the appName you pass to the wrapper. You can also do this in the portal: Apps, New App, pick the blueprint.
import { NoLagApi } from "@nolag/js-sdk";
const api = new NoLagApi(process.env.NOLAG_API_KEY); // nlg_live_...
const app = await api.apps.create({ name: "My App", blueprintId: "nolag-collab-sdk" });
console.log(app.slug); // e.g. "my-app-a3f9": this is your appName
const actor = await api.actors.create({ name: "web-client", actorType: "user" });
console.log(actor.accessToken); // shown once; keep itInstallation
npm install @nolag/collab @nolag/js-sdkShared connection. One core NoLag client can back several wrapper SDKs at once, for example collaboration, chat, and notify on a single socket, as long as each wrapper uses a distinct appName. Each wrapper attaches its handlers on construction and releases them with detach(), and never touches the socket itself. Your app owns connect() and disconnect().
Quick Start
import { NoLag } from '@nolag/js-sdk'
import { NoLagCollab } from '@nolag/collab'
// The app owns one core client. In a browser, pass a token provider so the
// SDK can mint fresh short-lived client tokens from your backend.
const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token)
// Inject the client into the collab wrapper
const collab = new NoLagCollab({
client,
appName: APP_SLUG, // the slug returned when you created the app
username: 'Alice',
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
// Join the seeded document (each document is a separate collaboration session)
const doc = collab.joinDocument('my-doc')
// Operations
// React to 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', color: '#ffcc00', range: { start: 0, end: 20 } },
})
// Operations this client has seen since joining (in-memory, synchronous)
const ops = doc.getOperations()
console.log(`${ops.length} operations cached`)
// Cursors
// Broadcast the local cursor (throttled). Use line/column for text editors,
// x/y for canvases, selection for ranges, path for multi-file documents.
doc.updateCursor({ line: 3, column: 8, selection: { start: 40, end: 55 } })
// Remote cursors from the local cache
const cursors = doc.getCursors()
console.log('Remote cursors:', cursors.length)
doc.on('cursorMoved', (cursor) => {
renderCursor(cursor.userId, { line: cursor.line, column: cursor.column, color: cursor.color })
})
// Awareness
// Set your own status; it travels in room presence
doc.setStatus('viewing') // 'active' | 'idle' | 'viewing'
// Fires when a remote user's idle timer elapses on this client
doc.on('awarenessChanged', ({ userId, status }) => {
console.log(`User ${userId} is now ${status}`)
})
// A remote user's explicit status is read from the user record
console.log(doc.getUsers().map((u) => `${u.username}: ${u.status}`))
// Presence
collab.on('userOnline', (user) => console.log('Joined lobby:', user.username))
collab.on('userOffline', (user) => console.log('Left lobby:', user.username))
doc.on('userJoined', (user) => console.log('Joined document:', user.username))
doc.on('userLeft', (user) => console.log('Left document:', user.username))
// Teardown
// The wrapper releases its handlers; the app closes the socket.
collab.detach()
client.disconnect()API Reference
NoLagCollab
Constructor Options
| Option | Type | Description |
|---|---|---|
client | NoLagSocket | Required. The injected core NoLag 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. Pass the suffixed slug returned when you created the app; the default 'collab' will not match a hosted app. |
avatar | string | Optional avatar URL. |
color | string | Optional cursor/highlight colour, sent with every cursor update. |
metadata | Record<string, unknown> | Optional custom data attached to user presence. |
documents | string[] | Documents to auto-join 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 | Returns | Description |
|---|---|---|
ready() | Promise<void> | Resolves once wrapper setup completed. Join methods throw before this resolves. |
detach() | void | Release this wrapper's handlers and topics; terminal, never closes the socket. |
joinDocument(name, opts?) | CollabDocument | Subscribe to a document and return it. Synchronous; returns the existing instance if already joined. opts.filters limits the operations subscription to those filter values. |
leaveDocument(name) | void | Unsubscribe from a document and release its resources. |
getDocuments() | CollabDocument[] | All currently joined documents. |
getOnlineUsers() | CollabUser[] | Users currently present in the online lobby. |
NoLagCollab Events
| Event | Payload | Description |
|---|---|---|
connected | none | Wrapper setup completed on a live connection. |
disconnected | reason: string | Connection closed. |
reconnecting | none | The core client is attempting to reconnect. |
reconnected | none | Connection restored; document membership and presence are restored automatically. |
error | error: Error | A transport or protocol error occurred. |
userOnline | user: CollabUser | A user joined the lobby. |
userOffline | user: CollabUser | A user left the lobby. |
CollabDocument
| Method | 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? }. Anything else belongs in data. |
getOperations() | CollabOperation[] | Operations cached on this client since it joined, in timestamp order (up to maxOperationCache). |
updateCursor(opts) | void | Broadcast the local cursor. opts is { x?, y?, line?, column?, selection?, path? }. Throttled to cursorThrottle. |
getCursors() | CursorPosition[] | Remote cursor positions from the local cache. |
setStatus(status) | void | Set the local user's status: 'active' | 'idle' | 'viewing'. Sent through room presence. |
getUsers() | CollabUser[] | Remote users present in this document, with their current status. |
getUser(userId) | CollabUser | undefined | One remote user by ID. |
setFilters(values) / addFilters(values) / removeFilters(values) | void | Change which operation filter values this document receives. An empty set restores the wildcard subscription. |
CollabDocument Events
| Event | Payload | Description |
|---|---|---|
operation | CollabOperation | Another user broadcast an operation. |
cursorMoved | CursorPosition | Another user's cursor moved. Delivered via the ephemeral channel. |
userJoined | user: CollabUser | A user joined this document. |
userLeft | user: CollabUser | A user left this document. |
awarenessChanged | { userId, status: 'idle' } | A remote user's idle timer elapsed on this client. Explicit setStatus() changes by other users do not fire this event; read them from getUser(). |
Types
| Type | Shape |
|---|---|
CollabOperation | { id, type, path?, position?, length?, content?, data?, userId, username, timestamp, isReplay } |
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' |