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 -dThat 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.
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.