Skip to content

Collaboration Plugin

The CollaborationPlugin enables real-time multi-user editing using Operational Transformation (OT). Local cell edits are captured, transformed against concurrent remote operations, and broadcast to all connected clients. A companion WebSocket relay server handles operation ordering and cursor relay.

Client A Server Client B
───────── ────── ─────────
edit cell ──→ op ─────────→ transform ──→ broadcast ──→ apply op
assign rev ack sender
store history

Each client maintains a local pending queue. The server assigns revision numbers and transforms operations against its history. Clients transform incoming remote operations against their pending queue before applying.

Terminal window
npm install @witqq/spreadsheet @witqq/spreadsheet-plugins
import { CollaborationPlugin, WebSocketTransport, RemoteCursorLayer } from '@witqq/spreadsheet-plugins';
const cursorLayer = new RemoteCursorLayer();
const transport = new WebSocketTransport({
url: 'ws://localhost:3151',
onInit: ({ clientId, color, revision, cursors }) => {
console.log(`Connected as ${clientId} (${color}) at revision ${revision}`);
// Initialize existing cursors
for (const c of cursors) {
if (c.cursor) cursorLayer.setCursor(c.clientId, { ...c, row: c.cursor.row, col: c.cursor.col });
}
},
onCursor: (info) => {
if (info.cursor) {
cursorLayer.setCursor(info.clientId, { ...info, row: info.cursor.row, col: info.cursor.col });
} else {
cursorLayer.removeCursor(info.clientId);
}
engine.requestRender();
},
onLeave: ({ clientId }) => {
cursorLayer.removeCursor(clientId);
engine.requestRender();
},
});
await transport.connect();
const collab = new CollaborationPlugin({
clientId: 'unique-client-id',
transport,
cursorLayer,
sendCursor: (cursor) => transport.sendCursor(cursor),
});
engine.installPlugin(collab);
interface CollaborationPluginConfig {
clientId: string;
transport: OTTransport;
cursorLayer?: RemoteCursorLayer;
sendCursor?: (cursor: { row: number; col: number } | null) => void;
}
OptionTypeDescription
clientIdstringUnique client identifier
transportOTTransportTransport implementation (WebSocket or mock)
cursorLayerRemoteCursorLayerOptional render layer for remote cursor display
sendCursorfunctionCallback to send cursor position updates

Three operation types cover all spreadsheet collaboration scenarios:

type OTOperation = SetCellValueOp | InsertRowOp | DeleteRowOp;
interface SetCellValueOp {
type: 'setCellValue';
row: number;
col: number;
value: unknown;
oldValue?: unknown;
}
interface InsertRowOp {
type: 'insertRow';
row: number;
count: number;
}
interface DeleteRowOp {
type: 'deleteRow';
row: number;
count: number;
}

Operations are wrapped in VersionedOperation for transport:

interface VersionedOperation {
clientId: string;
revision: number;
op: OTOperation;
}

The OT engine handles all 9 operation pairs (3×3 matrix):

import { transform, transformAgainstAll } from '@witqq/spreadsheet-plugins';
// Transform opA against opB → [opA', opB']
const [aPrime, bPrime] = transform(opA, opB);
// Transform local op against a list of server ops
const transformed = transformAgainstAll(localOp, serverOps);

Transform rules:

opA × opBBehavior
setCellValue × setCellValueSame cell → last-writer-wins (opB wins); different cells → no conflict
setCellValue × insertRowCell row ≥ insert row → shift down by insert count
setCellValue × deleteRowCell in deleted range → no-op; cell below → shift up
insertRow × insertRowEarlier insert shifts later insert down
insertRow × deleteRowAdjusts positions based on relative ranges
deleteRow × deleteRowOverlapping ranges → compute remaining deletions

Invariant: apply(apply(state, opA), opB') === apply(apply(state, opB), opA')

interface OTTransport {
send(op: VersionedOperation): void;
onReceive(handler: (op: VersionedOperation) => void): void;
onAck(handler: (revision: number) => void): void;
disconnect(): void;
}

Connects to the collaboration relay server:

interface WebSocketTransportConfig {
url: string;
onInit?: (data: { clientId: string; color: string; revision: number; cursors: CursorInfo[] }) => void;
onCursor?: (info: CursorInfo) => void;
onJoin?: (info: { clientId: string; color: string; name: string }) => void;
onLeave?: (info: { clientId: string }) => void;
}
MethodDescription
connect()Establish WebSocket connection (async)
send(op)Send operation to server
sendCursor(cursor)Send cursor position update
disconnect()Close connection and clean up
getClientId()Get assigned client ID

For testing — connects two clients directly without a server:

const [transportA, transportB] = MockTransport.createPair();
// Operations sent by A are received by B (and vice versa)
// Each transport auto-acks with incrementing revision numbers

Canvas render layer that displays colored cell overlays for remote users:

const cursorLayer = new RemoteCursorLayer();
// Set or update a remote cursor
cursorLayer.setCursor('client-2', {
clientId: 'client-2',
color: '#3498db',
name: 'Bob',
row: 5,
col: 3,
});
// Remove when user leaves
cursorLayer.removeCursor('client-2');
// Get all active cursors
const cursors: RemoteCursor[] = cursorLayer.getCursors();

Each cursor renders as:

  • Semi-transparent colored fill over the cell (20% opacity)
  • 2px solid colored border
  • Name label above the cell with colored background
MethodDescription
getPendingCount()Number of unacknowledged local operations
getRevision()Current server revision number

The relay server (packages/server/) manages operation ordering, transformation, cursor relay, and session lifecycle.

import { createCollabServer } from '@witqq/spreadsheet-server';
const { wss, close } = createCollabServer(3151);
// Server listens on ws://localhost:3151

Or via Docker:

Terminal window
npm run dev # Starts both app (3150) and collab server (3151)

Returns { wss: WebSocketServer, close: () => void }.

The server:

  1. Assigns each connecting client a unique ID and color (rotating through 8 colors)
  2. Sends init message with client info, current revision, and active cursors
  3. Broadcasts join/leave events to other clients

When the server receives an operation:

  1. Transforms it against all operations since the client’s revision
  2. Assigns a new revision number
  3. Stores in history
  4. Sends ack to the sender with the new revision
  5. Broadcasts the transformed operation to all other clients

If transformation results in a no-op, the server still sends an ack.

Cursor updates are relayed to all other clients without transformation. Each cursor message includes clientId, color, name, and cursor position.

When the last client disconnects, the server resets its state:

  • Clears operation history
  • Resets revision counter to 1

This ensures a clean state for the next session.

TypeDirectionFields
initServer → ClientclientId, color, revision, cursors
opBoth directionsclientId, revision, op
ackServer → Clientrevision
cursorBoth directionsclientId, color, name, cursor
joinServer → ClientclientId, color, name
leaveServer → ClientclientId