Skip to content

Collaboration

OT via prosemirror-collab, not Yjs. docsPreset() does not enable collab. Integrators append collabExtension(provider, { version }) from @deditor/collab as the last entry in extensions. <DocEditor collab={provider} /> does that internally after loadDocument(). @deditor/core does not import prosemirror-collab.

ts
function collabExtension(
  provider: CollabProvider,
  opts: { version: number },
): Extension  // name: 'collab'

Editor.create is synchronous and must receive the authority version as collab({ version }). Starting at 0 against a non-zero authority causes permanent VERSION_MISMATCH.

Shared wire types (CollabProvider, StepsPayload, SendStepsResult, Snapshot, Presence) live in @deditor/core.

Bootstrap

Locked sequence when collab is set:

  1. Show loading chrome (no EditorView, no typing).
  2. await provider.connect() (join; provider begins buffering steps / comment / presence).
  3. await provider.loadDocument(){ version, doc, comments }.
  4. Editor.create({ extensions: [...docsPreset(), collabExtension(provider, { version })], content: doc }).
  5. editor.comments.replaceAll(comments) then deriveDetached.
  6. editor.mount(el).
ts
await provider.connect()
const snap = await provider.loadDocument()
const editor = Editor.create({
  extensions: [...docsPreset(), collabExtension(provider, { version: snap.version })],
  content: snap.doc,
})
editor.comments.replaceAll(snap.comments)
editor.comments.deriveDetached(editor.state.doc)
editor.mount(el)

Provider subscriptions register once inside plugins(). The provider replays its buffer on a microtask so Editor.create has assigned editor.state first, dropping any StepsPayload with version <= lastSnapshot.version.

On loadDocument / connect failure: do not create/mount; show the error string.

Authority algorithm

apps/collab-server holds, per roomId, a versioned Node (docsSchema() only), a step log, parallel clientIDs, comments, tombstones, and presence.

Sequential apply — never step.apply(originalDoc) independently for each step:

ts
function applyAuthorityBatch(
  doc: Node,
  steps: Step[],
): { ok: true; doc: Node } | { ok: false; failedAt: number; error: string } {
  let current = doc
  for (let i = 0; i < steps.length; i++) {
    const result = steps[i].apply(current)
    if (result.failed) {
      return { ok: false, failedAt: i, error: String(result.failed) }
    }
    current = result.doc!
  }
  return { ok: true, doc: current }
}

This function lives in @deditor/collab. Naive “apply each to the original document” is forbidden: it accepts batches the real document would reject and diverges from clients.

sendSteps handler (room mutex held):

  1. Room missing or payload.version > room.version{ ok: false, reason: 'RESET' }. Do not bump.
  2. payload.version < room.version{ ok: false, reason: 'VERSION_MISMATCH', version: room.version }version only, no steps.
  3. Empty batch or Step.fromJSON throw or applyAuthorityBatch fail → APPLY_FAILED, do not bump.
  4. On success: append steps; stamp clientIDs from the joined socket (ignore payload.clientIDs); room.version += payload.steps.length; broadcast { type: 'steps', version, steps, clientIDs } to all members including the sender.

VERSION_MISMATCH has no steps. Applying a slice from the reject would overlap the broadcast and receiveTransaction twice.

RESET when the client is ahead (payload.version > room.version) or the room is unknown (server restart wiped in-memory state). The client must not retry the same JSON; it snapshot-resyncs.

Authority stamps clientIDs from the joined socket. A spoofed id would prevent own-step confirm and duplicate text.

Inflight SendLock + awaitingConfirm

ts
interface SendLock {
  inflight: boolean
  awaitedVersion: number
  awaitingConfirm: boolean
}

A single in-flight sendSteps. Do not start a second send while lock.inflight is true — including from onTransaction of a peer broadcast during the round-trip. Peer steps still apply immediately; they just do not trigger a nested send.

Confirmation is only via broadcast onStepsreceiveTransaction including own clientIDs. flushSendable does not locally confirm. After sendSteps returns ok, awaitingConfirm is true until own ids confirm.

Document steps apply from one path: onSteps (broadcast) and getStepsSince (catch-up). Both use StepsPayload.

WS protocol

Envelope:

ts
interface Frame<T = unknown> {
  v: 1
  type: string
  requestId?: string     // client-generated; echoed on the matching response
  body: T
}

One socket per CollabProvider. Client must not send steps / comment / presence before joined. load, steps, getStepsSince, and comment require requestId.

typedirbodynotes
joinC→S{ roomId: string, clientID: string, user: User }first message after open
joinedS→C{ clientID: string }socket ready; does not replace load
loadC→S{}requestId required
snapshotS→CSnapshotreply to load (and to getStepsSince reset)
stepsC→S{ version: number, steps: StepJSON[], clientIDs: string[] }client collab version before the batch; requestId required
steps-okS→C{ version: number }reply to that requestId; broadcast still follows
steps-rejectS→C{ reason: 'VERSION_MISMATCH', version: number } | { reason: 'APPLY_FAILED', error: string } | { reason: 'RESET' }mismatch has no steps. RESET when payload.version > room.version or room missing.
stepsS→CStepsPayloadbroadcast to all members including sender; this is what onSteps fires
getStepsSinceC→S{ version: number }requestId required
steps-sinceS→C{ reset: false, payload: StepsPayload } | { reset: true }if reset: true, a snapshot frame with the same requestId follows
commentC→SCommentOprequestId required
comment-okS→C{}ack
commentS→CCommentOpbroadcast including sender
presenceC→S{ selection: SelectionJSON | null }no ack; server fills clientID/user from join
presenceS→C{ map: Record<string, Presence> }full map
errorS→C{ message: string }unexpected; provider rejects inflight promises

sendSteps maps steps-ok{ ok: true, version }, steps-reject VERSION_MISMATCH{ ok: false, reason: 'VERSION_MISMATCH', version } (no steps), APPLY_FAILED / RESET → tagged false. Provider onSteps is only the broadcast steps frame, not the ack.

Demo server uses docsSchema() only

The sample authority (apps/collab-server):

  • WebSocket rooms by query roomId (and join body).
  • In-memory only; process restart clears rooms.
  • No auth. clientID and user are client-declared and spoofable — not an auth boundary.
  • Schema = docsSchema() only.
  • Reuses applyAuthorityBatch from @deditor/collab.
  • Not a production package.

Empty room: version 0, doc > paragraph, empty step log.

Sample createWsProvider

ts
import { createWsProvider } from '@deditor/collab'
import { DocEditor } from '@deditor/react'
import '@deditor/react/style.css'

const provider = createWsProvider({
  url: 'ws://localhost:8787',
  roomId: 'demo',
  user: { id: 'u1', name: 'Alice', color: '#4f81bd' },
})

export function App() {
  return <DocEditor collab={provider} currentUser={{ id: 'u1', name: 'Alice' }} />
}

clientID defaults to a 21-char nanoid. A second connect() without disconnect() throws. createMemoryProvider / MemoryAuthority exist for tests; they speak the same CollabProvider surface.

Released under the MIT License.