Skip to Content
API Referencecollab-adapter-server

@stesura/collab-adapter-server

The server side of Pitter Patter collaboration: the backend engine, the Postgres store, the Redis broadcast adapter and the HTTP routes. Node only. What the backend can do from your own server code is in Backend API.

EntryContents
@stesura/collab-adapter-servercreateCollabBackend, stores, codecs, broadcast, errors
@stesura/collab-adapter-server/nextNext.js App Router route factories
@stesura/collab-adapter-server/nodeA node:http handler and its request/response conversions
@stesura/collab-adapter-server/route-kitThe plumbing the routes are built from, for routes of your own
@stesura/collab-adapter-server/testingAn in-memory broadcast adapter for tests
@stesura/collab-adapter-server/schema.sql, /form-schema.sqlThe tables postgresStore expects

Backend

createCollabBackend

The framework-agnostic collab backend: the commit authority over a CollabStore and a BroadcastAdapter, plus presence, threads, rooms and server edits, exposing what a transport (the route factories) needs.

Parameters:
NameTypeDefault
__0CreateCollabBackendConfig<Tx>
Returns:
CollabBackend

CreateCollabBackendConfig

Options for createCollabBackend: one schema, or several by name under schemas.

NameTypeDefault
schemaSchema<any, any>
serverPlugins(schema: Schema<any, any>) => Plugin<any>[]

Plugins for the EditorState applyServerEdit builds on docs of this schema; their appendTransactions run on every server edit. Only plugins whose state derives from the doc alone (numbering, note order), never DOM/layout/UI ones. Default: none.

schemasRecord<string, CollabSchemaEntry>
defaultSchemastring
storeCollabStore<Tx>

Document/commit persistence (e.g. postgresStore).

broadcastBroadcastAdapter

Pub/sub + presence (e.g. redisBroadcast).

createInitialDoc(schema: Schema<any, any>, docId: string) => NodeJSON

The empty document createRoomDoc seeds a doc of a non-default schema with when it gets no initialContent (the store seeds default-schema docs itself). Default: schema.topNodeType.createAndFill().

missingDocBehavior"reject" | "seed"

What reading a docId with no row does.

  • "seed" (default): create an empty document.
  • "reject": reads return null (routes answer 410) and server edits never seed. Use it when docs are created explicitly via createRoomDoc, so a deleted doc isn’t resurrected by the next poll. Requires store.peekDoc. receiveCommit itself still seeds; the routes gate it.
eventsCollabBackendEvents
commitRetentionnumber

Commits retained per document. Older ones only serve clients catching up, and an unbounded log lets a forged low version force full-history scans under the doc lock.

Pruned inside the commit transaction every COMMIT_PRUNE_INTERVAL versions (requires store.pruneCommits). A client behind the window gets StaleVersionError (412) and re-hydrates and rebases its unconfirmed steps across the gap with the commits’ step maps (CreateCollabBackendConfig.commitMapRetention). Infinity disables. Default 1024.

commitMapRetentionnumber

How long the step maps of pruned commits are kept, in milliseconds: a client offline for up to this long rebases its edits across what it missed instead of losing them. Pruned by created_at at the commit prune cadence (requires store.pruneCommitMaps). Infinity keeps them. Default DEFAULT_COMMIT_MAP_RETENTION_MS (30 days).

maxContentBytesnumber

Byte cap on client JSON: createRoomDoc’s initialContent, and every route request body (the routes read it off the backend). Bounds the cost of one request; volume is the rate limiter’s job. Default DEFAULT_MAX_CONTENT_BYTES; Infinity disables.

maxCommitStepsnumber

Steps one commit may carry, a batch step counted by what it costs to apply (core’s commitStepWeight); a larger one throws CommitTooLargeError (routes: 413) before any transaction. Bounds the apply, which holds the doc’s row lock. Default core’s MAX_COMMIT_STEPS, which Replace All stays under; Infinity disables.

commitApplyCommitApplyOptions

Where a commit applies: from workerSteps steps on, in a worker thread with a heap and time budget, so a large apply blocks no other room and a runaway one is rejected (CommitApplyBudgetError , routes: 413) instead of stalling or crashing the process. At most maxWorkers run at once; a commit that waits past maxWaitMs is refused (CommitApplyBusyError , routes: 429) and resent by the client. Defaults: DEFAULT_COMMIT_APPLY .

maxThreadBytesnumber

Byte cap on one thread write’s request body, applied by the routes on top of maxContentBytes (the smaller wins). Default DEFAULT_MAX_THREAD_BYTES; Infinity disables.

maxThreadsPerDocnumber

Comment threads one document may hold. Creating one more throws ThreadLimitError; updating an existing thread is always allowed. Checked outside a transaction, so concurrent creations can overshoot it slightly. Requires store.getThread. Default DEFAULT_MAX_THREADS_PER_DOC; Infinity disables.

docCache{ maxBytes?: number; }

In-process cache of the latest document per docId. Upstream’s receiveCommit reads the whole document on every commit; with the cache, the lock is taken via store.lockDoc (version only) and the document is served from memory when version and incarnation match, falling back to a full read. Requires store.lockDoc. maxBytes: 0 disables.

`{ maxBytes: 64 MiB }`
snapshots{ every?: number; }

Version history (requires store.snapshots; without it history is off). An automatic snapshot is written inside the commit transaction every every versions, and version 0 is captured on a doc’s first commit, as the anchor exact reconstruction replays from. every: 0 turns the automatic ones off; named versions and restores still work.

`{ every: 64 }`
onErrorCollabErrorReporter

Sink for failures handled internally (a throwing mention hook, a Redis error, a failed commit), called in addition to the console log. The routes report through it too.

CollabSchemaEntry

One schema the backend serves.

NameTypeDefault
schemaSchema<any, any>
serverPlugins(schema: Schema<any, any>) => Plugin<any>[]

Plugins for the EditorState applyServerEdit builds on docs of this schema; their appendTransactions run on every server edit. Only plugins whose state derives from the doc alone (numbering, note order), never DOM/layout/UI ones. Default: none.

CollabBackend

What createCollabBackend returns: the engine the route factories serve, and the API for reading and writing documents, threads and rooms from server code.

NameTypeDefault
maxContentBytesnumber

The ingest byte cap (Infinity when disabled), which transports apply to raw bodies.

maxThreadBytesnumber

The thread-write byte cap (Infinity when disabled), which transports apply to raw bodies.

schemaSchema<any, any>

The default schema: the only one, unless the backend was given schemas.

schemasReadonlyMap<string, Schema<any, any>>

Every schema served, by name.

defaultSchemaNamestring
getDocSchema(docId: string, options?: { refresh?: boolean; } | undefined) => Promise<{ name: string; schema: Schema<any, any>; }>

The schema serving a doc: the one it was created under, else the default (also for a doc with no row yet). Transports dry-parse client steps with it first: a step that throws inside the authority surfaces as contention (a 409 retried forever), not a 400. Cached per doc; the cache is checked against the row on every commit, but not on reads, so a doc created under another schema through another instance can read stale here. refresh re-reads the store: transports do so before refusing a client, since a refusal is terminal for it.

reportErrorCollabErrorReporter

CreateCollabBackendConfig.onError, wrapped with the console log.

