@shashimadushan/docx-editor-collaboration
v0.1.2
Published
Real-time multi-user collaboration for @shashimadushan/docx-editor-editor. Yjs-based live editing, presence/online-users, live cursors, and invite/share-link UI — backend-agnostic, ships a self-hosted Hocuspocus reference server.
Maintainers
Readme
@shashimadushan/docx-editor-collaboration
Real-time multi-user collaboration for @shashimadushan/docx-editor-editor — Yjs-based live editing, a Google-Docs-style "who's online" presence bar with live cursors, and invite-by-email / share-link UI. Ships as a separate, opt-in package: install it only if you need multi-user editing, on top of the same comments/track-changes features that already work for single-user brainstorming.
Install
pnpm add @shashimadushan/docx-editor-collaboration yjs @hocuspocus/provider
# and, for the reference server:
pnpm add @hocuspocus/serverClient: wire into ReactDocxEditor
import { ReactDocxEditor } from '@shashimadushan/docx-editor-editor/react';
import { useCollaboration, PresenceBar, ShareDialog, InMemoryInviteAdapter } from '@shashimadushan/docx-editor-collaboration/react';
import '@shashimadushan/docx-editor-collaboration/styles.css';
const inviteAdapter = new InMemoryInviteAdapter();
function CollaborativeEditor({ documentId, user, role /* CollaboratorRole your app already resolved */ }) {
const { ready, synced, extension, tiptapExtensions, onlineUsers, mode } = useCollaboration({
documentId,
wsUrl: 'ws://localhost:1234',
user, // { id, name, color, avatar? }
role, // 'owner' | 'editor' | 'commenter' | 'viewer' — see "Roles & read-only enforcement" below
});
const [shareOpen, setShareOpen] = React.useState(false);
// IMPORTANT: mount `ReactDocxEditor` only once BOTH `ready` and `synced`
// are true — see "Why both `ready` and `synced`?" below for what goes
// wrong if you gate on `ready` alone.
if (!ready || !synced) return <div>Connecting…</div>;
return (
<>
<ReactDocxEditor
extensions={[extension]}
tiptapExtensions={tiptapExtensions}
// Locks a viewer/commenter's own local editor to read-only too —
// see "Roles & read-only enforcement" below for why this is
// required, not optional, alongside the server-side check.
mode={mode}
titleBarActions={
<>
<PresenceBar onlineUsers={onlineUsers} />
<button onClick={() => setShareOpen(true)}>Share</button>
</>
}
/>
<ShareDialog
open={shareOpen}
onClose={() => setShareOpen(false)}
documentId={documentId}
currentUser={user}
inviteAdapter={inviteAdapter}
permissionsAdapter={inviteAdapter}
/>
</>
);
}Why create the provider in an effect (the
readygate)?new HocuspocusProvider(...)opens a WebSocket immediately — a side effect. Creating it in render/useMemomakes React StrictMode's double-render spawn duplicate connections, and its mount→unmount→remount destroys the instance and hands the editor a deadY.Doc(no sync, no presence).useCollaborationtherefore creates the doc/provider inside an effect and exposesreadyso you can mount the editor against a guaranteed-live connection.
Why both ready and synced?
ready only means the Y.Doc/HocuspocusProvider objects exist — it flips to true the instant the provider is constructed, one React tick after mount. It says nothing about whether that doc actually reflects the server's state yet; a freshly-constructed Y.Doc starts out completely empty and only catches up once the WebSocket handshake and Yjs sync protocol finish.
synced is the flag that tracks that: it becomes true only once the provider fires Hocuspocus's synced event with state: true, confirming the local doc is caught up.
Mounting on ready alone is the most common Yjs integration bug there is. If you mount ReactDocxEditor — and let it seed its content prop, or run a loadDocx() call — as soon as ready is true, you're seeding a doc that's still empty. Yjs is a CRDT: when the server's real state arrives moments later, it does not detect "this is the same document, replace mine with theirs" — it merges the two, unioning both edit histories. The result is the entire document body appearing twice, and with every additional client that repeats the mistake, it compounds further (three copies, four, ...). Gating on ready && synced — as in the example above — closes this: the editor never mounts against a doc that hasn't yet been confirmed to match the server.
Seeding initial content
Given the above, the safe places to seed a new (never-before-edited) document are:
Server-side, preferably. Pass
loadInitialContenttocreateCollabServer()(see../server/persistence.ts) — it runs once, before any client connects, so there is no race to avoid at all. This is the recommended approach.Browser-side, only if you must, and only guarded by
isYDocEmpty(). If your app seeds content from the client (e.g. duplicating a template into a brand-new document), do it only aftersyncedistrue, and only whenisYDocEmpty(ydoc, field)confirms the synced doc really came back empty:import { isYDocEmpty } from '@shashimadushan/docx-editor-collaboration'; React.useEffect(() => { if (!synced || !ydoc) return; if (isYDocEmpty(ydoc)) { // safe: the server confirmed this doc has no content yet. seedInitialContentInto(ydoc); } }, [synced, ydoc]);Skipping the
isYDocEmpty()check is just as dangerous as skipping thesyncedgate — a returning client reconnecting to a document that already has real content must not re-seed it either.
Roles & read-only enforcement
Yjs has no concept of per-mark authorization — a writable connection can apply any update to any shared type in the doc, full stop. Enforcement therefore has to happen at the connection level, in two places that both matter:
Server-side (
createAuthenticateHook, see./server/auth.ts): sets Hocuspocus's connection-levelreadOnlyflag for both'viewer'and'commenter'roles. This is what actually stops a read-only collaborator's edits from ever reaching other clients.Client-side (
role→mode, this README's example above): the server-side flag alone leaves that collaborator's own local editor instance fully editable — they can type, watch nothing sync (their update is silently rejected/ignored by the read-only connection), and lose the work with no indication anything went wrong. Passing your app's already-resolvedroleintouseCollaboration()computesmodeviaeditorModeForRole()(also exported standalone, for headless/non-React use):import { editorModeForRole } from '@shashimadushan/docx-editor-collaboration'; editorModeForRole('viewer'); // 'viewing' editorModeForRole('commenter'); // 'viewing' — see note below editorModeForRole('editor'); // 'editing' editorModeForRole('owner'); // 'editing'Forward that into
ReactDocxEditor'smodeprop (or calleditor.setMode(mode)directly against a headlessDocxEditor) and the local editor locks in lockstep with what the server will actually accept.
commenter maps to 'viewing', same as viewer — this does not disable commenting. Comments are a separate, non-Yjs feature in @shashimadushan/docx-editor-editor (see "What this package does not touch" below) and are unaffected by EditorMode; a commenter can still add/reply to comments while their body-text editor is locked against edits that would just be dropped anyway.
Server: run the reference Hocuspocus server
// collab-server.ts — run as its own long-running Node process
import { createCollabServer, InMemoryInviteAdapter } from '@shashimadushan/docx-editor-collaboration/server';
const permissionsAdapter = new InMemoryInviteAdapter();
createCollabServer({
port: 1234,
permissionsAdapter,
// Preferred way to seed a new document's initial content — see
// "Seeding initial content" above. Return a raw Yjs update (e.g. from
// `Y.encodeStateAsUpdate()` of a doc you built with
// `prosemirrorJSONToYXmlFragment` from an existing .docx/template), or
// `null` for a genuinely blank document.
loadInitialContent: async (documentId) => null,
onPersist: async (documentId, ydoc) => {
// e.g.: const json = ydocToProseMirrorJSON(ydoc);
// const blob = await saveDocxFromJSON(json);
// await myStorage.write(documentId, blob);
},
}).listen();A Hocuspocus server is a stateful, bidirectional WebSocket process — it cannot run inside a Next.js API route (or any request/response-scoped serverless function). Run it as its own Node process; in production, host it somewhere with a persistent connection (a small VPS, Fly.io, etc.).
Connection status
usePresence()/useCollaboration() expose a status: ConnectionStatus that now distinguishes five states (widened from three in 0.1.x — existing 'connecting' | 'connected' | 'disconnected' checks keep compiling and behaving the same, since this is purely additive):
| Status | Meaning |
| --- | --- |
| 'connecting' | WebSocket handshake in progress. |
| 'connected' | Socket open, but the Yjs sync handshake hasn't completed — the doc may still be empty/stale. |
| 'synced' | The doc is confirmed caught up with the server. Safe to read/seed content — see above. |
| 'disconnected' | Socket closed (network drop, server restart, explicit disconnect()); local edits queue and flush on reconnect. |
| 'error' | The token was rejected (authenticationFailed). Reconnecting with the same token won't help — fetch a fresh one. |
Session token rotation
useCollaboration({ token }) accepts a token that can change on every render (e.g. refreshed on a timer, or after a 401) without tearing down the connection: internally it's handed to HocuspocusProvider as a callback read from a ref, not captured by value, so a rotating token is picked up on the provider's own next reconnect attempt instead of forcing a destroy-and-recreate of the Y.Doc/provider (which would drop in-flight local edits and flash the editor back to "Connecting…" mid-session).
Suggestion authorship
createCollaborationExtension() now calls the editor's track-changes author command from its onInit, attributing every suggestion (tracked insertion/deletion) made in a collaborative session to the collaborator's user.name instead of leaving it credited to the generic default author. No configuration needed — it's wired automatically from the user you already pass to useCollaboration()/createCollaborationExtension().
Bring your own backend
createCollaborationExtension({ ydoc, provider, user }) accepts any object satisfying the minimal CollaborationProvider interface (a Yjs Awareness instance + optional connect/disconnect) — Hocuspocus is the shipped reference, not a hard requirement. useCollaboration() is a Hocuspocus-flavored convenience hook; for another backend, construct your own Y.Doc/provider and call createCollaborationExtension() + usePresence() directly.
Share links (
InMemoryInviteAdaptercaveat). The referenceInMemoryInviteAdapter/LocalStorageInviteAdaptereach hold their own private state. A share-link token minted in the browser must be resolvable by the server'sonAuthenticatehook too — so for real share links, both sides must talk to one shared store. UseHttpInviteAdapterin the browser pointed at your own API routes, and the sameHttpInviteAdapter(or your DB-backedPermissionsAdapter) on the server. Seeexamples/demofor a complete working setup (app/api/collab/*+collab-server/).
No changes to packages/editor are required — everything above uses ReactDocxEditor's already-public extensions/tiptapExtensions/mode/titleBarActions props.
Invite / permissions adapters
InviteAdapter (email invites, share links) and PermissionsAdapter (roles, access) are pluggable interfaces — this package ships InMemoryInviteAdapter and LocalStorageInviteAdapter as reference implementations (invites are logged to the console, not actually emailed). A production app supplies its own adapter backed by a real user DB and an email provider (Resend, SendGrid, etc.).
What this package does not touch
Text-anchored comments (@mentions) and track-changes/suggestions in @shashimadushan/docx-editor-editor are unrelated, single-user "brainstorming" features and are unaffected by installing this package (aside from EditorMode's effect on the toolbar/editability, and suggestion authorship being wired automatically — see above).
