Collaboration
Real-time multi-user editing over your own transport, with your server as a participant.
These docs are for version 2.3.x. See the latest version.
Every document is a Yjs CRDT from the start. A single-user editor is a room with one participant. This gives you:
- Real-time editing over your own transport. Carets, presence, tracked changes, and comments are collaborative by default. Your authentication and infrastructure carry the traffic.
- Word round trip after concurrent edits. Edits merge as CRDT operations,
and the result exports to
.docxwith native tracked changes and comments. - A server in the same room.
@reviseio/editor/serverruns the same converters and document tools in Node. Your backend can create documents before anyone opens them, edit live rooms without a browser, and propose tracked changes for users to review.
The SDK never opens a socket. You own the transport, its authentication, and its lifecycle. Pass the provider to make the document collaborative:
import { HocuspocusProvider } from "@hocuspocus/provider";
const provider = new HocuspocusProvider({
url: "wss://collab.example.com",
name: "contract-1",
});
<ReviseEditor
currentUser={{ id: user.id, name: user.name, image: user.avatarUrl }}
initialDocuments={[
{
id: "contract-1",
source: file,
collaboration: { provider, synced },
},
]}
/>;The editor edits the provider's document in place. The provider's awareness carries carets and presence. The provider object marks which edits came from other people.
One copy of Yjs#
Yjs identifies its own types with instanceof, so your application and the
SDK must resolve to the same Yjs module. Yjs is a peer dependency. Install
it yourself:
npm install yjs y-protocolsIf the bundler gives each side a different copy, seeding a shared document
fails with Unexpected content type in insert operation. In Vite, keep the
SDK and Yjs on the same side of the dependency optimizer:
export default defineConfig({
resolve: { dedupe: ["yjs", "y-protocols"] },
optimizeDeps: { exclude: ["@reviseio/editor", "yjs", "y-protocols"] },
});Webpack and Next.js resolve a single copy when your lockfile has only one
yjs. Run npm ls yjs to check.
Connect callback#
The editor reserves eight top-level keys in the Yjs document (content,
chrome, notes, comments, annotations, metadata, sdt-wrappers,
paragraphs) and needs specific document settings. The recommended form
gives you a document and an awareness instance to connect:
collaboration: {
connect: (doc, awareness) =>
new HocuspocusProvider({ url, name: "contract-1", document: doc, awareness }),
synced,
}Return the provider. The editor uses it to recognize edits from peers. Your application still owns the connection, its authentication, and its lifetime.
Supplying a Y.Doc#
Some transports create the document themselves. Both of these forms work:
collaboration: { provider } // reads document/doc/getYDoc() + awareness
collaboration: { ydoc, awareness, remoteOrigin }The editor checks a supplied document before writing to it:
| Document state | Result |
|---|---|
| Empty | Seeded |
| Holds a Revise document | Joined |
| Reserved keys hold other data | Refused |
A refused document reports the conflicting key:
Revise collaboration: this Y.Doc already uses "metadata", which the editor
reserves for its own document.This happens if your application keeps one Yjs document per room for all of
its data and already uses a name such as comments or metadata. Give the
editor its own document, or keep your data in a separate one.
Use the explicit form for an unrecognized transport, a custom sync layer, or a setup where the document and the connection are separate objects:
collaboration: {
ydoc,
awareness, // omit or pass null for no presence
remoteOrigin: myTransport, // see below
synced: isSynced,
seed: "if-empty",
}The explicit form requires remoteOrigin: the transaction origin your
transport passes to Y.applyUpdate. Without it, the editor treats a peer's
edit as local. Cursor restoration breaks, and Ctrl+Z can undo someone else's
typing. A transport that applies updates with a null origin must be
wrapped to tag its own transactions. Passing provider handles this, since
most providers pass themselves as the origin.
Seeding#
The first participant writes the content. Everyone else joins it. seed
controls this:
| Value | Behavior |
|---|---|
"if-empty" (default) | If the shared document is empty, the source document is written into it. Otherwise the source is ignored and the editor joins the existing content. |
"never" | Always join. source is not needed, and a document input with no source is valid. |
{ id: "contract-1", collaboration: { provider, synced, seed: "never" } }Seeding happens after the first sync. Seeding before receiving the room's state would duplicate the document.
Sync state#
Set synced to true once the provider has received the room's initial state.
Until then, the editor keeps the document closed instead of showing an empty,
editable page:
const [synced, setSynced] = useState(false);
useEffect(() => {
provider.on("synced", () => setSynced(true));
}, [provider]);Later disconnections do not close the document. Yjs merges offline edits when the transport reconnects. A dropped connection affects presence only.
If your transport has no sync signal, omit synced. The editor opens
immediately, and a brand-new room can be seeded twice.
Roster#
collaboration.getState() returns awareness as a list of peers, with the same
colors the carets use:
const { enabled, synced, peers } = editor.collaboration.getState();
// peers: [{ clientId, isLocal, id, name, email, image, color }]
useEffect(() => editor.collaboration.subscribe(setPresence), [editor]);It fires on join, leave, caret movement, and sync state changes. A participant
with no currentUser does not appear.
Presence and cursors#
Remote carets render when awareness is available, with a label and color per
participant. Peers see what you pass as currentUser:
<ReviseEditor
currentUser={{
id: user.id,
name: user.name,
email: user.email,
image: user.avatarUrl, // drawn on the caret flag
color: user.brandColor, // caret, flag, and roster color
}}
/>name labels the caret. image is drawn on its flag and can be any URL the
browser can load, including a data: URI.
color is optional. Without it, the editor derives a stable color from the
user id, so each person has the same color for everyone. Set it to use your
own palette or to match an avatar color.
A participant with no currentUser has no caret for others to see. Pass one
for anyone who should be visible.
currentUser also sets the author of tracked changes. Without it,
suggestions export to Word with the reviewer name "Anonymous".
Server editing#
With @reviseio/editor/server, your server is a peer in the room, not only a relay. The
backend entry runs the same converters and mutation engine as the editor.
Changes your server makes to the Y.Doc reach every open browser as ordinary
updates, and browser changes reach your server the same way. The backend
reference covers the full API. This section covers
common workflows.
Seeding from your own server#
Your server can create the room from a file it already has:
import { seedYDocFromFile, encodeYDoc, ydocToDocx } from "@reviseio/editor/server";
// When a room is first opened, before any client finishes syncing:
await seedYDocFromFile(ydoc, docxBytes, "agreement.docx");
// Store the encoded bytes however you like:
await store.put(roomId, encodeYDoc(ydoc));
// Export the live room without a browser:
const bytes = await ydocToDocx(ydoc);Clients then pass seed: "never" and no source file, and join the existing
document. This avoids a race where two people open a new document at the same
time and both import it.
The @reviseio/editor/server converters parse XML and HTML with DOM APIs, so jsdom is
a peer dependency of this entry point. For a worked example of a
@reviseio/editor/server service editing the same live document as the browser, see
reviseio/sdk-demo.
Editing a live room#
To change a room on the server, bind a document session to it. The session has the same document tools as the browser: read, search, measure, mutate, and comment. Edits go through the same Yjs-backed mutation engine, so peers receive ordinary updates and remote carets stay in place:
import { createServerDocumentSession } from "@reviseio/editor/server";
const session = await createServerDocumentSession(ydoc, {
documentId: "agreement-1",
});
try {
// Sessions default to suggesting, so this appears as a tracked change
// in every open tab.
const edit = await session.tools.call("replace", {
search_result_id: searchResultId,
replacements: [{ find: "Delaware", replace: "New York" }],
});
await notifyReviewers(edit.suggestionIds ?? []);
} finally {
session.dispose(); // your Y.Doc stays alive
}Sessions default to suggesting mode. A service proposes tracked changes
unless it opts into direct edits with mode: "editing" or a per-call
directMode. Nothing an unattended backend job proposes is applied until a
person accepts it.
Review across server and browser#
Server and browser share the same tool API. tools.execute() returns the same
ReviseToolResult, mutations report the same suggestionIds, and accept and
reject return the same ReviseSuggestionDecision. The server can propose
changes and a person can resolve them in the browser:
// Server: propose, and pass the IDs to your workflow.
const edit = await session.tools.call("replace", { ... });
await queueForReview(edit.suggestionIds ?? []);
// Browser: later, resolve that batch from your review UI.
const decision = editor.review.acceptSuggestions(await reviewBatch());
// { resolved, missing, unresolved }. Stale IDs are listed in `missing`.It also works in reverse. A person suggests changes in the browser, and a
scheduled job reads session.listSuggestions(). Each pending record includes
its author, so the job can resolve its own agent's suggestions and leave human
ones alone. See tracked changes for the
review UI.
Working without a live transport#
A server can participate without a socket. For an API endpoint or a queue worker, the client posts its document as one encoded update. The server edits a decoded copy and returns only the delta. The delta merges into the live document like any peer's edit, even if the user kept typing in the meantime:
// Client
const response = await api.review({ update: Y.encodeStateAsUpdate(ydoc) });
Y.applyUpdate(ydoc, response.delta, "my-backend");
// Server
import { decodeYDoc } from "@reviseio/editor/server";
const ydoc = decodeYDoc(update);
const baseline = Y.encodeStateVector(ydoc);
// ...run a session, propose suggestions...
const delta = Y.encodeStateAsUpdate(ydoc, baseline);The server keeps no state and needs no transport. The response carries the
created suggestionIds for the same review flow as above.
What the SDK does not do#
- It does not connect. No socket, retries, authentication, or reconnection backoff. Your provider handles all of it.
- It does not persist. Your server decides where the document is stored between sessions.
- It does not destroy your
Y.Doc. A document you pass in outlives the component. Unmounting only clears this client's caret. - It does not enforce who may edit. Roles limit what each embed can do, but each client sets its own role, and there is no document locking. To enforce a role, check it on the server at the transport. The backend entry lets that server process read and edit the document it guards.