Agora

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 yjs or automerge; 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/collaboration

This 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:

config/collaboration.ts
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 whiteboardswhiteboards/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:

app/pages/researches/writing.tsx
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:

app/components/writing_editor.tsx
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 · /state

The 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) and automerge (a JSON-like document with a native change graph). Both answer the same getDocumentState / createVersion / restoreVersion calls.
  • Edge deployment without a rewrite. partykit / partyserver move 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

EngineWhere the socket livesInstallUse it for
yjsyour Adonis processnothing — bundledrich text, canvases, live cursors. The default
automergeyour Adonis processnothing — bundleda JSON-like document with a native change graph
partykitCloudflare Durable Objectsa Worker you deploy (collaboration:init writes it)many instances without sticky sessions
partyserverCloudflare Durable Objectssamethe 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

EditorWhat shipsPeer to install
Excalidraw<ExcalidrawBoard> — a mounted, synced board — plus useExcalidrawSync and createExcalidrawAdapter@excalidraw/excalidraw (optional peer, imported lazily)
tldrawcreateTldrawAdapter() — the snapshot mirror, records plus schematldraw, yours to render
Plain textcreateTextAdapter() — a string key on the documentnone. A textarea, Monaco, anything
Tiptap / ProseMirror / CodeMirrorno adapter, and none is needed: useCollabDoc hands you the Y.Doc and useAwareness the awareness channel, which is exactly what their collaboration extensions takethe 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

StoragePersists toUse it for
lucidStorage() — or the LucidStorage class it wrapsyour database, tables created on first useproduction. Documents, versions and comments
FileSystemStoragea directory on disksingle-process development
InMemoryCollaborationStoragenothing, dies with the processtests

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

On this page