receiveCommit(docId: string, commitJSON: CommitJSON, options?: ReceiveCommitOptions | undefined) => Promise<void>

Upstream’s receiveCommit (reimplemented: bounded apply, own retries), plus content gates and the commit’s actor.

listenForCommit(docId: string, version: number) => Promise<CommitJSON[]>
supportsCommitMapsboolean

Whether the store keeps step maps (store.getCommitMaps).

getCommitMaps(docId: string, from: number, to: number) => Promise<CommitMapsBody>

The step maps of every commit in (from, to], one entry per version in order, with the commits’ refs. What a client further behind than the retained commits rebases across. Requires store.getCommitMaps.

getInitialDoc(docId: string) => Promise<DocSnapshot | null>

Hydration read. Null only under missingDocBehavior: "reject" with no doc row.

getDocVersion(docId: string) => Promise<number | null>

The doc’s current version, or null when it has no row; never seeds. The starting point for following store.getCommitLog(docId, afterVersion) or checking staleness. One indexed read with store.getDocVersion; without it, a full document read.

getInitialDocForTransport(docId: string, acceptEncoding: string | null) => Promise<DocTransportResult | null>

CollabBackend.getInitialDoc in the cheapest form this request accepts. acceptEncoding is the request’s Accept-Encoding header; null forces plain JSON.

getDocStatus(docId: string) => Promise<"gone" | "ok">

“gone” when missingDocBehavior: "reject" and the doc row is absent. Always “ok” in “seed” mode, without a store read.

applyServerEdit(docId: string, build: ServerEditBuildFn, opts?: ApplyServerEditOptions | undefined) => Promise<ServerEditResult>

Applies a programmatic edit: runs build on a fresh EditorState (with serverPlugins) and submits the steps as a commit, rebased against concurrent ones and retried on contention. See ServerEditBuildFn for the purity requirement; check droppedSteps on the result.

applyServerEditMany(edits: ServerEditSpec[]) => Promise<ServerEditManyResult>

applyServerEdit over many docs, with per-edit results. Never throws; no cross-document atomicity.

createRoom(roomId: string, meta?: Record<string, unknown> | undefined) => Promise<void>

Creates the room if absent, storing meta (opaque host data, e.g. its kind or tenant). A room that already exists keeps its first meta. Requires store.rooms.

getRoom(roomId: string) => Promise<RoomRecord | null>

The room and its meta, or null. Requires store.rooms.

getRoomManifest(roomId: string) => Promise<RoomManifest>

A room’s manifest (docs + change ref). Requires store.rooms.

listenForRoomManifest(roomId: string, knownRef: string) => Promise<RoomManifestPollResult>

Long-poll for manifest changes since knownRef. Requires store.rooms.

createRoomDoc(roomId: string, entry: RoomDocEntry & { initialContent?: NodeJSON; }) => Promise<void>

Adds a doc to a room (creating the room if absent), seeded with initialContent or an empty document, and bumps the manifest ref. schemaName picks the schema the doc keeps for good; absent, the default. Requires store.rooms.

removeRoomDoc(roomId: string, docId: string) => Promise<void>

Removes a doc from a room, purging its rows (doc, commits, threads), and bumps the manifest ref. Requires store.rooms.

getRoomOfDoc(docId: string) => Promise<string | null>

The room a doc belongs to, or null. For doc-route authorize: resolve the room, then check the app’s own membership.

getPresence(docId: string) => Promise<Record<string, PresenceIndicator>>

Live presence indicators of a document, keyed by clientId.

updatePresence(docId: string, update: PresenceUpdate) => Promise<void>

Stores a client’s indicator, stamping movedAt with server time when moved.

removePresence(docId: string, clientId: string) => Promise<void>
listenForPresence(docId: string, clientId: string, refs?: Record<string, string> | undefined) => Promise<PresencePollResult>
getRoomPresence(roomId: string) => Promise<Record<string, RoomPresenceIndicator>>

Live room presence, keyed by clientId.

updateRoomPresence(roomId: string, update: RoomPresenceUpdate) => Promise<void>

Stores a client’s room indicator, stamping updatedAt with server time.

removeRoomPresence(roomId: string, clientId: string) => Promise<void>
listenForRoomPresence(roomId: string, clientId: string, refs?: Record<string, string> | undefined) => Promise<RoomPresencePollResult>

Long-poll for room presence changes against the refs the client holds. A member whose indicator expired (TTL) counts as a change.

getThreads(docId: string) => Promise<ThreadsSnapshot>
saveThread(docId: string, thread: ThreadRecord, author?: { id: string; name?: string; avatar?: string; } | undefined) => Promise<void>

Upserts one thread blob (whole-blob, last write wins).

listenForThreads(docId: string, knownRef: string) => Promise<ThreadsPollResult>
clearThreads(docId: string) => Promise<void>

Deletes every thread of a document and wakes thread polls. Requires store.deleteThreads.

getThread(docId: string, threadId: string) => Promise<ThreadRecord | null>

One thread record, or null. Requires store.getThread.

createThread(docId: string, input: CreateThreadInput) => Promise<CreateThreadResult>

Creates a thread with one comment and anchors it in the document: the blob is saved first (through CollabBackend.saveThread), then the anchor lands via applyServerEdit. When the anchor can’t be placed (see anchorError) or the edit fails, the thread is removed again (hard delete when the store has deleteThread, else a soft delete). A target the current doc already rejects writes nothing. Requires store.getThread.

updateThread(docId: string, threadId: string, update: (thread: SerializedThread) => SerializedThread) => Promise<SerializedThread | null>

Read-modify-write of one thread through CollabBackend.saveThread (caps, wakes, mention events). Not transactional: a concurrent write in between is overwritten (last write wins, as for client writes). Resolves the saved blob, or null when the thread doesn’t exist. Requires store.getThread.

resolveThread(docId: string, threadId: string, resolved?: boolean | undefined) => Promise<SerializedThread | null>

Sets resolved via CollabBackend.updateThread.

deleteThread(docId: string, threadId: string, options?: DeleteThreadOptions | undefined) => Promise<DeleteThreadResult>

Soft delete by default: sets deletedAt via CollabBackend.updateThread, anchors kept (as the client’s delete, which an undo reverts). hard removes the row (requires store.deleteThread), wakes thread polls, then removes the thread’s anchors with applyServerEdit.

getProofreadCache(docId: string, field: string) => Promise<Record<string, unknown>>
patchProofreadCache(docId: string, field: string, ops: { set?: Record<string, unknown>; delete?: string[]; }) => Promise<void>
supportsHistoryboolean

Whether the store keeps version history (store.snapshots).

listSnapshots(docId: string, opts?: { limit?: number; before?: number; } | undefined) => Promise<SnapshotMeta[]>

A doc’s history, newest first, without content. Requires store.snapshots, as every history method does.

getSnapshot(docId: string, version: number) => Promise<Snapshot | null>

One history version with its document, or null.

saveNamedSnapshot(docId: string, label: string, authorId?: string | undefined) => Promise<{ version: number; }>

Names the doc’s current version (renames it when already named), under the doc lock.

restoreSnapshot(docId: string, version: number, authorId?: string | undefined) => Promise<{ version: number; }>

