Getting started
Install @adonis-agora/collaboration, write config/collaboration.ts, authorize a document, choose a storage backend, scaffold the tables, and open your first shared editor.
By the end of this guide two browser tabs will be editing the same document, their edits will merge without a conflict, and a reload will bring the content back from your database. Nothing here requires Redis, a worker, or a second process — those come later, when you have more than one Node instance.
node ace add @adonis-agora/collaborationadd installs the package and runs configure, which does three things:
- registers the service provider in
adonisrc.ts— it builds the manager, attaches the WebSocket to the HTTP server and mounts the REST routes; - registers the ace commands (
collaboration:init,make:collab-document); - registers the codegen hook and publishes
config/collaboration.ts.
Confirm the wiring:
node ace list | grep collab
# collaboration:init
# make:collab-documentnode ace collaboration:initThis creates app/collaboration/documents/ (the folder the codegen hook scans) and — when a
database is configured — three migrations: collab_documents, collab_versions and
collab_comments. Run them:
node ace migration:runThe tables also create themselves
lucidStorage issues CREATE TABLE IF NOT EXISTS on first use, so a fresh clone works before
anyone has run a migration. Ship the migrations anyway: they are what makes the schema reviewable,
versioned and rollback-able. The lazy creation is a development convenience, not the plan.
The published config/collaboration.ts is deliberately incomplete: its authorize throws. The
library has no idea who may read researches/42/writing, and it will not guess.
import env from '#start/env'
import { defineConfig, lucidStorage } from '@adonis-agora/collaboration'
export default defineConfig({
engine: 'yjs',
path: '/collaboration',
debounce: 2000,
storage: lucidStorage({ connection: 'primary' }),
async authorize(ctx, docName) {
// ctx.userId is the id resolved on the handshake / by the token endpoint.
return { canRead: true, canWrite: true, canComment: true }
},
})That authorize is a placeholder — the next step replaces it with something real.
authorize runs on every WebSocket handshake and on every REST request that names a
document — the token issue, the comment and version endpoints, the state read. It receives the
connection context (at minimum a userId) and the document name, and answers three questions:
| Field | Question | What false does |
|---|---|---|
canRead | may this user open the document at all? | the handshake is dropped; the read endpoints answer 403 |
canWrite | may they change the shared content? | the connection becomes read-only — they still see every edit, theirs are dropped |
canComment | may they leave anchored comments? | the comment write endpoints answer 403 |
All three are enforced by the library itself, on the socket and on the REST routes. Read them on
the client as well — editable={canWrite}, hiding the comment composer — so the UI does not offer
an action the server will refuse.
For an app where every document type has its own rule, writing one authorize with a chain of
regexes gets ugly fast. Declare the document types instead — the library matches the incoming name
against each pattern and hands you the extracted, typed params:
import { defineCollection, defineConfig, lucidStorage } from '@adonis-agora/collaboration'
import Research from '#models/research'
import Whiteboard from '#models/whiteboard'
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: research.status === 'draft', canComment: true }
},
},
...defineCollection('whiteboards', {
engine: 'automerge',
async authorize(ctx, { params }) {
const member = await Whiteboard.isMember(params.id, ctx.userId)
return { canRead: member, canWrite: member, canComment: member }
},
}),
},
})Two things happen here at once. researches/:id/writing and whiteboards/:id get different
authorization, and whiteboards/:id also gets a different engine — Automerge instead of the
global default. The rest of the app is unaffected.
Unmatched documents are denied, not allowed
A document name that matches no pattern falls back to the global authorize. If you did not write
one, the answer is { canRead: false, canWrite: false, canComment: false } — the library fails
closed. That is the correct default, and it is also the most common cause of a client stuck on
connecting: the pattern simply does not match the name the client asked for.
@adonis-agora/collaboration-client keeps one session per document name — one Y.Doc, one
transport, one reconnect loop — inside a provider. Sessions are reference counted: the socket opens
with the first hook that mounts and closes when the last one unmounts, while the Y.Doc stays in
the provider's map, so navigating back resumes on the document you left. Mount the provider once,
in the layout rather than the page — a provider that remounts on navigation throws that map away:
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>
)
}baseUrl is optional and falls back to location.origin, so a same-origin app can leave it out
entirely. Those apps (Inertia, a monolith) need nothing else — the session cookie rides along and
the token endpoint resolves the user through your auth middleware. A separate frontend origin needs
a real baseUrl and adds getHeaders:
<CollaborationProvider
baseUrl="https://api.example.com"
getHeaders={() => ({ authorization: `Bearer ${accessToken()}` })}
>getHeaders is a function, called per request, so a rotating access token is picked up without
remounting the provider.
import { useAwareness, useCollabDoc } from '@adonis-agora/collaboration-client'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCaret from '@tiptap/extension-collaboration-caret'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export function WritingEditor({ researchId }: { researchId: string }) {
const docName = `researches/${researchId}/writing`
const { doc, synced, error } = useCollabDoc({ docName })
const { peers, awareness } = useAwareness({
docName,
user: { userId: currentUser.id, name: currentUser.name },
})
const editor = useEditor({
extensions: [
StarterKit.configure({ undoRedo: false }), // the CRDT owns undo/redo
Collaboration.configure({ document: doc }),
CollaborationCaret.configure({ provider: { awareness }, user: currentUser }),
],
}, [awareness]) // the channel is rebuilt on reconnect
if (error) return <p>Could not open this document: {error.message}</p>
if (!synced) return <p>Loading the document…</p>
return (
<>
<PeerAvatars peers={peers} />
<EditorContent editor={editor} />
</>
)
}Four details worth internalizing.
undoRedo: false on StarterKit is not optional — ProseMirror's own undo stack does not know about
remote operations, so it will happily undo someone else's paragraph; the CRDT's undo manager is the
one that understands "mine". (On Tiptap 2 that option was called history; on Tiptap 3 the key
history does nothing at all, which is the quiet way to end up with two undo stacks.)
synced gates the first paint, not editable. useCollabDoc hands the Y.Doc back
immediately and connects in the background, so without a gate the editor mounts on a document that
is briefly empty and anything typed into it merges with the server's state when it lands. synced
is true once that initial state has actually been applied. Do not write
editable: status === 'connected': 'connected' only means the socket is open — it does not close
that window — and it makes a four-second network blip turn the editor read-only mid-sentence with
no explanation. Yjs handles offline edits; let people keep typing and show the connection state
next to the title instead.
useAwareness hands back the awareness channel itself alongside peers, which is what
CollaborationCaret needs to publish the local caret — keyed on awareness, because that channel
is rebuilt on every reconnect.
And pass user to useAwareness. peers is built from the user field of everyone else's
awareness state, so if nobody publishes one, every tab renders a room full of anonymous client ids.
Open the page in two tabs. Type in one. It appears in the other, and both survive a reload.
Where the data actually is
After that first session, four things exist:
- The live document — a CRDT held in memory by the driver, synced to every connected client and
flushed to
collab_documentson a debounce (debounce, default 2000 ms). - Versions — nothing yet, until someone calls
createVersion. See Versions. - Comments — rows in
collab_comments, keyed by document and space. See Comments. - Presence — in-memory awareness inside each browser. Cross-instance presence needs Redis; see Presence.
Next steps
- Type your document names.
node ace make:collab-document researches/writinggenerates the shared types, and the typed registry turns every document name in the app into a union. If you declared your documents inline inconfig/collaboration.tsinstead — as above — the registry has nothing to scan; derive the union from the config withInferCollabDocumentNames<typeof collaborationConfig>. - Add comments and history.
useCommentsanduseVersionsare REST-backed hooks that work even while the WebSocket is down — see Client hooks. - Go multi-instance. Set
redisUrlsolistPresenceaggregates across every Node process, and read Production before you scale out. - Move the WebSocket to the edge. Edge engines put the sync on Cloudflare Durable Objects while Adonis keeps the token and the storage.
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.
Concepts
The four ideas the library is built on — the document as the unit of everything, convergence as a property of the data structure, one permission seam enforced on both paths, and the line between CRDT state and ordinary data.