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

TopicPurpose
operationsDocument operations: insert, delete, replace, format, custom
_cursorsCursor 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 it

Installation

npm install @nolag/collab @nolag/js-sdk

Shared 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

OptionTypeDescription
clientNoLagSocketRequired. The injected core NoLag client the app owns and connects.
usernamestringRequired. Display name for the local user.
appNamestringThe 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.
avatarstringOptional avatar URL.
colorstringOptional cursor/highlight colour, sent with every cursor update.
metadataRecord<string, unknown>Optional custom data attached to user presence.
documentsstring[]Documents to auto-join once the wrapper is ready.
maxOperationCachenumberMax operations cached per document (default 1000).
idleTimeoutnumberMs without a cursor update before a remote user is reported idle on this client (default 60000).
cursorThrottlenumberMinimum ms between cursor broadcasts (default 50).
debugbooleanEnable wrapper debug logging (default false).
MethodReturnsDescription
ready()Promise<void>Resolves once wrapper setup completed. Join methods throw before this resolves.
detach()voidRelease this wrapper's handlers and topics; terminal, never closes the socket.
joinDocument(name, opts?)CollabDocumentSubscribe 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)voidUnsubscribe from a document and release its resources.
getDocuments()CollabDocument[]All currently joined documents.
getOnlineUsers()CollabUser[]Users currently present in the online lobby.

NoLagCollab Events

EventPayloadDescription
connectednoneWrapper setup completed on a live connection.
disconnectedreason: stringConnection closed.
reconnectingnoneThe core client is attempting to reconnect.
reconnectednoneConnection restored; document membership and presence are restored automatically.
errorerror: ErrorA transport or protocol error occurred.
userOnlineuser: CollabUserA user joined the lobby.
userOfflineuser: CollabUserA user left the lobby.

CollabDocument

MethodReturnsDescription
sendOperation(type, opts?)CollabOperationBroadcast 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)voidBroadcast 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)voidSet 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 | undefinedOne remote user by ID.
setFilters(values) / addFilters(values) / removeFilters(values)voidChange which operation filter values this document receives. An empty set restores the wildcard subscription.

CollabDocument Events

EventPayloadDescription
operationCollabOperationAnother user broadcast an operation.
cursorMovedCursorPositionAnother user's cursor moved. Delivered via the ephemeral channel.
userJoineduser: CollabUserA user joined this document.
userLeftuser: CollabUserA 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

TypeShape
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'