Makes a history version the current content, as an ordinary commit through applyServerEdit, so collaborators receive it live and it lands in history itself (restoredFrom). The head it replaces is snapshotted in the same transaction, so a restore can always be undone from history. Resolves the new head; unchanged when the content already matched.

reconstructVersion(docId: string, version: number) => Promise<{ docJSON: NodeJSON; version: number; } | null>

The document exactly as it was at version: the newest snapshot at or below it, plus the commits after it replayed. Null when no snapshot precedes it or the commits are pruned (commitRetention bounds how far back replay reaches).

evictDocCache(docId?: string | undefined) => void

Drops the cached document for docId (all docs when omitted). Required after rewriting a doc outside the engine without bumping its version, since the cache trusts version equality; otherwise it only frees memory.

Its methods’ option and result types are exported too: ReceiveCommitOptions, ThreadsSnapshot, ThreadsPollResult, RoomManifest, RoomManifestPollResult, PresencePollResult, RoomPresencePollResult and DocTransportResult (how a hydration answer is sent: the stored bytes as-is, or decoded JSON).

Constants

ExportValueDescription
DEFAULT_SCHEMA_NAME"default"The name a single schema is served under.
SCHEMA_NAME_PATTERNRegExpWhat a name in schemas may look like: alphanumeric, _ or -, at most 64 characters.
DEFAULT_MAX_CONTENT_BYTES16 MiBDefault maxContentBytes: the cap on one client request body.
DEFAULT_MAX_THREAD_BYTES256 KiBDefault cap on one thread’s request body.
DEFAULT_MAX_THREADS_PER_DOC2000Default cap on comment threads per document; past it a save throws ThreadLimitError.
DEFAULT_SNAPSHOT_EVERY64Default snapshots.every: versions between automatic history checkpoints.
DEFAULT_MAX_STORED_DOC_BYTES64 MiBDefault postgresStore maxStoredDocBytes: the cap on one stored document, uncompressed.

Events

Passed as createCollabBackend({ events }). See mention, thread, commit and room events.

CollabBackendEvents

createCollabBackend({ events }): hooks fired after writes, for notifications and indexing.

NameTypeDefault
onThreadMentionsCreated(event: ThreadMentionsCreatedEvent) => void | Promise<void>

Fired after a thread save whose blob has (commentId, mentionedUserId) pairs the stored blob lacked. Not awaited; errors go to onError and never fail the save. Requires store.getThread. The pre-save read isn’t transactional, so concurrent saves of one thread can rarely double-fire or miss a mention.

onThreadChanged(event: ThreadChangedEvent) => void | Promise<void>

Fired after a thread save that created, resolved, reopened, deleted or restored the thread, or added, edited or deleted a comment, and after a hard deleteThread. Not for clearThreads nor removeRoomDoc. Same guarantees as onThreadMentionsCreated: not awaited, errors go to onError, requires store.getThread, and concurrent saves of one thread can rarely double-fire or miss a change.

onCommit(event: CommitEvent) => void | Promise<void>

Fired once per persisted commit (client commits and server edits), after its transaction and broadcast. Not for resent, rejected or noop commits, nor createRoomDoc. Not awaited; errors go to onError and never fail the commit. Per process: other instances don’t see it, so keep store.getCommitLog for cross-instance catch-up.

onRoomChanged(event: RoomChangedEvent) => void | Promise<void>

Fired after createRoom (or createRoomDoc) creates a room that didn’t exist, createRoomDoc adds a doc, or removeRoomDoc removes one; never for a no-op. Not awaited; errors go to onError. Per process, like onCommit: writes through another instance aren’t seen.

ThreadMentionsCreatedEvent

A thread save added mentions that weren’t in the previously stored blob.

NameTypeDefault
docIdstring
threadIdstring
editorIdstring

Editor fragment the thread belongs to (main doc, footnote, …).

threadMetadataRecord<string, unknown>

thread.metadata as stored (client-owned).

mentionsNewCommentMentions[]

One entry per comment that gained mentions in this save.

ThreadChangedEvent

A thread save (or hard delete) changed the thread’s lifecycle; see ThreadChange.

NameTypeDefault
docIdstring
threadIdstring
editorIdstring

Editor fragment the thread belongs to (main doc, footnote, …).

threadMetadataRecord<string, unknown>

thread.metadata as stored (client-owned).

author{ id: string; name?: string; avatar?: string; } | null

The writer’s authenticated identity (the routes’ resolveAuthor). Null for backend calls made without one (updateThread, createThread, deleteThread…).

changesThreadChange[]

Never empty.

CommitEvent

A persisted commit; see events.onCommit.

NameTypeDefault
docIdstring
refstring
versionnumber

The version the commit produced.

actorCommitActor | null

As stored on the commit row.

source"server" | "client"

receiveCommit, or applyServerEdit / applyServerEditMany.

RoomChangedEvent

A room or its manifest changed through this backend; see events.onRoomChanged.

NameTypeDefault
roomIdstring
type"created" | "doc-created" | "doc-removed"

Diff helpers

What the events are computed with, for pipelines of your own.

commentId → mentioned user ids. Soft-deleted comments and threads yield nothing.

Parameters:
NameTypeDefault
threadDataUnknownRecord
Returns:
Map<string, Set<string>>

Mentions in next that prev lacks (prev = null → all are new), keyed by (commentId, userId): re-saves don’t re-notify, but removing and re-adding a mention across two saves does.

All of it is unverified client input: author is whatever the blob says, and mentionedUserIds can name any user, including ones without access to the document. Pass authenticatedAuthor (the routes’ resolveAuthor) to replace the blob’s author, both in the result and in the self-mention exclusion; targets must still be checked against the document’s ACL before notifying.

Parameters:
NameTypeDefault
prevUnknownRecord
nextUnknownRecord
authenticatedAuthor{ id: string; name?: string; avatar?: string; }
Returns:
NewCommentMentions[]

The users one comment newly mentions; see diffThreadMentions.

NameTypeDefault
commentIdstring
author{ id: string; name?: string; avatar?: string; }

Author of the comment containing the mentions.

mentionedUserIdsstring[]

Deduped; excludes the author (the authenticated one when known, else the blob’s).

What next changed over prev: a new thread is created alone (its first comments are part of it), otherwise one entry per transition. Reactions and metadata changes yield nothing. A comment soft-deleted or dropped from the blob is comment-deleted; an edit is a changed body.

Parameters:
NameTypeDefault
prevUnknownRecord
nextUnknownRecord
Returns:
ThreadChange[]

One thing a thread save changed.

NameTypeDefault
type"deleted" | "resolved" | "created" | "unresolved" | "restored" | "comment-added" | "comment-edited" | "comment-deleted"

Server edits

backend.applyServerEdit and applyServerEditMany. See Programmatic edits.

ServerEditBuildFn

Builds one server edit. MUST be pure: it re-runs against a fresh read on every contention retry, so locate targets by walking the doc (node attrs, ids), never by cached positions or state from a previous run.

Parameters:
NameTypeDefault
ctxServerEditBuildContext
Returns:
void

ServerEditBuildContext

Passed to a server-edit build callback. Mutate tr; the engine applies it with applyTransaction, so server plugins’ appendTransactions run.

NameTypeDefault
stateEditorState
trTransaction
schemaSchema<any, any>

