Yjs (Hocuspocus)
The default engine — an embedded Hocuspocus server sharing the Adonis HTTP port, y-protocols over WebSocket, shared types for text and canvases, awareness for live cursors, and snapshot-based versions.
The default. Your Adonis process embeds a Hocuspocus sync server, browsers connect a WebSocket to
/collaboration on the same port your HTTP app already listens on, and they speak the binary
y-protocols wire format. There is no second process, no broker, and no extra port to open in a
firewall.
export default defineConfig({
engine: 'yjs',
path: '/collaboration', // the upgrade path on the Adonis HTTP server
debounce: 2000, // ms of quiet before the state is flushed to storage
storage: lucidStorage({ connection: 'primary' }),
// ...
})The provider attaches the upgrade handler once the HTTP server exists, and filters on path — every
other upgrade request passes through untouched, so other WebSocket features in the same app keep
working.
The shared types
A Yjs document is a container of named shared types, each with its own merge semantics. You
rarely construct them yourself; the editor binding does it. But knowing what is in the document
explains what versions capture and what getDocumentText can read:
| Type | Holds | Used by |
|---|---|---|
Y.XmlFragment | rich text with marks and nesting | Tiptap / ProseMirror — the default fragment |
Y.Map | a keyed record | Excalidraw and tldraw scenes, mirrored under one key |
Y.Array | an ordered list | lists, ordered records |
Y.Text | plain text with formatting | CodeMirror, plain editors |
The document is a container: a page with a rich-text body and a canvas beside it can keep both under different keys of one document, sharing a name, a version history and a comment store.
Rich text with Tiptap
The full walkthrough lives in Tiptap collaboration; the short version:
const { doc, synced } = useCollabDoc({ docName })
const { awareness } = useAwareness({ docName, user: { userId: me.id, name: me.name } })
const editor = useEditor({
extensions: [
StarterKit.configure({ undoRedo: false }), // the CRDT owns undo
Collaboration.configure({ document: doc }),
CollaborationCaret.configure({ provider: { awareness }, user: { name, color } }),
],
}, [awareness])
if (!synced) return <DocumentLoading />Disable the editor's own history
ProseMirror's undo stack does not distinguish your operations from a collaborator's, so leaving it
enabled means Ctrl+Z can undo someone else's paragraph. undoRedo: false hands undo to the CRDT's
own manager, which tracks origin. The option is history on Tiptap 2 and undoRedo on Tiptap 3 —
passing the old name to a Tiptap 3 StarterKit is accepted and does nothing.
Gate the first paint on `synced`, not `editable` on the connection
doc is returned before the server's state arrives, so something has to hold the first render back
— that is what synced is for. editable: status === 'connected' does not do that job:
'connected' says the socket is open, not that the state was applied. It also breaks a working
editor, turning it read-only on every dropped WebSocket with nothing on screen to say why. See
Sessions.
Canvases
Excalidraw and tldraw both serialize to a scene object, and both are mirrored into a Y.Map key by
an adapter — so the same useCollabEditor hook drives either:
const { state, update, synced, peers } = useCollabEditor({
docName: 'whiteboards/1',
editorId: 'excalidraw',
adapter: createExcalidrawAdapter(),
})Canvas mirroring is last-write-wins over the whole scene
This is the one place in the library where "the CRDT merges it" is not the whole story. The scene
lives under a single key (Y.Map[editorId].value) holding the entire elements array, so every
change writes the whole array. Yjs converges — but on that one value, so two people drawing at the
same instant produce two whole-scene writes and the later one replaces the earlier: the other
person's stroke is gone, with no conflict and no error. There is no element-level merge here.
It is usually livable — edits appear, people work in different regions, and the peer cursors keep
them apart — and it is not something you can tune. If per-item concurrency actually matters, model
each element as its own document key and write a CollabEditorAdapter that does it. Rich text is
different: Tiptap binds to a Y.XmlFragment and does get real character-level merging.
See Editors.
Awareness
Awareness is part of the Yjs protocol, not something the library adds: each client broadcasts an
ephemeral state — cursor, selection, name, colour — to the others in the room. It is what
useAwareness reads, and it is per-room and per-process by nature. See
Presence.
Persistence and the debounce
Every change is applied to the live in-memory document immediately and broadcast to every connected
client. Writing to storage is debounced: after debounce milliseconds of quiet, the driver
encodes the full state and saves it.
The window is a straight trade. Lower means less data at risk if the process dies; higher means fewer writes under sustained typing. The default of 2000 ms is a reasonable middle for prose. Two things worth knowing about the edges:
- Sustained typing keeps resetting the timer, so a very high
debouncecan mean a long stretch with nothing written. Treat it as a maximum acceptable data loss dial. - Graceful shutdown flushes pending documents, so a normal deploy does not lose the window. A
SIGKILLdoes.
Versions
Yjs has no built-in history, so the driver adds one: createVersion stores the full encoded
state as a snapshot, and restoreVersion applies that snapshot's content back into the live
document inside a transaction — so every connected client sees the restore arrive as an ordinary
edit. Before it does, it writes the state it is about to replace into the history as a version
labelled restored from #<seq> and attributed to restoredBy, so the restore is itself undoable.
diffVersions materializes both snapshots, extracts the text and reports { added, removed } by
line. See Versions.
Reading a document server-side
getDocumentText extracts plain text from the Tiptap layout — the input for search indexing, a RAG
pipeline, a grammar checker, or a word count:
const text = await collaboration.current.getDocumentText({ docName })It prefers the live document when one is open and falls back to storage otherwise, so it is correct whether or not anyone is editing. A document that is not in the Tiptap layout returns an empty string rather than throwing — check for it before feeding the result to something that expects content.
getDocumentState gives you the raw encoded bytes instead, for backup or transfer, and
getVersionState({ docName, versionId }) gives you the same bytes for a point in the history —
what you need to extract the text of a specific version, since listVersions only returns
metadata.
Writing into a document server-side
getDocumentState hands you a copy, which is enough to export a document and useless for
changing one: while you hold it, people are typing into the real one. An external change — a
webhook from Drive, a nightly import — has to land in the instance the clients are connected to,
or it loses their keystrokes, or they lose it.
withLiveDocument borrows that instance for the duration of a callback:
const result = await collaboration.current.withLiveDocument({
docName,
run: (doc) => {
doc.transact(() => applyExternalChange(doc))
return doc.getXmlFragment('default').toString()
},
})
if (!result.live) {
// Nobody has it open here. Merge into the stored state instead:
// 'not-loaded' → try persistDocument
// 'engine-has-no-live-document' → this engine syncs elsewhere; see the edge page
}The callback receives the real Y.Doc, and everything it writes inside a transact reaches every
connected client through the sync protocol, exactly like an edit typed by a person. The result is
{ live: true, value } or { live: false, reason } — never a thrown error and never a bare
undefined, because "nobody has this document open" is an ordinary answer that your caller has to
handle, and a callback may legitimately return undefined itself. hasLiveDocument({ docName })
answers the cheap half of the question on its own.
Live means live in *this* process
The document is only alive where its clients are connected, so this is a same-process API by construction — and, on more than one instance, a same-instance one. Read the section below.
Running more than one instance
The embedded server holds each document in the memory of one process. Two instances behind a load balancer will happily host two independent copies of the same document, and they will not see each other's edits.
The fix is sticky routing: every connection for a given document name must land on the same instance. Read Production before you scale out — or move the document to an edge engine, where a Durable Object is the single owner by construction.
Engines
The CRDT backend behind a document — what a driver owns, how Yjs, Automerge and the edge engines compare, how a document picks one, and what changes in your code when it does.
Automerge
A JSON-like CRDT document with a native change graph — the wire protocol, the dedicated useAutomergeDoc hook, why it cannot back a Y.Doc, and the single-instance constraint.