Collaboration
Real-time collaborative editing for AdonisJS — the shared document converges on its own through a CRDT, while versions, anchored comments and presence layer on top through ordinary authorized routes.
@adonis-agora/collaboration turns an AdonisJS app into a real-time, multi-user editing
backend. Two people type in the same paragraph, drag the same shape on the same canvas, and both
edits survive — not because a server arbitrated them, but because the data structure itself has a
merge with no losing side. The library owns the sync engine, the persistence, the version history,
the anchored comments and the presence, and ships the React hooks that consume all of it.
The one rule
The CRDT holds the shared content, and nothing else. Text, shapes, cell values — anything two people edit at once — lives in the document and converges without a server opinion. Everything around it — who may open the document, the named versions, the comment threads, who is online — is ordinary authorized data in your database, reached over REST. Putting comments inside the CRDT, or reading the live document out of a table, is the mistake this split exists to prevent. See Concepts.
The problem it solves
Real-time editing looks like one feature and is actually five, each with its own failure mode: a sync protocol, a persistence story that doesn't lose the last thirty seconds, a permission check that holds on a WebSocket handshake (where you cannot send headers), a history you can name and roll back to, and a way to pin a comment to a range of text that keeps moving while someone else types. Wiring those five yourself means a Hocuspocus server, a hand-rolled token endpoint, a snapshot table, and a comment model that drifts out of sync with the document.
@adonis-agora/collaboration collapses that into three guarantees:
- Convergence is the engine's job, not yours. You pick
yjsorautomerge; the driver owns the wire protocol, the merge and the debounced persistence. Your UI edits a scene or a rich-text fragment — never a wire message. - One permission seam, enforced on both paths.
authorize(ctx, docName)runs on the WebSocket handshake and on the REST token issue, and it is fail-closed: a document that matches no rule is denied. There is no path into a document that skips it. - Versions, comments and presence come with it. A named snapshot you can restore, comments anchored to a text range or a canvas object, and a live peer list — all persisted through one storage interface and exposed through routes Tuyau already types.
Quickstart
The minimal loop — install, authorize, mount an editor — with zero infrastructure: the embedded Yjs engine and the in-memory store. Swap in Lucid storage and Redis presence when you go to production. For the full walkthrough, see Getting started.
Install and configure the package:
node ace add @adonis-agora/collaborationThis registers the provider and the ace commands in adonisrc.ts, wires the codegen hook, and
publishes config/collaboration.ts.
Fill in authorize. The published config throws until you do — the library never grants access by
default:
import { defineConfig, lucidStorage } from '@adonis-agora/collaboration'
import Research from '#models/research'
export default defineConfig({
engine: 'yjs',
storage: lucidStorage({ connection: 'primary' }),
documents: {
'researches/:id/writing': {
async authorize(ctx, { params }) {
const research = await Research.query()
.where('id', params.id)
.andWhere('owner_id', ctx.userId)
.first()
if (!research) return { canRead: false, canWrite: false, canComment: false }
return { canRead: true, canWrite: true, canComment: true }
},
},
},
})The pattern's :id segment is extracted for you — no regex per connection — and params.id is
typed string straight from the key. Nothing to declare twice: rename the segment and the
params type follows.
The `:id` is what makes it many documents
A pattern matches segment by segment, and only a :segment accepts anything. 'whiteboards'
matches one document named whiteboards — whiteboards/42 does not match it and falls back
to the global engine and authorize. When the type is "one document per whiteboard", use
...defineCollection('whiteboards', { … }), which declares whiteboards/:id for you and cannot be
written without the :id. The manager also warns at boot about any declared pattern that has no
:segment. See Documents.
Mount the provider once, near the root of the React tree. It owns one session — one Y.Doc and one
transport — per document name, shared by every hook that asks for that name:
import { CollaborationProvider } from '@adonis-agora/collaboration-client'
export default function WritingPage({ research }: { research: { id: string } }) {
return (
<CollaborationProvider baseUrl={window.location.origin}>
<WritingEditor researchId={research.id} />
</CollaborationProvider>
)
}Edit the shared document. useCollabDoc fetches the token, resolves the engine the server chose,
opens the WebSocket and hands you the Y.Doc — Tiptap writes straight into it:
import { useAwareness, useCollabDoc } from '@adonis-agora/collaboration-client'
import Collaboration from '@tiptap/extension-collaboration'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export function WritingEditor({ researchId }: { researchId: string }) {
const docName = `researches/${researchId}/writing`
const { doc, synced } = useCollabDoc({ docName })
const { peers } = useAwareness({ docName })
const editor = useEditor({
// undoRedo: false — the CRDT owns undo, or Ctrl+Z undoes someone else's edit
extensions: [StarterKit.configure({ undoRedo: false }), Collaboration.configure({ document: doc })],
})
if (!synced) return <p>Loading the document…</p>
return (
<>
<PeerAvatars peers={peers} />
<EditorContent editor={editor} />
</>
)
}Every keystroke is now a CRDT operation that syncs to every other open tab and persists on a debounce.
synced is the gate on the first paint: the Y.Doc comes back immediately and fills in when
the server's state lands, so rendering before that shows an empty document for a beat. Gating
editable on status === 'connected' instead is the tempting mistake — it does not close that
window (a socket being open is not state having arrived) and it turns the editor read-only on every
dropped connection. See Sessions.
How the pieces connect
Browser ──GET /collaboration/token?doc=…──▶ authorize(ctx, docName) ← the permission seam
│ │
│ { token, wsUrl, engine } ◀────────┘
▼
Browser ──WebSocket──▶ engine transport ──▶ authorize(ctx, docName) ← the same seam, again
(Hocuspocus │ Automerge │ PartyKit at the edge)
│
├── debounced persistence ──▶ CollaborationStorage
└── join/leave ─────────────▶ presence (Redis)
Browser ──REST──▶ /collaboration/comments · /versions · /versions/restore · /stateThe client never hard-codes an engine. It asks for a token, the server answers with the engine that document belongs to, and the hook picks the matching transport. Switching a document from the embedded Yjs server to a PartyKit worker at the edge is a config change; the editor code does not move.
What you get
- Two CRDT engines, one API.
yjs(embedded Hocuspocus — rich text, canvases, awareness) andautomerge(a JSON-like document with a native change graph). Both answer the samegetDocumentState/createVersion/restoreVersioncalls. - Edge deployment without a rewrite.
partykit/partyservermove the WebSocket to Cloudflare Durable Objects; Adonis keeps owning the token, the storage and the permission check. - Version control on either engine. Name a checkpoint, list the history, restore it live — Yjs through binary snapshots, Automerge over its own change graph.
- Comments anchored where they belong. A text range, a canvas object, or a media timestamp — persisted with the document, scoped to a space so a canvas and its transcript don't share a thread list.
- Presence in two layers. Peer-to-peer awareness for live cursors, plus a Redis-backed roster that answers "who is in this document" across every Node instance.
- Typed end to end. Document names become a union in
.adonisjs/, and the built-in routes are ordinary routes — so Tuyau types them for the frontend.
What works out of the box
Everything below ships with the package — nothing to write, nothing to glue. The only extra install is the editor's own library, and each is loaded lazily, so an app that never renders one never pays for it.
Sync engines
| Engine | Where the socket lives | Install | Use it for |
|---|---|---|---|
yjs | your Adonis process | nothing — bundled | rich text, canvases, live cursors. The default |
automerge | your Adonis process | nothing — bundled | a JSON-like document with a native change graph |
partykit | Cloudflare Durable Objects | a Worker you deploy (collaboration:init writes it) | many instances without sticky sessions |
partyserver | Cloudflare Durable Objects | same | the partyserver flavour of the same edge model |
The client picks its transport from the engine the token response names, so moving a document between these is a config change and the editor code does not move.
Editors
| Editor | What ships | Peer to install |
|---|---|---|
| Excalidraw | <ExcalidrawBoard> — a mounted, synced board — plus useExcalidrawSync and createExcalidrawAdapter | @excalidraw/excalidraw (optional peer, imported lazily) |
| tldraw | createTldrawAdapter() — the snapshot mirror, records plus schema | tldraw, yours to render |
| Plain text | createTextAdapter() — a string key on the document | none. A textarea, Monaco, anything |
| Tiptap / ProseMirror / CodeMirror | no adapter, and none is needed: useCollabDoc hands you the Y.Doc and useAwareness the awareness channel, which is exactly what their collaboration extensions take | the editor's own packages |
Rich text is the one case with no adapter on purpose — Tiptap binds to the Y.Doc directly, and a
wrapper would only stand between them. See
Tiptap integration.
Storage
| Storage | Persists to | Use it for |
|---|---|---|
lucidStorage() — or the LucidStorage class it wraps | your database, tables created on first use | production. Documents, versions and comments |
FileSystemStorage | a directory on disk | single-process development |
InMemoryCollaborationStorage | nothing, dies with the process | tests |
All three implement the same CollaborationStorage SPI, including pruneVersions — so a custom
backend is one object, not a fork. See Storage.
Presence
Peer-to-peer awareness needs nothing. The cross-instance roster — "who is in this document" across
every Node process — needs redisUrl in the config and nothing else.
Where to go next
Getting started
Install, write config/collaboration.ts, authorize a document, and open your first shared editor.
Concepts
The four ideas — the document as the unit, convergence, the permission seam, and what is CRDT versus what is data.
Engines
Yjs, Automerge and the edge engines — what each one is good at, and how a document picks one.
REST routes
The ten built-in endpoints: the token flow, comments, versions and binary state.
Storage
Where document states, versions and comments persist — Lucid, and writing your own backend.
Client hooks
The provider, document sessions, awareness, comments, versions and editor adapters.
Codegen
Ace commands and the typed document registry generated into .adonisjs/.
Advanced
Take over the REST surface, or wire Tiptap onto the shared document.
Testing
Fake transports, in-memory storage, and proving authorize actually denies.
Production
Multiple instances, sticky sessions, debounce, Redis presence and backup.
Troubleshooting
The symptoms that actually happen, and what causes each one.
API reference
Every config option, manager method, exported helper, route and hook — from source.