Pitter Patter
Server-authoritative collaboration built on Pitter Patter
(prosemirror-collab-commit).
A central authority orders and validates every commit, and the document is stored as ProseMirror
JSON in your Postgres. Comments, presence and the shared proofread cache use the same backend.
| Package | Runs | Contents |
|---|---|---|
@stesura/collab-adapter | client | composeCollabPlugins, presence plugin + cursor styles, long-poll listeners |
@stesura/collab-adapter-react | client | useCollabEditor, CollabProvider, RoomProvider, comment adapter, proofread cache, presence/connection hooks |
@stesura/collab-adapter-server | server | createCollabBackend, postgresStore, redisBroadcast, route factories (/next, /node), schema.sql |
How it works
┌────────────── client ──────────────┐ ┌───────────── your server ─────────────┐
│ collab plugin (versions, rebase) │ HTTP │ createCollabRoutes (Next.js catch-all) │
│ presence plugin (remote cursors) │─────▶│ └─ createCollabBackend │
│ comment adapter (threads mirror) │ long │ ├─ CollabAuthority (validates, │
│ proofread cache (results mirror) │ poll │ │ orders, persists commits) │
└────────────────────────────────────┘ │ ├─ postgresStore → Postgres │
│ └─ redisBroadcast → Redis │
└────────────────────────────────────────┘- Postgres holds documents, commits and comment threads. Concurrent commits to one document
serialize on a row lock; a losing writer violates
UNIQUE(doc_id, version), rolls back, and is rebased and retried. - Redis holds the ephemeral parts: long-poll wake-ups, presence (with a TTL) and the shared proofread cache.
- Clients sync over HTTP long-polling: no websockets, so it runs on serverless hosts.
Client mount
From the Pitter Patter demo :
"use client";
import "@stesura/collab-adapter/styles.css"; // remote cursor styles
import { StesuraEditor, stesuraNodeViews, StesuraUserProvider } from "@stesura/editor-react";
import {
CollabProvider,
createCollabProofreadCacheFactory,
useCollabEditor,
} from "@stesura/collab-adapter-react";
import { proofreadPlugin } from "@stesura/proofread";
const DOC_ID = "my-document";
const Editor = () => {
const { editorState, dispatch, pluginFactory, schema, docStatus } = useCollabEditor({
extraPlugins: ({ parentEditorId }) => [
proofreadPlugin({
debounceTimeMS: 1000,
generateProofreadErrors,
createCache: createCollabProofreadCacheFactory({ docId: DOC_ID }),
parentEditorId,
}),
// ...trackChanges, pagination, references, comments plugins
],
});
if (docStatus === "gone") return <DocumentRemovedState />;
return (
<StesuraEditor
state={editorState}
dispatchTransaction={dispatch}
schema={schema}
pluginFactory={pluginFactory}
nodeViews={stesuraNodeViews}
uiExtensions={uiExtensions}
/>
);
};
export default function Page() {
return (
<StesuraUserProvider currentUser={user} resolveUsers={resolveUsers}>
<CollabProvider docId={DOC_ID}>
<Editor />
</CollabProvider>
</StesuraUserProvider>
);
}useCollabEditor
Takes the same arguments as the other mount hooks (editorId, schema, configuration,
pluginOptions, extraPlugins, transactionModifier, initialContent), plus:
getPresenceDisplayName(userId): the name shown on a remote cursor.onInlineImageRejected(): called when a pasted base64 image is refused. Inline images are never synced; images must be uploaded (see File uploads).onError: telemetry sink, see Telemetry.
editorId, configuration, pluginOptions, extraPlugins and initialContent are read when
the connection starts; remount (a React key) to change them. The callbacks and
transactionModifier are read live.
It returns { editorState, dispatch, pluginFactory, schema, docStatus, reconnect }.
docStatus is one of:
| Status | Meaning |
|---|---|
"loading" | Fetching the document |
"ready" | Syncing |
"disconnected" | Sync stopped after repeated failures. Resumes on reconnect() or when a send gets through |
"gone" | The document was removed (410) |
"error" | A failure no retry fixes: a malformed snapshot, or a client/server protocol mismatch (426) |
Loops pause while the tab has been hidden for 30 s, and resume when it’s visible again.
Undo is plain prosemirror-history: the collab plugin marks remote transactions, so undo reverts
only local edits and maps over remote commits.
CollabProvider
| Prop | Purpose |
|---|---|
docId | The document. While undefined the provider renders loadingState |
apiBasePath | Prefix for the API; requests go to ${apiBasePath}/api/docs/:docId. Default "" |
headers | Added to every request (doc, presence, comments). A function is called per request, so rotated tokens are picked up |
credentials | fetch credentials mode, e.g. "include" for cross-origin cookies |
comments | false skips comment sync and wires noopCommentAdapter |
loadingState | Shown while docId is undefined. Default <EditorLoadingState />; null renders nothing |
onError | Telemetry sink |
useCollabDoc() returns { docId, apiBasePath, fetch } for the nearest provider (throws outside
one); useOptionalCollabDoc() returns undefined instead. fetch carries the provider’s
headers and credentials.
RoomProvider and the proofread factories make their own requests: pass them the same headers
and credentials.
currentUser comes from StesuraUserProvider. For demos, useGuestUser() generates an
anonymous user; real apps pass their authenticated user.
Server
Two ways to run the backend: inside your Next.js app, or as a standalone Node server.
In a Next.js app
1. Create the tables. Run @stesura/collab-adapter-server/schema.sql once against your
Postgres. It is idempotent and creates:
| Table | Contents |
|---|---|
collab_doc | One row per document: compressed content + version |
collab_commit | Commit log. UNIQUE(doc_id, version) is the contention backstop: keep it |
collab_thread | One row per comment thread |
collab_room, collab_room_doc | Rooms and their manifests, see Rooms |
RLS is enabled with no policies on every table, as a deny-all backstop for other connections (e.g. Supabase’s PostgREST roles). The backend’s own connection is unaffected.
A database created before September 2026 stores collab_doc.content as jsonb. Re-running the
schema doesn’t change column types, so migrate it once:
alter table collab_doc alter column content type bytea
using convert_to(content::text, 'UTF8');Old rows stay readable and are rewritten on their next save.
2. Configure the backend (apps/demo/src/app/api/docs/_lib/backend.ts in the demo):
import "server-only";
import { createCollabBackend, postgresStore, redisBroadcast } from "@stesura/collab-adapter-server";
import { createInitialDoc } from "@stesura/core/helpers";
import { stesuraSchema } from "@stesura/core/schema";
const schema = stesuraSchema();
export const backend = createCollabBackend({
schema,
store: postgresStore({
url: process.env.DATABASE_URL!,
schema,
// Optional. The default seed is schema.topNodeType.createAndFill(), which is sparser.
createInitialDoc: (s) => createInitialDoc(s).toJSON(),
}),
broadcast: redisBroadcast({ url: process.env.REDIS_URL! }),
});The backend’s options (missingDocBehavior, maxContentBytes, commitRetention, docCache,
serverPlugins, events) are covered in Backend API.
3. Mount the routes:
// app/api/docs/[docId]/[[...path]]/route.ts
import { createCollabRoutes } from "@stesura/collab-adapter-server/next";
import { backend } from "../../_lib/backend";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const { GET, POST, PUT, DELETE } = createCollabRoutes(backend, {
authorize: async (req, { docId }) => {
const session = await getSession(req);
return session ? canAccessDoc(session.userId, docId) : false;
},
});authorize is your document ACL and is required: the factory throws without it, unless you
pass allowUnauthenticated: true (local demos only). It receives the request and
{ docId, path }, and returns true, false (403), or a Response to send as-is (e.g. a 401).
Standalone Node server
@stesura/collab-adapter-server/node serves the same routes from node:http, and adds CORS, a
health check and client-address resolution:
import { createServer } from "node:http";
import { createCollabNodeHandler } from "@stesura/collab-adapter-server/node";
const handler = createCollabNodeHandler({
backend,
docs: { authorize }, // mounted at /api/docs
rooms: { authorize: authorizeRoom }, // mounted at /api/rooms; omit to skip rooms
allowedOrigins: ["https://app.example.com"], // exact matches, sent with credentials
trustedProxyHops: 1, // proxies in front of the server, for X-Forwarded-For
});
createServer(handler).listen(3001);healthPath defaults to /healthz (null disables it). On the client, point apiBasePath at
the server’s origin (https://collab.example.com) and pass auth through headers or
credentials.
For on-premise installs there is also a prebuilt server image (apps/collab-server) built on this
handler. It is configured through environment variables, runs migrations on boot, and takes auth
as an authorize module, a shared bearer secret, or an explicit opt-in to run open. See
deploy/on-prem/INTEGRATING.md for setup.
Route options
export const { GET, POST, PUT, DELETE } = createCollabRoutes(backend, {
authorize,
// Presence identity from the session instead of the request body. null → 403.
resolvePresenceUser: async (req) => (await getSession(req))?.userId ?? null,
// The authenticated author, stamped onto mention events.
resolveAuthor: async (req) => {
const session = await getSession(req);
return session ? { id: session.userId, name: session.name } : null;
},
// Token bucket per actor on the write routes.
rateLimit: { ratePerSecond: 20, burst: 60, identify: (req) => sessionUserId(req) },
});rateLimitthrottles commit, presence, thread and proofread writes afterauthorize, with a separate budget per route family. It is in-process, per instance: put a shared limiter at the edge for multi-instance deployments. Withoutidentifythe actor is the document plus the client address on/node, or the document alone on/next(which has no client address). All collaborators on a document then share one budget.maxConcurrentPolls(default 16) caps held long-polls per actor, andmaxConcurrentPollsPerDoc(default 512) per document. On/nextthe per-actor cap only applies whenrateLimit.identifyis set.hydrationPassThrough(defaulttrue), see Storage compression.
Over budget, both limits answer 429 with Retry-After; clients back off and retry.
Telemetry
Errors the engine recovers from (a failed mention hook, a Redis reset, a commit that 500’d) only reach the console unless you pass a reporter:
export const backend = createCollabBackend({
schema,
store,
broadcast: redisBroadcast({ url: REDIS_URL, onError: reportToSentry }),
onError: reportToSentry, // (error, { scope, docId, level, detail }) => void
});The route factories use the backend’s reporter. On the client, useCollabEditor,
useLocalCollabEditor, CollabProvider, RoomProvider, LocalCommentProvider and the proofread
factories take the same onError.
Connection notes
- Postgres: any instance. On Supabase, use the transaction pooler URL (port 6543).
postgresStorepassesprepare: falseby default, which that pooler needs. Driver options, such as the pool size (options: { max }), go inoptions. - Redis: Redis or Valkey 7.4 or later (presence uses hash-field TTLs). On Upstash, use the TCP
rediss://URL, not the REST API.
Custom stores
postgresStore is the only Postgres-specific code. To use another SQL database, implement
CollabStore; its JSDoc states the correctness contract (atomic transactions, a row lock in
getDoc, UNIQUE(doc_id, version) and UNIQUE(doc_id, ref)). The optional methods are
performance hooks, see Backend API → Custom stores.
tableNames renames the tables without a custom store. memoryStore implements the contract in
memory, for tests.
Multiple backends (different schemas)
A backend is bound to one schema. To sync documents of a second schema (e.g. a form builder next to the editor), run a second backend on the same infrastructure with its own tables and mount:
// app/api/forms/_lib/form-backend.ts
export const formBackend = createCollabBackend({
schema: formSchema,
store: postgresStore({
url: process.env.DATABASE_URL!,
schema: formSchema,
tableNames: { doc: "form_doc", commit: "form_commit", thread: "form_thread" },
}),
// Namespaces the presence, thread and proofread keys in the shared Redis.
broadcast: redisBroadcast({ url: process.env.REDIS_URL!, keyPrefix: "forms" }),
});
// app/forms/api/docs/[docId]/[[...path]]/route.ts
export const { GET, POST, PUT, DELETE } = createCollabRoutes(formBackend, { authorize });Run @stesura/collab-adapter-server/form-schema.sql once (the form_* doc, commit and thread
tables; no rooms). On the client, point a CollabProvider at the second mount:
<CollabProvider docId={formDocId} apiBasePath="/forms">
<FormEditor /> {/* useCollabEditor({ schema: formSchema }) */}
</CollabProvider>Presence
Remote cursors and selections come from this workspace’s presence plugin, composed automatically
(import @stesura/collab-adapter/styles.css once). It is a fork of
@pitter-patter/presence-client that places each cursor once when it arrives and then maps it
through later transactions, which stops cursors jumping.
- A leaving client sends a leave signal (unmount, tab close), so its cursor disappears at once.
- Indicators expire after 30 s without a refresh, for crashed clients
(
redisBroadcast({ presenceTTLSeconds })). An idle user’s cursor fades and returns on their next action. - Your own user’s cursors are never shown, including those from your other sessions.
- Colors are stable per userId and match the user’s track-change color. Names come from
useCollabEditor({ getPresenceDisplayName }).
Who’s here
The remote carets are aria-hidden on purpose: they render inside the document, where labels
would be read out as part of the text. Screen-reader and keyboard users need a presence list
outside the document. CollabPresenceRoster is one:
import { CollabPresenceRoster } from "@stesura/collab-adapter-react";
<CollabPresenceRoster getDisplayName={(userId) => users[userId]?.name} maxVisible={4} />;It renders a labelled list (you first, then each remote user once), sr-only names, a labelled
“+N” overflow, and a polite live region announcing arrivals and departures. Its strings are in all
15 locales (t.collab.*). To build your own, use useDocPresence.
Connection & presence hooks
Both read the sync loops useCollabEditor already runs, so they report nothing until that
document’s editor is mounted. docId defaults to the nearest CollabProvider’s.
useDocConnection(docId?)→"connecting" | "online" | "offline".<CollabConnectionStatus />renders it as a status-bar dot + label (nothing outside aCollabProvider); the demo mounts it through a UI extension’sStatusBarElements.useDocPresence(docId?)→ the raw presence snapshot, keyed by clientId. Nothing is filtered, including your own other sessions: dedupe byuserId.
Failure containment
The comments and presence UI render data other people wrote. Wrap that subtree, not the editor, in
CollabErrorBoundary, so a bad thread or decoration shows a “collaboration unavailable” notice
instead of breaking the page:
import { CollabErrorBoundary } from "@stesura/collab-adapter-react";
<CollabErrorBoundary docId={docId} onError={reportToSentry}>
<CommentsPanel />
</CollabErrorBoundary>;Pass fallback={({ error, reset }) => …} to render your own notice.
Comments
CollabProvider wires a CommentAdapter against the backend’s thread
routes: threads, replies, delete/undo, resolve and reactions sync to collaborators within a poll
cycle. useThreadSyncFailed(docId) turns true if sync stops for good (e.g. an auth failure),
for an “out of date” banner.
Each thread is stored as one blob, last write wins. Two users editing the same thread at the same instant can lose one update.
Shared proofread cache
Proofreading caches results per paragraph. With createCollabProofreadCacheFactory
the cache is stored in Redis per document, so reloads and other users reuse results instead of
re-checking. Each client loads it once on mount; there is no live sync.
proofreadPlugin({
debounceTimeMS: 1000,
generateProofreadErrors,
createCache: createCollabProofreadCacheFactory({ docId, apiBasePath, headers }),
parentEditorId,
});Writes are batched every 2 s. Entries expire 30 days after their last write
(redisBroadcast({ proofreadTTLSeconds })); expiry only costs a re-check.
Deployment checklist
- Postgres: run
schema.sqlonce. - Redis 7.4 or later.
DATABASE_URLandREDIS_URL.- The backend config and the catch-all route (or the Node server).
authorizewired to your ACL, andheaders/credentialson the client if auth isn’t same-origin cookies.onErrorreporters on the backend andredisBroadcast.
Next steps
- Collaboration overview: backend comparison.
- Rooms: multi-document suites.
- Backend API: the server-side surface.
- Server edits: editing documents from server code.
- Comments: the adapter contract.
- Live: Pitter Patter demo , suite demo .