ApplyServerEditOptions

Options of applyServerEdit (and each applyServerEditMany entry).

NameTypeDefault
mustExistboolean

Return not-found instead of seeding a missing doc. Default false; always on under missingDocBehavior: "reject".

maxRetriesnumber

Retries after contention before throwing ServerEditContentionError, with full-jitter backoff. Each attempt already makes up to 5 transaction attempts upstream. Default 5.

actorCommitActor

Stored with the commit, e.g. { kind: "system", causedBy: userCommitRef }.

ServerEditResult

Outcome of applyServerEdit.

NameTypeDefault
status"applied" | "noop" | "not-found"

ServerEditSpec

One edit for applyServerEditMany.

NameTypeDefault
docIdstring
buildServerEditBuildFn
optsApplyServerEditOptions

ServerEditManyResult

export type ServerEditManyResult = Array< { docId: string } & (ServerEditResult | { status: "error"; error: unknown }) >;

Per-edit outcomes, aligned with the input array by index (not keyed by docId, so two edits to one document both report).

Comment threads

backend.createThread and deleteThread. See Comment threads. New threads default to DEFAULT_THREAD_EDITOR_ID ("default"), the root editor id of useCollabEditor.

CreateThreadInput

What backend.createThread takes.

NameTypeDefault
anchorCommentAnchorTarget
comment{ body: JSONContent; author: CommentAuthor; }

The thread’s first comment. author is trusted as given: the host vouches for it.

metadataRecord<string, unknown>

Stored as thread.metadata; editorId is always overwritten.

threadIdstring

Defaults to a random UUID.

editorIdstring

Root editor the thread belongs to. Default DEFAULT_THREAD_EDITOR_ID.

actorCommitActor

Stored with the anchor commit.

CreateThreadResult

What backend.createThread resolves to.

NameTypeDefault
threadIdstring
editServerEditResult

The anchor edit. The thread exists only when applied; otherwise it was removed again. As with any server edit, droppedSteps > 0 means a concurrent commit dropped (part of) the anchor.

anchorErrorCommentAnchorError | null

Why the anchor couldn’t be placed (edit is then noop), else null.

CommentAnchorTarget

Where a server-created thread’s anchor goes, located by node id: a text range inside a textblock (blockId, start, end offsets into its text) or a whole node (nodeId).

NameTypeDefault
type"node" | "range"

CommentAnchorError is the reason an anchor couldn’t be placed: "target-not-found", "invalid-range", "unsupported-node", "excluded-subtree" or "no-comment-schema".

DeleteThreadOptions

Options of backend.deleteThread.

NameTypeDefault
hardboolean

Remove the row and the thread’s anchors instead of setting deletedAt. Default false.

actorCommitActor

Stored with the anchor-removal commit (hard only).

DeleteThreadResult

What backend.deleteThread resolves to.

NameTypeDefault
deletedboolean

Whether the thread existed.

editServerEditResult | null

The anchor-removal edit; null for a soft delete.

Stores

postgresStore

Default CollabStore, on Postgres via the postgres driver. Meets the correctness contract through sql.begin, SELECT ... FOR UPDATE, and the UNIQUE(doc_id, version) and UNIQUE(doc_id, ref) constraints in the shipped schema.sql, which must be run once first.

Parameters:
NameTypeDefault
configPostgresStoreConfig
Returns:
CollabStore<Tx>

PostgresStoreConfig

Options for postgresStore.

NameTypeDefault
urlstring

Postgres connection string. For Supabase, use the transaction pooler (Supavisor, port 6543) connection string.

schemaSchema<any, any>

Builds the empty document seeded on first access (schema.topNodeType.createAndFill()).

createInitialDoc(schema: Schema<any, any>, docId: string) => NodeJSON

Override the seeded empty document (e.g. default sections/styles).

compressionDocCompression

How content is compressed at rest. Default "brotli" (level 4): ~95% smaller rows for ~12 ms of async encode per commit. Pass a DocCodec for your own. Changing it never invalidates stored rows: each row records the codec that wrote it.

compressionLevelnumber

Brotli 0–11 (default 4) or gzip 1–9 (default 6); ignored otherwise. Keep it low: encoding runs on the commit path, and brotli 11 takes seconds on a large document.

maxStoredDocBytesnumber

Cap on one stored document in uncompressed bytes, enforced on write and as the decompression bound on read. Default DEFAULT_MAX_STORED_DOC_BYTES.

tableNames{ doc?: string; commit?: string; commitMap?: string; thread?: string; room?: string; roomDoc?: string; snapshot?: string; }

Override the table names if you namespace them.

optionsOptions<Record<string, PostgresType<any>>>

Forwarded to the postgres driver. prepare: false is the default (the Supavisor transaction pooler requires it) unless overridden here.

memoryStore

In-memory CollabStore for tests and prototyping; process-local, so never behind more than one server instance.

A single mutex is the transaction: callbacks run one at a time, writes roll back on a throw, and saveCommit throws on a taken version or ref like the UNIQUE constraints. Pool reads see committed state only (see preTx). The seeding reads, getDoc(null, …) and getInitialDoc, take the mutex, so calling them inside a transaction deadlocks — pass the tx instead.

Parameters:
NameTypeDefault
configMemoryStoreConfig
Returns:
CollabStore<MemoryTx>

MemoryStoreConfig

Options for memoryStore.

NameTypeDefault
schemaSchema<any, any>

ProseMirror schema used to build the initial empty document.

createInitialDoc(schema: Schema<any, any>, docId: string) => NodeJSON

Optional override for the initial empty document (see postgresStore).

CollabStore

The contract a custom store implements.

Storage contract for the collab engine. postgresStore is the default implementation.

Correctness contract

Per commit the CollabAuthority runs getCommit → getDoc → getCommits → saveCommit → saveDoc inside runWithTransaction, retrying the whole callback on any error. Concurrent edits are only safe if:

  1. runWithTransaction is a real atomic transaction.
  2. getDoc (and lockDoc) take a row lock on the document (SELECT ... FOR UPDATE), so concurrent commits to one doc serialize.
  3. UNIQUE(doc_id, version) makes a losing concurrent commit fail, so the authority retries and rebases.
  4. UNIQUE(doc_id, ref) makes a duplicate resend fail. The getCommit idempotency check runs before the lock, so two copies of one commit can both pass it.

Without these, concurrent commits silently lose or duplicate data.

NameTypeDefault
runWithTransaction<T>(fn: (tx: Tx) => Promise<T>) => Promise<T>

Run fn inside one atomic database transaction.

getDoc(tx: Tx | null, docId: string) => Promise<DocSnapshot>

Read the document, taking the row lock when tx is present. Seeds an empty document when the row is absent, so it never returns null.

lockDoc(tx: Tx, docId: string) => Promise<DocLock | null>

Optional: take the same row lock as getDoc but return only version and timestamp, never content. Null when the row is absent (no seeding; the engine then falls back to getDoc).

Enables the engine’s doc cache: the per-commit document read becomes a version and incarnation check. Without it, every commit reads the full content. getDoc must return the same incarnation, or the cache never hits.

getCommit(tx: Tx | null, docId: string, ref: string) => Promise<CommitJSON | null>

