Skip to content
Tikab's Toolkit

Collaborative editing

Real-time multi-user editing — several people in the same document, cursors and all — built on Yjs (a CRDT) and bound to a ProseKit editor. Like every optional service it is env-gated: without COLLAB_WS_URL each editor falls back to a local-only document (still editable, just not synced), so the app never depends on the sync server.

docker compose --profile collab up -d

That starts the Yjs websocket sync server (self-built, pinned — see example/docker/ycollab/). It is air-gapped-friendly: build the image once, ship it in the .tar, no third-party registry pull.

The split

@repo/collab is the transport + binding only — no schema, no UI, no strings. The consumer brings the ProseKit schema and the editor component; the package gives it a synced Yjs document and the ProseKit binding.

Loading diagram...

Opening a session

A session is a Yjs document plus a websocket provider for a room. With no URL it's a standalone local doc — same API, no sync:

/**
 * Open a collaborative session for a room. With `url` set, edits sync through
 * the websocket provider; without it the session is a standalone local doc.
 * `user` is published to awareness so peers can render the remote cursor.
 */
export function createCollabSession(opts: {
  url?: string | null;
  room: string;
  user: CollabUser;
}): CollabSession {
  const doc = new Y.Doc();
  const fragment = doc.getXmlFragment("prosemirror");
 
  let provider: WebsocketProvider | null = null;
  let awareness: Awareness;
 
  if (opts.url) {
    provider = new WebsocketProvider(opts.url, opts.room, doc);
    awareness = provider.awareness;
  } else {
    awareness = new Awareness(doc);
  }
 
  awareness.setLocalStateField("user", {
    name: opts.user.name,
    color: opts.user.color ?? "#e8a33d",
  });
 
  return {
    doc,
    awareness,
    fragment,
    provider,
    onStatus: (cb) => {
      if (!provider) {
        cb(false);
        return () => {};
      }
      const handler = (event: { status: string }) => cb(event.status === "connected");
      provider.on("status", handler);
      return () => provider?.off("status", handler);
    },
    destroy: () => {
      provider?.destroy();
      doc.destroy();
    },
  };
}

Binding the editor

The binding is one ProseKit extension. Union it with your schema — and drop defineHistory, because Yjs owns undo/redo (collaborative undo only reverts your edits):

/**
 * The ProseKit extension that binds an editor to the session's Yjs document:
 * sync + collaborative undo + remote cursors. Union it with your schema
 * extension — and DROP `defineHistory`, Yjs owns undo here.
 */
export function defineCollab(session: CollabSession): YjsExtension {
  return defineYjs({ doc: session.doc, awareness: session.awareness, fragment: session.fragment });
}

Try it

/sandbox/collab puts two editors in the same room on one page: type in one and the text appears in the other, merged by the CRDT. The block spec (e2e/sandbox/collab.spec.ts) waits for the socket to connect, types into editor A and asserts it arrives in editor B — proving sync through the server, not a local echo.