[]previous and next page

Collaboration

Live editing between people and devices. How merging works, how to connect, presence and follow mode, offline edits, and running your own server.

Two editors joined by a transport, live on this page:

A live CanvasFlow editor runs here when JavaScript is on.

Two editors on one document, synced through an in-memory transport. Draw in either.

Connect in one line#

ts
import { connect } from 'canvasflow/collab'

const client = connect(editor, { room: 'board-42' }) // every tab on this device
const shared = connect(editor, { room: 'board-42', url: 'wss://sync.example.com' }) // over the network

Without a url, tabs on the same device sync through a BroadcastChannel. With one, the client connects to a WebSocket server at url/room. Either way you get a CollabClient; call client.stop() to disconnect.

How merging works#

Collaboration in CanvasFlow is built on a CRDT, a data structure where every replica can accept edits independently and still end up identical. In practice that means:

  • Edits merge field by field. If you change a shape's color while someone moves it, both changes survive. Nobody's edit is lost to a "last save wins".
  • Deletes win. If you delete a shape while someone edits it, it stays deleted for everyone, instead of half-coming back.
  • Z-order never conflicts. Shapes are ordered with fractional indexes, so two people reordering at once can't produce a tie.
  • Undo only undoes your own changes. Undo after a collaborator's edit takes back your last change, not theirs.
  • Offline edits merge later. Changes made without a connection are queued and merged when it returns.

The engine behind this, SyncDoc, is part of the core. You never use it directly unless you're writing a server.

The client#

ts
import { CollabClient, WebSocketTransport } from 'canvasflow/collab'

const client = new CollabClient(editor, new WebSocketTransport('wss://sync.example.com/board-42'), {
  room: 'board-42',
  user: { name: 'Maya', color: '#7c5cff' },
  cache: myStorageAdapter, // offline cache, survives restarts
}).start()

client.status // 'connecting' | 'online' | 'offline'
client.onChange(() => updateStatusBadge(client.status))
client.setUser({ name: 'Maya K.' })
Option What it does
room Which document to join
user { id, name, color } shown to others. Missing fields are generated
cache A storage adapter that keeps the replica (with merge info) for offline use
presenceInterval How often presence is sent, in milliseconds
peerTimeout When a silent peer is considered gone

Presence#

Presence is what makes a shared board feel alive: cursors, names, selections, the laser pointer. It's sent separately from the document and never saved.

ts
import { createPresenceAvatars, createPresenceLayer } from 'canvasflow/collab'

app.addOverlayLayer(createPresenceLayer(client), 10) // cursors, names, selections, carets
header.append(createPresenceAvatars(client, editor)) // who's here; click someone to follow them

client.peers // Map of everyone connected, with their presence
client.follow(peerId) // keep your camera on theirs until you move

Call client.setCursorInside(false) when the pointer leaves your canvas so others don't see a frozen cursor.

The presence layer redraws whenever a peer moves, even while nothing changes on your own canvas, and eases each cursor between the positions the network delivers, so cursors glide instead of hopping. createPresenceLayer(client, { smoothing: 0 }) turns the easing off. A peer who jumps across the board is not dragged across it: the cursor moves there at once.

Run a server#

The package includes a small WebSocket relay with no dependencies. Each room keeps a merged replica, so late joiners get the full document at once, and rooms are saved to disk.

sh
npx canvasflow-server --port 8787 --data ./canvasflow-data

Or start it from your own Node code:

server.tsts
import { startServer } from 'canvasflow/server'

startServer({ port: 8787, dataDir: './rooms', maxPayload: 4_000_000 })

Clients connect to ws://host:8787/<room>. Put it behind your own proxy for TLS and authentication.

Other transports#

A transport is five methods, so anything that can deliver messages can carry a CanvasFlow document: WebRTC, a message bus, your existing realtime service.

ts
interface Transport {
  connect(): void
  disconnect(): void
  send(message: Message): void
  onMessage(fn: (message: Message) => void): () => void
  onStatus(fn: (status: 'connecting' | 'online' | 'offline') => void): () => void
  readonly status: 'connecting' | 'online' | 'offline'
}

Built in: BroadcastChannelTransport, WebSocketTransport (with reconnection and an offline queue), createMemoryTransportPair() for tests and demos like the one above, and ConvexTransport for Convex.

A store and a live relay#

A database-backed transport keeps the board, but every message is a write and a query, which is too slow for cursors. RelayTransport pairs it with a socket relay that only forwards: edits go to both, so peers see them at socket speed while the store keeps them, and cursors go to the relay alone while it is up (the store still hears them every ten seconds, and takes over completely when the relay drops). A late or duplicated message is harmless: edits merge field by field, and the client never steps back to an older cursor.

board.tsts
import { ConvexClient } from 'convex/browser'
import type { Transport } from 'canvasflow/collab'
import { CollabClient, RelayTransport } from 'canvasflow/collab'
import { ConvexTransport } from 'canvasflow/convex'
import { api } from '../convex/_generated/api'

declare const live: Transport // your socket relay (any Transport that forwards messages to the room)

const convex = new ConvexClient(import.meta.env.VITE_CONVEX_URL)
const store = new ConvexTransport(convex, api.canvasflow, boardId)
const client = new CollabClient(editor, new RelayTransport(store, live), { room: boardId }).start()