A single commit by ref, or null (the idempotency check).

getCommits(tx: Tx | null, docId: string, version: number) => Promise<CommitJSON[]>

All commits with version strictly greater than version, ascending.

oldestCommitAfter(docId: string, version: number) => Promise<number | undefined>

Optional: version of the oldest retained commit after version, or undefined. Pool-level. Used by the commit path’s staleness pre-flight; without it the engine reads the whole getCommits tail, steps included, on every write. Implement as select version … limit 1.

saveDoc(tx: Tx | null, docId: string, docJSON: NodeJSON, version: number, lastUpdatedTimestamp: number) => Promise<void>

Persist the materialized document. Implementations should stamp the current time: the authority passes back the timestamp it read, so storing it freezes the row’s seed time.

saveCommit(tx: Tx | null, docId: string, ref: string, version: number, steps: { [key: string]: unknown; }[], actor?: CommitActor | undefined, maps?: StepMapJSON[] | undefined) => Promise<...>

Insert a commit. MUST throw if version or ref is already taken for docId — that is how contention and duplicate resends reach the authority. actor is stored with it when given; maps (one per step) too, for CollabStore.getCommitMaps.

getCommitLog(docId: string, afterVersion: number) => Promise<CommitLogEntry[]>

Optional: who committed what after afterVersion, ascending, without the steps. Pool-level. Backs audit reads (“who changed this since the last publish”).

getDocVersion(docId: string) => Promise<number | null>

Optional: the doc’s current version without its content, or null when the row is absent. Pool-level, never seeds. Backs CollabBackend.getDocVersion, which otherwise reads and decodes the whole document.

pruneCommits(tx: Tx | null, docId: string, throughVersion: number) => Promise<void>

Optional: delete commits up to and including throughVersion. Called inside the commit transaction to enforce commitRetention; without it the commit log grows unboundedly. A store with CollabStore.getCommitMaps first keeps their step maps and refs, in one row per call, in the same transaction.

pruneCommitMaps(tx: Tx | null, docId: string, retentionMs: number) => Promise<void>

Optional: delete the step maps pruneCommits kept more than retentionMs ago. Called inside the commit transaction at the prune cadence, to enforce commitMapRetention.

getCommitMaps(docId: string, afterVersion: number, throughVersion: number) => Promise<CommitMapsEntry[]>

Optional: the step maps of the commits in (afterVersion, throughVersion], live and pruned, ascending, one entry per version held (a version held by neither is absent). Pool-level. Backs the maps route a client rebases across a gap with; without it that route answers 404.

getInitialDoc(docId: string) => Promise<DocSnapshot>

Hydration read (GET doc route). Pool-level, no lock. Seeds when the row is absent.

peekDoc(docId: string) => Promise<DocSnapshot | null>

Optional: read without seeding; null when the row is absent. Pool-level. Required by applyServerEdit’s mustExist and missingDocBehavior: "reject". For existence checks use CollabStore.docExists.

getInitialDocEncoded(docId: string) => Promise<EncodedDocSnapshot>

Optional: CollabStore.getInitialDoc undecoded, so the GET-doc route can send the stored compressed bytes as-is. Seeds when the row is absent. Without it the backend decodes and answers plain JSON.

peekDocEncoded(docId: string) => Promise<EncodedDocSnapshot | null>

Optional: CollabStore.getInitialDocEncoded without seeding; null when the row is absent. Used in missingDocBehavior: "reject" mode — implement both or neither.

docExists(docId: string) => Promise<boolean>

Optional: does the doc row exist? Pool-level. Runs on every request in reject mode, so implement it as a probe (select 1 ... where id = $1), never as a content read. The backend falls back to peekDoc when absent.

getDocSchemaName(docId: string) => Promise<string | null>

Optional: the doc’s DocSnapshot.schemaName, or null for the default schema or no row. Required by a backend serving more than one schema. A probe, never a content read.

rooms{ createRoom(roomId: string, meta?: Record<string, unknown> | undefined): Promise<boolean>; getRoom(roomId: string): Promise<RoomRecord | null>; ... 4 more ...; removeRoomDoc(roomId: string, docId: string): Promise<...>; }

Optional: room manifests (suite = room). Required by the backend’s room operations, which throw without it. Membership/ACLs are not stored here: the host app checks them in the route authorize callback.

