Paid features

Collaboration

npm i @matrajs/collab

The protocol is the well-trodden one. A client sends the steps it has made together with the version they applied to; the authority accepts them only if that version is still current. A client whose version is stale pulls what it missed, rebases its own unsent work over it, and tries again. Neither side loses an edit.

There is no CRDT here and no dependency. Rebasing already lives in the engine — mapping a step over another is what lets a local edit survive a remote one — so collaboration is a version counter and a transport on top of that.

This package is commercial. Free to evaluate, develop against, test with, teach with and use in personal projects · a subscription buys running it in production. Nothing phones home and there is no runtime licence check.

The client

import { createEditor, starterKit } from '@matrajs/core'
import { collab } from '@matrajs/collab'

const editor = createEditor({
  extensions: [...starterKit, collab({ clientId: 'nahim-1a2b' })] as const,
})
OptionType
clientIdstring Required. Two clients must never share one · it is how a client recognises its own work coming back.
versionnumber? The version this client starts from. Defaults to 0.

The authority

Authority is a plain class with no server attached, so the same object works over a WebSocket, over HTTP polling, in a Durable Object, or in an in-memory channel in a test.

import { Authority } from '@matrajs/collab'

const authority = new Authority((version) => broadcast(version))
Member
versionHow many steps it holds.
receive(version, steps) Appends if the client is current, and returns whether it was. false means pull, rebase, try again. Rejecting rather than merging is what keeps the history linear and every client's version meaningful.
since(version)Everything that happened after that version.

The loop

Two functions, and they are the whole integration.

import { getVersion, sendableSteps } from '@matrajs/collab'

/** Send what the authority has not seen. */
function push() {
  const sendable = sendableSteps(editor)
  if (!sendable) return true

  const accepted = authority.receive(sendable.version, sendable.steps)
  if (accepted) editor.commands.confirmCollabSteps(sendable.steps.length)
  return accepted
}

/** Take what we missed and rebase onto it. */
function pull() {
  const missing = authority.since(getVersion(editor))
  if (missing.length) editor.commands.receiveCollabSteps(missing)
}

editor.on('change', () => {
  if (!push()) {
    pull()
    push()
  }
})
Export
sendableSteps(editor) { version, steps, clientId }, or null when there is nothing outstanding.
getVersion(editor)The version this client believes it is on.
receiveCollabSteps(steps) Applies other clients' steps. Your own are skipped — they are already in the document, and applying them twice would duplicate the edit.
confirmCollabSteps(count) The authority took this many · stop tracking them as unconfirmed.
A step that no longer applies is dropped rather than thrown. One malformed message from a peer must not take an editor down, and a hostile one must not be able to try.

Remote cursors

A second extension, because presence is optional and costs a little to keep.

import { colorFor, remoteCursorCSS, remoteCursors } from '@matrajs/collab'

const editor = createEditor({
  extensions: [
    ...starterKit,
    collab({ clientId }),
    remoteCursors(),
  ] as const,
})

editor.on('selectionChange', () => {
  const { from, to } = editor.selection
  socket.send(JSON.stringify({
    clientId,
    anchor: from,
    head: to,
    meta: { name: 'Nahim' },
  }))
})

socket.onmessage = (event) => editor.commands.setPresence(JSON.parse(event.data))
Command or export
setPresence(presence){ clientId, anchor, head, meta? }
removePresence(clientId)Someone left.
clearPresence()Everyone left · a reconnect, usually.
colorFor(clientId) A stable colour derived from the id, at a fixed saturation and lightness so it stays legible on light and dark. Derived beats assigned: two clients that never speak still agree on what colour a third person is.
remoteCursorCSSThe stylesheet the decorations expect. See Styling.

Read the cursors for an avatar row:

import type { RemoteCursors } from '@matrajs/collab'

const people = editor.extensionState<RemoteCursors>('remoteCursors')

Edit this page on GitHub