@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 stored for 24-hour replay. Cursor positions are synchronised via a throttled ephemeral channel so other participants see where each user is working without generating durable storage traffic. Users are automatically marked idle by the SDK after a configurable period of no cursor activity, and marked editing again on the next cursor update. 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
  • 24-hour operation history with replay on reconnect
  • Throttled ephemeral cursor sync with configurable interval
  • Automatic idle detection after configurable inactivity timeout
  • 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 durable operation history and _cursors for ephemeral cursor position updates. The SDK throttles outbound cursor messages to cursorThrottle (default 50 ms) and starts an idle timer that resets on every cursor update. When the timer fires the SDK emits awarenessChanged locally and broadcasts the idle status to other participants. The app owns the socket lifecycle; the wrapper never opens or closes it.

TopicPurposeReplay
operationsDocument operations: insert, delete, replace, format, custom24 hours
_cursorsReal-time cursor positions and awareness status (not persisted)Ephemeral

Installation

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

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,
  username: 'Alice',
  idleTimeout: 30_000,       // Mark user idle after 30 s without cursor activity
  cursorThrottle: 50,        // Throttle cursor updates (default 50 ms)
})

await client.connect()   // the app owns the connection
await collab.ready()     // wrapper setup complete

// Join a document (each document is a separate collaboration session)
const doc = await collab.joinDocument('doc-proposal-q3')

// ── Operations ────────────────────────────────────────────────────────────────

// Send an insert operation
doc.sendOperation('insert', {
  position: 42,
  content: 'Hello, world!',
})

// Send a delete operation
doc.sendOperation('delete', {
  position: 42,
  length: 13,
})

// Send a format operation
doc.sendOperation('format', {
  position: 10,
  length: 5,
  attributes: { bold: true },
})

// Send a custom operation (any domain-specific action)
doc.sendOperation('custom', {
  type: 'highlight',
  color: '#ffcc00',
  range: { start: 0, end: 20 },
})

// Retrieve stored operations (replayed from last 24 hours on reconnect)
const ops = await doc.getOperations()
console.log(`${ops.length} operations in history`)

// React to operations from other users
doc.on('operation', ({ userId, type, opts, timestamp }) => {
  console.log(`User ${userId} sent ${type} op`, opts)
})

// ── Cursors ───────────────────────────────────────────────────────────────────

// Broadcast cursor position (throttled to cursorThrottle)
doc.updateCursor({ position: 42, line: 3, column: 8 })

// Read all cursors from local cache
const cursors = doc.getCursors()
console.log('Active cursors:', cursors.length)

// React to cursor moves from other users
doc.on('cursorMoved', ({ userId, position, line, column }) => {
  renderCursor(userId, { line, column })
})

// ── Awareness ─────────────────────────────────────────────────────────────────

// Update own status
doc.setStatus('editing')  // 'editing' | 'idle' | 'away'

// React to status changes (idle is set automatically by the SDK)
doc.on('awarenessChanged', ({ userId, status }) => {
  console.log(`User ${userId} is now ${status}`)
})

// ── Presence ──────────────────────────────────────────────────────────────────

collab.on('userOnline', ({ userId }) => console.log('Joined lobby:', userId))
collab.on('userOffline', ({ userId }) => console.log('Left lobby:', userId))

doc.on('userJoined', ({ userId }) => console.log('Joined document:', userId))
doc.on('userLeft', ({ userId }) => console.log('Left document:', userId))

// ── 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.
avatarstringOptional avatar URL.
colorstringOptional cursor/highlight colour.
metadataRecord<string, unknown>Optional custom data attached to user presence.
appNamestringNoLag app for topic prefixes (default 'collab').
documentsstring[]Documents to auto-join once the wrapper is ready.
maxOperationCachenumberMax operations cached per document (default 1000).
idleTimeoutnumberMs of inactivity before a user is marked idle (default 60000).
cursorThrottlenumberMinimum ms between cursor broadcasts (default 50).
debugbooleanEnable wrapper debug logging (default false).
MethodReturnsDescription
ready()Promise<void>Resolves once wrapper setup completed.
detach()voidRelease this wrapper's handlers and topics; terminal, never closes the socket.
joinDocument(name)Promise<CollabDocument>Subscribe to a collaboration document. Returns the document instance.
leaveDocument(name)Promise<void>Unsubscribe from a document and release its resources.

NoLagCollab Events

EventPayloadDescription
connectednoneWebSocket connection established.
disconnectedreason: stringConnection closed.
reconnectednoneConnection restored; operation history replay begins automatically.
errorerror: ErrorA transport or protocol error occurred.
userOnline{ userId: string }A user joined the lobby.
userOffline{ userId: string }A user left the lobby.

CollabDocument

MethodReturnsDescription
sendOperation(type, opts?)voidBroadcast an operation. type is 'insert' | 'delete' | 'replace' | 'format' | 'custom'. opts is type-specific.
getOperations()Promise<Operation[]>Fetch the stored operation history for this document (up to 24 hours).
updateCursor(opts)voidBroadcast current cursor position. Automatically throttled. Resets the idle timer.
getCursors()Cursor[]Return cursor positions for all active users from the local cache.
setStatus(status)voidManually set awareness status: 'editing' | 'idle' | 'away'. Broadcast to all participants.

CollabDocument Events

EventPayloadDescription
operation{ userId, type, opts?, timestamp }An operation was broadcast by another user.
cursorMoved{ userId, position, line?, column?, timestamp }A user's cursor position changed. Delivered via the ephemeral channel.
userJoined{ userId: string }A user joined this document.
userLeft{ userId: string }A user left this document.
awarenessChanged{ userId, status: 'editing' | 'idle' | 'away' }A user's awareness status changed. Fired locally when the idle timer elapses.
replayStart{ count: number }Operation history replay has begun.
replayEnd{ replayed: number }Operation history replay has completed.