API reference
Every config option, manager method, exported helper, type, route and client hook — from source.
The complete public surface of both packages.
config/collaboration.ts
defineConfig({
engine?: 'yjs' | 'automerge' | 'partykit' | 'partyserver'
engineFor?: (docName: string) => CollaborationEngine | Promise<CollaborationEngine>
documents?: Record<string, CollabDocumentDeclaration>
authorize?: (ctx: CollabConnectionContext, docName: string) => Promise<CollabPermission>
storage?: CollaborationStorage
path?: string
debounce?: number
redisUrl?: string
tokenSecret?: string
partykit?: PartyKitConfig
routes?: {
enabled?: boolean
prefix?: string
middleware?: unknown[]
resolveUser?: ResolveUserFn
}
})| Option | Default | Notes |
|---|---|---|
engine | 'yjs' | the fallback engine for anything not routed elsewhere |
engineFor | — | overrides per-document engine, for every document |
documents | — | pattern → declaration; first match wins |
authorize | — | the global fallback. Absent and unmatched ⇒ deny |
storage | in-memory | an unconfigured install loses everything on restart |
path | '/collaboration' | the WebSocket upgrade path on the Adonis HTTP server |
debounce | 2000 | ms of quiet before the state is flushed to storage |
redisUrl | — | enables the cross-instance presence roster |
tokenSecret | derived from appKey | signs the collaboration token; with no key anywhere the library refuses to issue or accept one |
partykit | — | required when the engine is partykit/partyserver |
routes.enabled | true | false skips auto-registration; call collaborationRoutes yourself |
routes.prefix | '/collaboration' | |
routes.middleware | [] | applied to the whole group |
routes.resolveUser | ctx.auth.check() then ctx.auth.user | throws a 401 when there is no user |
PartyKitConfig
{
roomHost: string // 'my-app.partykit.dev' or 'localhost:1999'
party?: string // default 'main'
jwtSecret?: string // HS256 secret shared with the worker
fetchTimeoutMs?: number // default 10000
}documents entries
type CollabDocumentDeclaration<P> = {
engine?: CollaborationEngine
authorize?: (
ctx: CollabConnectionContext,
info: { docName: string; params: P },
) => Promise<CollabPermission>
load?: (info: { docName: string; params: P }) => Promise<CollabDocumentSeed>
}
type CollabDocumentSeed = Uint8Array | Y.Doc | null | undefined
type DocumentParams<Pattern extends string>
// 'researches/:id/writing' → { id: string }
// 'a/:x/b/:y' → { x: string; y: string }
// 'announcements' → {}
// string → Record<string, string>Inside defineConfig({ documents }), P is DocumentParams<key> — inferred from the pattern the
entry sits under, so a plain object literal is all you write:
'researches/:id/writing': {
async authorize(ctx, { docName, params }) { /* params.id is a string; params.foo is an error */ },
}defineDocument<P>(declaration) is an identity helper for declarations that live away from their
key. Attaching one whose P contradicts the key is a compile error at the config.
load is the document's first content, asked for only when the storage has never stored that
name, and persisted before the document is served — once per document, not once per connection.
Return the engine's binary state (Y.encodeStateAsUpdate, A.save), a Y.Doc on the Yjs engine,
or null for "nothing to seed". A throw is reported and the document opens empty; concurrent
connections on a new document seed it once. See
Documents.
defineCollection
defineCollection<Prefix extends string>(
prefix: Prefix,
declaration?: CollabDocumentDeclaration<{ id: string }>,
): { [K in `${Prefix}/:id`]: CollabDocumentDeclaration<{ id: string }> }Declares the collection <prefix>/:id — the entry most document types are — and returns it keyed by
that pattern, ready to spread into documents. params.id is typed in authorize. The prefix is
the literal part of the name ('whiteboards', 'workspaces/main/boards'): a :param, a leading or
trailing / throws.
documents: {
...defineCollection('whiteboards', { engine: 'automerge', authorize }),
...defineCollection('announcements'), // globals apply
'researches/:id/writing': { authorize }, // any other shape: write the pattern
}A declared pattern with no :segment matches exactly one document; the manager warns about each
one at boot. documentPatternParams(pattern) lists a pattern's :segments ([] for such a
pattern).
matchDocumentPattern(pattern, docName) is exported too — same-segment-count matching, returning the
extracted params or null.
CollaborationManager
Resolve it from the container, or reach the singleton through the service:
import collaboration from '@adonis-agora/collaboration/services/main'
const manager = collaboration.current // ← note `.current`The service exports an accessor, not the manager
collaboration.current.getDocumentText({ docName }), not collaboration.getDocumentText(...). The
indirection is what lets the module be imported before the app has booted; .current throws with a
clear message if it is read too early.
| Method | Returns |
|---|---|
authorize(ctx, docName) | Promise<CollabPermission> — the resolved rule, same as the handshake uses |
engineFor({ docName }) | Promise<CollaborationEngine> |
getDocumentState({ docName }) | Promise<Uint8Array> — live document if open, else storage |
getDocumentText({ docName }) | Promise<string> — plain text; '' when it is not a text layout |
withLiveDocument({ docName, run }) | Promise<LiveDocumentResult<T>> — runs run(doc) on the live in-process document; { live: false, reason } when there is none, and then run never runs |
hasLiveDocument({ docName }) | Promise<boolean> — is this process holding the document open right now |
getVersionState({ docName, versionId }) | Promise<Uint8Array> — the binary state of one version; throws on an unknown id |
createVersion({ docName, createdBy, label? }) | Promise<CollabVersion> |
listVersions({ docName }) | Promise<CollabVersion[]> |
restoreVersion({ docName, versionId, restoredBy }) | Promise<void> — applies live; first snapshots the replaced state as a new restored from #<seq> version, attributed to restoredBy |
diffVersions({ docName, aId, bId }) | Promise<{ added: number; removed: number }> |
pruneVersions({ keep, docName?, dryRun? }) | Promise<number> — versions deleted; keep is per document |
persistDocument({ docName, state }) | Promise<void> — throws without configured storage |
listPresence({ docName }) | Promise<PresenceMember[]> — [] without Redis |
attach(server) | Promise<void> — called by the provider |
close() | Promise<void> |
comments | the CommentService (below) |
presence | the PresenceService, when Redis is configured |
engine | the configured default engine, as a string |
manager.comments
| Method | Returns |
|---|---|
list(docName, space?) | Promise<CollabComment[]> — sorted oldest first |
get(docName, commentId) | Promise<CollabComment | null> — one comment, through the storage's indexed lookup when it has one (getComment), else a filtered list |
create(docName, { space, anchor, body, userId, authorName? }) | Promise<CollabComment> |
resolve(docName, commentId, resolved) | Promise<CollabComment | null> — a toggle |
remove(docName, commentId) | Promise<boolean> |
Types
type CollaborationEngine = 'yjs' | 'automerge' | 'partykit' | 'partyserver'
interface CollabConnectionContext {
userId: string
user?: { name?: string; avatarUrl?: string | null }
}
interface CollabPermission {
canRead: boolean // enforced by the library
canWrite: boolean // false ⇒ the connection is read-only; version writes 403
canComment: boolean // false ⇒ the comment write endpoints 403
}
type CommentAnchor =
| { kind: 'text-range'; start: number; end: number; selectedText: string }
| { kind: 'canvas-object'; shapeId: string; relativeX: number; relativeY: number }
| { kind: 'timestamp'; at: number; end?: number }
interface CollabComment {
id: string
documentName: string
space: string
anchor: CommentAnchor
body: string
userId: string
authorName?: string | null
resolvedAt: string | null
createdAt: string
updatedAt: string | null
}
interface CollabVersion {
id: string // UUID on Yjs; the document heads on Automerge
seq: number // friendly 1, 2, 3…
label: string | null
createdBy: string | null
createdAt: string
}
interface PresenceMember {
userId: string
name?: string | null
avatarUrl?: string | null
}
type LiveDocumentResult<T> =
| { live: true; value: T }
| { live: false; reason: LiveDocumentAbsence }
type LiveDocumentAbsence =
| 'not-loaded' // this engine keeps documents here; nobody has this one open
| 'engine-has-no-live-document' // the engine syncs elsewhere (partykit), or is not YjsA result rather than a T | undefined: "there is no live document" is an ordinary answer, and
collapsing it into undefined makes it indistinguishable from a callback that returned undefined
itself. The two reasons call for different fallbacks — not-loaded is temporary,
engine-has-no-live-document never changes for that document.
CollaborationStorage
interface CollaborationStorage {
loadDocument(docName: string): Promise<{ state: Uint8Array } | null>
saveDocument(docName: string, state: Uint8Array, meta?: Record<string, unknown>): Promise<void>
listVersions(docName: string, page?: ListPageOptions): Promise<CollabVersion[]>
saveVersion(docName: string, version: CollabVersion, snapshot: Uint8Array): Promise<void>
loadVersionSnapshot(docName: string, versionId: string): Promise<Uint8Array | null>
pruneVersions(options: { keep: number; docName?: string; dryRun?: boolean }): Promise<number>
listComments(docName: string, space?: string, page?: ListPageOptions): Promise<CollabComment[]>
getComment?(docName: string, commentId: string): Promise<CollabComment | null> // optional
saveComment(docName: string, comment: CollabComment): Promise<void>
deleteComment(docName: string, commentId: string): Promise<void>
}interface ListPageOptions {
page?: number // 1-based, default 1
size?: number // default 50, capped at 200
}ListPageOptions mirrors @adonis-agora/filter's pagination shape (page/size) so every
@adonis-agora/* package pages identically. Omit it entirely to get every row — that is what the
internal seq and restore lookups rely on; the HTTP routes always supply one.
Implementations: lucidStorage({ connection }) / LucidStorage, FileSystemStorage(baseDir?),
InMemoryCollaborationStorage.
Token helpers
issueToken(config, user, docName, authorize?): Promise<{ token; wsUrl; engine; expiresAt }>
buildWsUrl(config, docName): string
// One signed format for every engine; the key is what differs.
signCollabToken(payload, secret, expiresInSeconds?): string
verifyCollabToken(token, secret, expectedDocName): { userId; docName; user?; exp } | null
verifySelfHostedToken(token, docName, secret?): Promise<CollabConnectionContext | null>
signPartyKitToken // = signCollabToken, signed with partykit.jwtSecret
verifyPartyKitToken // = verifyCollabToken
// The signing key: explicit → config/collaboration.ts → appKey → throw.
resolveTokenSecret(secret?): Promise<string>
COLLAB_TOKEN_TTL_SECONDS // 300
class CollabForbiddenError extends Error // authorize denied canRead
class CollabAuthorizationError extends Error // authorize itself threw — retryable, not a denial
class CollabUnauthorizedError extends Error // no user resolved
class CollabTokenSecretMissingError extends Error // no signing key anywhere — issue and verify both refuseexpectedDocName is not optional on the verifiers: the client picks the document it connects to,
so a token that is not checked against it opens every document its holder can name.
issueToken requires an authorize and throws CollabForbiddenError without one: an absent
rule is a misconfiguration, not a grant. Pass the manager's, not your config's, so per-document
rules apply.
issueCollaborationToken(
{ docName: string; user?: { id: string; name?: string | null }; ctx?: HttpContext },
options?: { authorize?; manager?; engine?; path?; partykit?; resolveUser? },
): Promise<{ token: string; wsUrl: string; engine: string; expiresAt?: number }>Issues a credential outside the route — for a server-rendered page that hands it to the client,
so the browser opens the socket without GET /collaboration/token in front of it. Same authorize,
same per-document engine, same wsUrl; every option defaults exactly as the routes' do. Throws
CollabUnauthorizedError (no caller) or CollabForbiddenError (denied) instead of returning a
response. See Token flow.
Observability
onCollaborationError(listener): () => void // subscribe; returns unsubscribe
setCollaborationLogger(logger | undefined): void // the provider installs the Adonis logger
reportCollaborationError(event): void // report your own; never throws
collaborationEvents // the EventEmitter
COLLAB_ERROR_EVENT // its event name — deliberately not 'error'
interface CollabErrorEvent {
scope: 'storage' | 'authorize'
operation: string // e.g. 'saveDocument', 'onAuthenticate'
docName?: string
error: unknown
}Storage failures inside the Hocuspocus hooks, and a throw from authorize at the handshake, are
reported here instead of vanishing. With neither a logger nor a listener they reach console.error.
COLLAB_ERROR_EVENT is not 'error' on purpose: an EventEmitter with no 'error' listener
throws, which would turn the observability hook into a second outage.
A denial and an evaluation failure stay distinct all the way to the client. On the Yjs handshake the
former is a CollabForbiddenError and the latter a CollabAuthorizationError; on the Automerge
socket they are close codes 4003 (final) and 4503 (retryable).
Routes
collaborationRoutes(router, {
prefix?: string
middleware?: unknown[]
resolveUser?: ResolveUserFn
authorize?: AuthorizeFn
manager?: CollabManagerLike | (() => Promise<CollabManagerLike>)
engine?: string
path?: string
partykit?: { roomHost?; jwtSecret?; party? }
}): voidSynchronous, so it composes inside router.group(). Omit authorize and the routes resolve the
rule through the manager — the same one the handshake uses — and deny when nothing declares one.
| Method | Path | Body / query |
|---|---|---|
GET | /token | ?doc= |
GET | /comments | ?doc=&space=&page=&size= |
POST | /comments | { docName, space, anchor, body, authorName? } — author is the caller |
PATCH | /comments/:id | ?doc= · { resolved? } (default true) — author or canWrite |
DELETE | /comments/:id | ?doc= — author or canWrite |
GET | /versions | ?doc=&page=&size= |
POST | /versions | { docName, label? } — createdBy is the caller |
POST | /versions/restore | { docName, versionId } → 204 |
GET | /state | ?doc= → application/octet-stream |
POST | /state | ?doc= · raw body · x-collab-worker-secret |
GET /comments and GET /versions page with page/size — offset-based, page 1-based
(default 1) and size clamped server-side to 1..200 (default 50). Same shape as
@adonis-agora/filter, so every Agora list endpoint pages identically.
Also exported: defaultResolveUser (authenticates via ctx.auth.check() before reading the user),
canMutateComment({ comment, userId, permission }) and secretsMatch(received, expected).
Document-name types
InferCollabDocumentNames<typeof collaborationConfig> // union of declared patterns
CollabDocumentNameFor<'researches/:id/writing'> // `researches/${string}/writing`defineConfig is generic, so the literal keys of documents survive into the config's type. See
Typed registry.
Ace commands
| Command | Flags |
|---|---|
collaboration:init | --engine=yjs|automerge|partykit |
make:collab-document <name> | --spaces=a,b · --anchors=text-range,canvas-object,timestamp |
Client — @adonis-agora/collaboration-client
CollaborationProvider
<CollaborationProvider
baseUrl?={string} // optional — falls back to location.origin
getHeaders?={() => Record<string, string> | Promise<Record<string, string>>}
createTransport?={(info, doc, docName) => CollabTransport}
fetchImpl?={typeof fetch}
initialTokens?={Record<string, CollabTokenInfo>} // pre-issued, by docName
/>initialTokens carries tokens the server issued while rendering the page
(issueCollaborationToken), keyed by document name. Each is used for that document's first
connection only; reconnects fetch a fresh one, and an expired or malformed entry falls back to the
HTTP endpoint.
Hooks
useCollabDoc({ docName, token? }): { // token: pre-issued, first connection only
doc: Y.Doc // stable for the provider's lifetime
collabDoc: CollabDoc // the same document behind read/write/onUpdate
status: CollabStatus
synced: boolean // the server's initial state has been applied
error: Error | null
}
useAutomergeDoc<T>({ docName, WebSocketImpl? }): {
doc: A.Doc<T> | null
status: CollabStatus
synced: boolean // the server's state has been merged on THIS connection
pendingChanges: number // local changes not yet written to the socket
error: Error | null
change(fn: A.ChangeFn<T>): void
}
useAwareness({ docName, user? }): {
peers: CollabPeer[] // everyone except you
status: CollabStatus
awareness: Awareness | null // the channel; null until connected
setLocalState(state: Record<string, unknown> | null): void
setLocalStateField(field: string, value: unknown): void
}
// `user` is your identity, published into the awareness `user` field — the
// field every other client's `peers` reads. Without it the room is anonymous.
interface CollabLocalUser {
userId?: string
name?: string
avatarUrl?: string | null
[key: string]: unknown
}
useCollabEditor<S>({ docName, editorId, adapter, doc? }): {
state: S
update(next: S): void
status: CollabStatus
synced: boolean
peers: CollabPeer[]
}
useExcalidrawSync({ doc, excalidrawAPI, editorId? }): void
// doc: CollabDoc | Y.Doc | null — either of the two useCollabDoc returns
// editorId defaults to 'excalidraw'; the scene is last-write-wins on one key
useComments({ docName, space? }): {
comments: CollabComment[]
loading: boolean
error: Error | null
refresh(): Promise<void>
create(input): Promise<CollabComment>
resolve(commentId, resolved?): Promise<void>
remove(commentId): Promise<void>
}
useVersions({ docName }): {
versions: CollabVersion[]
loading: boolean
error: Error | null
refresh(): Promise<void>
create(label?): Promise<CollabVersion>
restore(versionId): Promise<void>
}type CollabStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
Editor adapters
createExcalidrawAdapter<S>(): CollabEditorAdapter<{ elements: S[]; appState?: Record<string, unknown> }>
createTldrawAdapter<S>(): CollabEditorAdapter<{ snapshot: S }>
createTextAdapter(): CollabEditorAdapter<string>
interface CollabEditorAdapter<S> {
empty(): S
create(doc: CollabDoc, editorId: string): { state: S; update(next: S): void }
}
interface CollabDoc {
read<T>(key: string): T | undefined
write(key: string, value: unknown): void
onUpdate(callback: () => void): () => void
}Session and REST client
class DocSession {
constructor(
docName: string,
config: CollaborationClientConfig,
options?: { registry?: DocSessionRegistry }, // the provider passes its map
)
readonly doc: Y.Doc
readonly docName: string
get transport(): CollabTransport | undefined
get engine(): string | undefined
get error(): Error | null
get synced(): boolean // initial state applied, not just "socket open"
get refCount(): number // mounted consumers holding it open
get isDestroyed(): boolean
getStatus(): CollabStatus
getSnapshot(): DocSessionSnapshot // { status, error, synced }
subscribe(listener: () => void): () => void // also starts the session
start(): Promise<void> // idempotent
retain(): () => void // hold it open; returns its own release
release(): void // the last one calls stop()
stop(): void // tears down the transport, KEEPS the Y.Doc
destroy(): void // terminal: destroys the Y.Doc, evicts from the map
}
fetchToken(config, docName)
listComments(config, docName, space?)
createComment(config, input)
resolveComment(config, docName, commentId, resolved)
deleteComment(config, docName, commentId)
listVersions(config, docName)
createVersion(config, docName, label)
restoreVersion(config, docName, versionId)
collabFetch<T>(config, path, init?)
class CollabRestError extends Error { status: number; body: string }Transports: HocuspocusCollabTransport, PartyKitCollabTransport, PartyServerCollabTransport,
and defaultTransportFactory.
interface CollabTransport {
readonly doc: Y.Doc
readonly awareness?: Awareness | null
getStatus(): CollabStatus
isSynced?(): boolean // optional; the session falls back to the status
subscribe(listener: () => void): () => void
destroy(): void
}Only Hocuspocus can separate "socket open" from "initial state applied", so only it implements
isSynced. For a transport that does not, session.synced reports getStatus() === 'connected' —
never less informative than the status, and never more.