snapshots{ save(tx: Tx | null, docId: string, version: number, docJSON: NodeJSON, meta?: SnapshotWriteMeta | undefined): Promise<void>; list(docId: string, opts?: { ...; } | undefined): Promise<...>; get(docId: string, version: number): Promise<...>; latestAtOrBelow(docId: string, version: number): Promise<...>; prune?(docId...

Optional: version-history checkpoints, one per (doc, version). Required by the backend’s history operations, which throw without it. Rows go with their document.

getThreadsRevision(docId: string) => Promise<string>

Opaque value that changes whenever this document’s threads change; only equality is meaningful. Clients long-poll on it. It must be derived from the durable write, not the broadcast layer, so a lost broadcast costs one poll cycle instead of hiding the change.

getThreads(docId: string) => Promise<ThreadRecord[]>

All comment threads of a document. Pool-level.

getThread(docId: string, threadId: string) => Promise<ThreadRecord | null>

Optional: one thread, or null. Pool-level. Required by events.onThreadMentionsCreated (the pre-save baseline for the mention diff).

countThreads(docId: string) => Promise<number>

Optional: how many threads a document holds. Pool-level. Backs maxThreadsPerDoc; without it the engine counts getThreads, reading every blob.

deleteThreads(docId: string) => Promise<void>

Optional: delete every thread of a document. Pool-level. Backs CollabBackend.clearThreads.

deleteThread(docId: string, threadId: string) => Promise<void>

Optional: delete one thread; a no-op when absent. Pool-level. Backs CollabBackend.deleteThread’s hard mode and createThread’s rollback.

upsertThread(docId: string, thread: ThreadRecord) => Promise<void>

Insert-or-replace one thread (last write wins). Pool-level. Must reject when the doc row is absent (the shipped schema’s FK).

close() => Promise<void>

Optional: close pooled connections on shutdown.

Store records

TypeDescription
DocSnapshotA document with its version and timestamp, as getInitialDoc returns it.
EncodedDocSnapshotA stored document left undecoded, so hydration can send its bytes as-is.
DocLockWhat lockDoc reads: version, timestamp and incarnation, never the content.
CommitLogEntryOne commit as getCommitLog lists it: who and when, without the steps.
CommitActorWho made a commit: { kind, id?, causedBy? }, stored on its row.
ThreadRecordOne stored thread: { id, editorId, data }, data being the client’s blob.
RoomRecordA room: { id, meta, createdAt }.
RoomDocEntryOne document in a room manifest: { docId, title, position, schemaName? }.
MemoryTxmemoryStore’s transaction handle.
SnapshotMetaOne history checkpoint without its document: { version, label, authorId, restoredFrom, createdAt }. Re-exported from @stesura/collab-adapter/protocol.
SnapshotA SnapshotMeta with its docJSON, as getSnapshot returns it.
SnapshotWriteMetaWhat a checkpoint write records beside the content: optional label, authorId and restoredFrom.

Compression

postgresStore({ compression }) takes "brotli" (the default), "gzip", "none" or a DocCodec. See Storage compression.

Brotli at level (0–11). Hydration can send its rows to the browser as-is (Content-Encoding: br).

Parameters:
NameTypeDefault
levelnumber
Returns:
DocCodec

Gzip at level (1–9). Hydration can send its rows to the browser as-is (Content-Encoding: gzip).

Parameters:
NameTypeDefault
levelnumber
Returns:
DocCodec

How a stored document body is compressed. The codec id is written into each row’s envelope, so reads always use the codec that wrote the row.

NameTypeDefault
idnumber

Envelope codec id. 0–2 are reserved for none/gzip/brotli; custom codecs pick 16–255.

contentCodingstring | null

HTTP content-coding (“gzip”, “br”) when the bytes are exactly what a browser decodes under it, else null. Only then can stored bytes be streamed straight to a client.

encode(json: Uint8Array<ArrayBufferLike>) => Promise<Uint8Array<ArrayBufferLike>>
decode(bytes: Uint8Array<ArrayBufferLike>, opts: { maxBytes: number; }) => Promise<Uint8Array<ArrayBufferLike>>

MUST reject with a RangeError once the output would exceed maxBytes, without buffering past it (decompression bomb guard).

export type DocCompression = "brotli" | "gzip" | "none" | DocCodec;

compression option of postgresStore.

ExportValueDescription
identityCodecDocCodecNo compression: the envelope header, then the raw UTF-8 JSON.
DEFAULT_BROTLI_LEVEL4Smaller output than any gzip level, at less CPU than gzip 4.
DEFAULT_GZIP_LEVEL6zlib’s default.
DOC_CODEC_IDS{ none: 0, gzip: 1, brotli: 2 }The built-in codec ids written into each row. A custom codec takes an id from 16 to 255.

Broadcast

redisBroadcast

Default BroadcastAdapter, on Redis/Valkey. A commit publishes its version on the doc’s channel (as Pitter Patter’s RedisBroadcastManager did); presence indicators live in a hash with per-field TTLs, and changes are published as { ref, clientId }.

Parameters:
NameTypeDefault
configRedisBroadcastConfig
Returns:
BroadcastAdapter

RedisBroadcastConfig

Options for redisBroadcast.

NameTypeDefault
urlstring

Redis/Valkey connection string (e.g. an Upstash rediss:// TCP URL).

timeoutnumber

Max time (ms) a long-poll listener waits for a change. Default 5000.

presenceTTLSecondsnumber

Seconds an indicator survives without a refresh, for clients that vanished without leaving. Idle cursors fade after this and return on the next selection change. Default 30. Requires Redis >= 7.4 (hash-field TTLs).

roomPresenceTTLSecondsnumber

Seconds a room presence indicator survives without a refresh. Clients heartbeat every 10s, so a closed tab drops out within this. Default 30. Requires Redis >= 7.4.

proofreadTTLSecondsnumber

Seconds a proofread cache bucket survives without a write. Default 30 days.

refTTLSecondsnumber

Deprecated: Ignored: thread and room-manifest bumps are publish-only and store no key.

keyPrefixstring

Namespace for every key/channel this adapter owns, so backends sharing one Redis don’t collide. Default: none.

onErrorCollabErrorReporter

Sink for driver failures (socket resets, failed reconnects, unparseable hash fields). Usually the same reporter as createCollabBackend’s onError.

BroadcastAdapter

What a replacement for Redis implements. Its channels are PresenceChannel, RoomPresenceChannel, IndicatorChannel, RefSignalChannel, ThreadsSignalChannel and ProofreadCacheChannel; a long-poll waiter is a WakeListener. See The broadcast adapter.

Ephemeral data for the collab engine: pub/sub fan-out, presence, and the proofread cache. Durable data lives in the CollabStore. redisBroadcast is the default.

Fan-out is a latency optimization (a lost wake costs one poll timeout); presence, however, lives only here.

NameTypeDefault
collabCollabBroadcastManager

Commit fan-out for the long-polls.

presencePresenceChannel

Presence storage + fan-out.

roomPresenceRoomPresenceChannel

Room presence storage + fan-out.

threadsRefSignalChannel

Comment-thread change notifications (keyed by docId).

roomsRefSignalChannel

Room-manifest change notifications (keyed by roomId).

proofreadProofreadCacheChannel

Shared proofread-result cache.

connect() => Promise<void>

Open all connections. The engine memoizes it but calls again after a failure, so it must be retryable when only some connections opened.

close() => Promise<void>

Optional: release every connection. Call it on shutdown and in test teardown.

Errors

The routes map these to the status shown; from server code, catch them by name.

ErrorThrown whenStatus
CommitRejectedErrorA commit fails a content gate (no steps, an inline data: image)422
StaleVersionErrorThe client’s version predates the retained commits; it must reload412
InvalidDocumentErrorClient-supplied document JSON doesn’t parse under the schema400
ContentTooLargeErrorClient JSON over maxContentBytes413
UnknownSchemaErrorA schema name the backend doesn’t serve400
DocSchemaChangedErrorA document was re-created under another schema mid-commit; the commit is resent409
DocNotFoundErrorA thread write for a document with no row404 / 410
ThreadLimitErrorA new thread would pass maxThreadsPerDoc422
SnapshotNotFoundErrorA history version has no snapshot404
ThreadExistsErrorcreateThread with a threadId already taken
InvalidThreadDataErrorA stored thread blob isn’t a valid thread
ServerEditContentionErrorA server edit is still contended after its retries
RoomDocExistsErrorcreateRoomDoc with a docId that already exists, in any room409
RoomNotFoundErrorA room-doc write names a room with no row
StoredDocTooLargeErrorA save or read passes the store’s maxStoredDocBytes
CorruptStoredDocErrorA stored row can’t be decoded

Next.js routes (/next)

App Router handlers. Mounting and options are covered in Server and Rooms. The option and context types below come from the main entry and serve /node too; here the hooks receive a NextRequest.

createCollabRoutes

App Router GET/POST/PUT/DELETE handlers for the doc API. Re-export them from one catch-all route file:

// app/api/docs/[docId]/[[...path]]/route.ts import { createCollabRoutes } from "@stesura/collab-adapter-server/next"; export const { GET, POST, PUT, DELETE } = createCollabRoutes(backend, { authorize }); export const runtime = "nodejs"; export const dynamic = "force-dynamic";

Hooks receive a NextRequest, so request.cookies etc. are available in authorize.

Parameters:
NameTypeDefault
backendCollabBackend
optionsCreateCollabRoutesOptions<NextRequest>
Returns:
{ GET: (request: NextRequest, ctx: RouteParams) => Promise<Response>; POST: (request: NextRequest, ctx: RouteParams) => Promise<...>; PUT: (request: NextRequest, ctx: RouteParams) => Promise<...>; DELETE: (request: NextRequest, ctx: RouteParams) => Promise<...>; }

CreateCollabRoutesOptions

Generic over the request type so an adapter can hand hooks something richer than Request (/next passes NextRequest). The core only uses the standard surface.

NameTypeDefault
authorize(request: Req, context: CollabRouteContext) => boolean | Response | Promise<boolean | Response>

Authorization gate run before every handler. Return true to allow, false for a plain 403, or a Response (e.g. a 401 with a body) to answer with it. Required unless CreateCollabRoutesOptions.allowUnauthenticated is set.

When docs belong to rooms, resolve membership through the room:

resolveAuthor(request: Req, context: CollabRouteContext) => { id: string; name?: string; avatar?: string; } | Promise<{ id: string; name?: string; avatar?: string; } | null> | null

The authenticated author of a thread write. Mention events are stamped with this instead of the forgeable author in the client’s blob — wire it whenever events.onThreadMentionsCreated feeds notifications.

resolvePresenceUser(request: Req, context: CollabRouteContext) => string | Promise<string | null> | null

The authenticated user behind a presence update. userId is otherwise body input driving every peer’s name, colour and “this is me” filter, so any member could impersonate another. Returning null answers 403.

resolveActor(request: Req, context: CollabRouteContext) => CommitActor | Promise<CommitActor | null> | null

Who a commit is from, stored with it (collab_commit.actor_*). Returning null answers 403. Without it commits carry no actor.

rateLimitCollabRateLimitOptions<CollabRouteContext, Req>

Per-actor throttle on the write routes: commit POST, presence POST/DELETE, thread PUT and proofread POST.

maxConcurrentPollsnumber

Long-polls one actor may hold open at once, across commits, presence and threads; above it the poll answers 429. A concurrency cap rather than a rate, since a held poll is meant to be slow — its cost is the subscription and the open request.

The actor is rateLimit.identify when wired, else the adapter’s client address. With neither (e.g. /next without identify), only CreateCollabRoutesOptions.maxConcurrentPollsPerDoc applies. A tab holds three; keyed on an address, one NAT’d office shares the budget.

16
maxConcurrentPollsPerDocnumber

Long-polls held on one document across all callers — the guard that still applies when no per-actor key exists.

512
allowUnauthenticatedboolean

Mount without an authorize gate, so every caller can read and write every docId. For local development; without it, a missing authorize throws.

hydrationPassThroughboolean

Answer hydration with the stored compressed bytes and a matching Content-Encoding when the request accepts that coding and the store can supply them. Turn it off for a host that re-encodes or strips Content-Encoding; the plain-JSON fallback is always correct, only larger.

true

createCollabRoomRoutes

App Router handlers for the room-manifest API. Same contract as createCollabRoutes, mounted at app/api/rooms/[roomId]/[[...path]]/route.ts.

Parameters:
NameTypeDefault
backendCollabBackend
optionsCreateCollabRoomRoutesOptions<NextRequest>
Returns:
{ GET: (request: NextRequest, ctx: RoomRouteParams) => Promise<Response>; POST: (request: NextRequest, ctx: RoomRouteParams) => Promise<...>; DELETE: (request: NextRequest, ctx: RoomRouteParams) => Promise<...>; }

CreateCollabRoomRoutesOptions

Room-side counterpart of CreateCollabRoutesOptions.

NameTypeDefault
authorize(request: Req, context: CollabRoomRouteContext) => boolean | Response | Promise<boolean | Response>

Authorization gate run before every handler; same contract as the doc routes’. Required unless allowUnauthenticated is set.

resolvePresenceUser(request: Req, context: CollabRoomRouteContext) => string | Promise<string | null> | null

The authenticated user behind a room presence update; the body’s userId otherwise. Returning null answers 403.

rateLimitCollabRateLimitOptions<CollabRoomRouteContext, Req>

Per-actor throttle on doc create and delete and on presence updates; the default actor is the roomId (plus client address when known). Creating a doc parses up to maxContentBytes through the schema, so this is the costliest route to leave unmetered.

maxConcurrentPollsnumber

Manifest and presence long-polls one actor may hold open at once; same actor rules as the doc routes’ CreateCollabRoutesOptions.maxConcurrentPolls, with a budget of its own.

16
maxConcurrentPollsPerRoomnumber

Manifest and presence long-polls held on one room across all callers.

512
allowUnauthenticatedboolean

Mount without an authorize gate, so every caller can enumerate, create and delete every room’s documents. For local development; without it, a missing authorize throws.

CollabRouteContext

What the doc routes pass authorize, identify and the other per-request callbacks.

NameTypeDefault
docIdstring

Already percent-decoded. Ids are opaque and host-chosen, but must be percent-encoded on the wire and at most 256 characters decoded; longer ones are a 400 before authorize runs.

pathstring[]

Path segments after the docId, e.g. ["commits"] or ["presence", clientId].

CollabRoomRouteContext

What the room routes pass authorize and the other per-request callbacks.

NameTypeDefault
roomIdstring

Already percent-decoded.

pathstring[]

Path segments after the roomId, e.g. ["listen"] or ["docs", docId].

CollabRateLimitOptions

Per-actor token-bucket throttle. In-process and per instance: a multi-instance deployment still wants a shared limiter at the edge — this is the floor, not the ceiling.

NameTypeDefault
ratePerSecondnumber

Sustained requests per second per actor.

burstnumber

Burst capacity in requests. Defaults to ratePerSecond.

identify(request: Req, context: Context) => string | Promise<string>

The actor a bucket belongs to. Defaults to the doc/room id plus the client address when the adapter supplies one, else the id alone. Never a clientId or threadId: those are client-chosen, so a bucket keyed on them is bypassable. Collaborators behind one address share the default bucket — wire this to the session user for per-user fairness.

Node server (/node)

The same routes on node:http, with CORS and a health check. See Standalone Node server. It takes the option and context types above, from the main entry.

createCollabNodeHandler

A http.createServer listener serving the doc and room routes. Unmatched paths answer 404, unsupported methods 405, and OPTIONS 204 on any path.

Parameters:
NameTypeDefault
optionsCollabNodeHandlerOptions
Returns:
(req: IncomingMessage, res: ServerResponse<IncomingMessage>) => Promise<void>

CollabNodeHandlerOptions

Options for createCollabNodeHandler.

NameTypeDefault
backendCollabBackend
docsCreateCollabRoutesOptions & { mount?: string; }

Doc routes. Omit to serve only the room API; at least one of docs/rooms is required.

roomsCreateCollabRoomRoutesOptions & { mount?: string; }

Room-manifest routes. Omit to serve only the doc API.

healthPathstring | null

Answers GET with an empty 200 (other methods 405), before auth and any store access. Null disables it.

"/healthz"
allowedOriginsAllowedOrigins

Browser origins allowed to call this server (CORS with credentials): exact origins, https://*.example.com for one subdomain label, or a predicate (see AllowedOrigins ). Other origins get no CORS headers.

trustedProxyHopsnumber

Reverse proxies whose X-Forwarded-For / X-Forwarded-Proto entries are trusted: the client address and scheme are the entries this many hops from the right, 0 uses the socket. Too low and every caller behind the proxy shares one address; too high and a client can pick its own.

0

toWebRequest

A web Request for a Node request, for hosts dispatching their own routes beside createCollabNodeHandler. The scheme honours X-Forwarded-Proto only within trustedProxyHops. The body is streamed rather than buffered, so a route’s byte cap applies while reading.

Parameters:
NameTypeDefault
reqIncomingMessage
__1{ trustedProxyHops: number; }
Returns:
Request

sendWebResponse

Writes a web Response to res and ends it, streaming the body with backpressure. Iterated directly rather than via Readable.fromWeb, which would need the same stream-type cast as toWebRequest.

Parameters:
NameTypeDefault
resServerResponse<IncomingMessage>
responseResponse
Returns:
Promise<void>

clientAddressOf

The client address: the X-Forwarded-For entry trustedProxyHops from the right, or the socket peer when that is 0 or the header is shorter than the trusted chain.

Parameters:
NameTypeDefault
reqIncomingMessage
trustedProxyHopsnumber
Returns:
string | undefined

AllowedOrigins

export type AllowedOrigins = readonly string[] | ((origin: string) => boolean);

Browser origins allowed to call a route cross-origin. Strings match exactly, except scheme://*.host[:port], which matches one subdomain label of host (scheme and port exactly): https://*.example.com allows https://acme.example.com, not https://example.com nor https://a.b.example.com. Or a predicate, for anything else. From the main entry.

Route kit (/route-kit)

The plumbing the collab routes are built from, for routes of your own that should behave the same: body caps, the protocol and schema checks, client string bounds, throttling and long-poll caps. Web Request/Response only, so it works under any framework.

Guards

A 426 when the request’s protocol is outside SUPPORTED_PROTOCOLS (400 when the header is not an integer), else null. Runs before authorize, so a skewed client learns why on hydration rather than mid-edit; the range is public per version anyway. No header means protocol 1.

Parameters:
NameTypeDefault
requestRequest
Returns:
Response | null

The schema handshake for one served schema (D6 in docs/executed/custom-schema-plan.md), named in its refusals so a host that built the wrong one learns which the document uses:

  • check reads x-collab-schema: null (pass) for no header, the server’s own fingerprint or one already accepted; a 426 SchemaMismatchBody for any other; 400 when malformed.
  • verify answers the schema-check route: the client’s structure is accepted when the server only added nodes or marks (schemaAccepts), and its fingerprint (computed here, never taken from the client) passes check from then on. The structure is only compared, never built.

In-process: each instance learns accepted fingerprints on its own, so a client may post its structure once per instance.

Parameters:
NameTypeDefault
schemaSchema<any, any>
schemaNamestring
Returns:
{ check: (request: Request) => Response | null; verify: (body: unknown) => Response; }

The matcher AllowedOrigins describes, for hosts answering CORS on routes of their own as createCollabNodeHandler does.

Parameters:
NameTypeDefault
allowedAllowedOrigins
Returns:
(origin: string) => boolean

Runs after authorize, so an unauthorized caller can never spend an authorized actor’s budget. Buckets are also keyed by the first path segment, so each route family has its own budget.

Parameters:
NameTypeDefault
rateLimitCollabRateLimitOptions<Context, Req>
defaultActor(context: Context) => string
Returns:
(request: Req, context: Context, clientAddress: string | undefined) => Promise<Response | null>

Concurrency caps on held long-polls: per actor (identify, else the client address, else none) and per target (the doc or room). Each call resolves a release function, or null when a cap refuses and the poll should answer 429.

Parameters:
NameTypeDefault
identify(request: Req, context: Context) => string | Promise<string>
maxPerActornumber
maxPerTargetnumber
Returns:
(request: Req, context: Context, clientAddress: string | undefined, target: string) => Promise<(() => void) | null>

Whether this request’s Accept-Encoding allows answering in coding. Judged per request, not per browser: a proxy or non-browser client may accept less, and a body in an unrequested coding is undecodable. RFC 9110 §12.5.3: an explicit token beats *, and q=0 is a refusal.

Parameters:
NameTypeDefault
codingstring
Returns:
boolean

Reading requests

Reads and parses a request body under a byte cap. Yields TOO_LARGE over the cap and null for a missing, undecodable or malformed body; never throws. The cap is enforced while reading (declared Content-Length first, then a running total), so an oversized body is never fully buffered.

Parameters:
NameTypeDefault
requestRequest
maxBytesnumber
Returns:
Promise<unknown>

?version=: absent is 0, anything but a safe non-negative integer is null.

Parameters:
NameTypeDefault
rawstring
Returns:
number | null

A non-null, non-array object: the shape check before reading a parsed body’s fields.

Parameters:
NameTypeDefault
vunknown
Returns:
boolean

A string within MAX_CLIENT_STRING_LENGTH. May be empty.

Parameters:
NameTypeDefault
valueunknown
Returns:
boolean

A non-empty string within MAX_CLIENT_STRING_LENGTH.

Parameters:
NameTypeDefault
valueunknown
Returns:
boolean
ExportValueDescription
TOO_LARGEsymbolWhat readJson yields for a body over its cap; answer with tooLarge.
MAX_CLIENT_STRING_LENGTH256Cap on client-chosen strings that become keys (ids, path segments, refs). Reject, never truncate.
SUPPORTED_PROTOCOLS{ min, max }The client protocol versions these routes serve, inclusive.

Responses

The body is the document alone, with version and timestamp in headers, so stored compressed bytes can be sent as-is for the browser to inflate — an envelope would force a decode here.

Parameters:
NameTypeDefault
bodyBodyInit
__1DocResponseOptions
Returns:
Response

Headers for docResponse.

NameTypeDefault
versionnumber

Sent as x-doc-version.

lastUpdatedTimestampnumber

Sent as x-doc-updated-at.

schemaNamestring

Sent as x-doc-schema when given. Collab docs always name theirs; a host’s own docs may not.

contentCodingstring

The body’s Content-Encoding, for stored compressed bytes sent as-is.

The status helpers answer the way the collab routes do, so clients handle them the same:

HelperStatusBody
noContent()204none
badRequest(error)400{ error }
forbidden()403none
notFound()404none
conflict(error)409{ error }. Answer the same whoever owns the resource, so it’s no existence oracle
gone()410none; clients stop polling
staleVersion(error)412{ error } from a StaleVersionError; the client reloads
tooLarge(limit, name?)413{ error } naming the limit
tooManyRequests()429{ error }, with Retry-After: 1

Testing (/testing)

In-memory BroadcastAdapter. Commit, thread and room wakes behave like redisBroadcast, and so does room presence (TTLs included); doc presence is stored without TTLs and its polls never wake; proofread is an inert stub. timeout is the long-poll timeout in ms.

Parameters:
NameTypeDefault
__0{ timeout?: number; roomPresenceTTLSeconds?: number; }
Returns:
FakeBroadcast

What fakeBroadcast returns: the adapter plus a record of what it broadcast.

NameTypeDefault
collabCollabBroadcastManager

Commit fan-out for the long-polls.

presencePresenceChannel

Presence storage + fan-out.

roomPresenceRoomPresenceChannel

Room presence storage + fan-out.

threadsRefSignalChannel

Comment-thread change notifications (keyed by docId).

roomsRefSignalChannel

Room-manifest change notifications (keyed by roomId).

proofreadProofreadCacheChannel

Shared proofread-result cache.

connect() => Promise<void>

Open all connections. The engine memoizes it but calls again after a failure, so it must be retryable when only some connections opened.

close() => Promise<void>

Optional: release every connection. Call it on shutdown and in test teardown.

broadcasts{ docId: string; commit: CommitJSON; }[]

Every commit handed to broadcastCommit, in order.

Last updated on