Collaboration plugin
@nextgensoftwares/folio-plugin-collab is realtime collaboration as an add-on: a WebSocket transport with reconnects, presence and follow mode, conflict-safe step sync built on @nextgensoftwares/folio-sync's server logic, version history with diffs and restore, and an offline outbox that flushes on reconnect.
Being built
This package is under active development. The pieces below exist in the source today; names and options may change before release. For collaboration that is stable now, see attachCollab / LocalAuthority and SyncClient in the Collaboration guide.
Shape
browser server (e.g. a WebSocket gateway)
┌──────────────────────────────┐ ┌─────────────────────────────────────┐
│ FolioEditor (collabPlugin) │ │ CollabRoom (one per open document) │
│ ▲ │ │ messages │ identity, broadcast, presence, │
│ │ steps ▼ │ ◄────────► │ heartbeats, history recording │
│ CollabClient ── transport ───┼─────────────┼─► MemoryDocumentServer (@nextgensoftwares/folio-sync)│
│ presence · outbox · RPCs │ │ versions, checksums, mass-delete │
└──────────────────────────────┘ └─────────────────────────────────────┘The authority is the same MemoryDocumentServer that @nextgensoftwares/folio-sync uses, so a realtime push gets the same guarantees as an HTTP save: version check, result checksum verification and the mass-delete guard. The room adds identity (the author comes from the connection, never from the message body), broadcasting, presence and history.
Client
import { collabPlugin, FolioEditor } from '@nextgensoftwares/folio-editor';
import { CollabClient, WebSocketTransport } from '@nextgensoftwares/folio-plugin-collab';
const editor = new FolioEditor({ schema, doc, layout, measurer, plugins: [collabPlugin(version, clientID)] });
const client = new CollabClient(editor, new WebSocketTransport(`wss://example.com/docs/${docId}/collab`), {
docId,
clientID, // must match collabPlugin's clientID
user: { id: user.id, name: user.name, color: '#2563eb' },
outbox: idbStore, manifest, // optional: unsaved edits survive reloads
}).start();
client.subscribe(() => render(client.snapshot())); // status, peers, follow stateFolioEditor satisfies the client's CollabEditor interface as is; HeadlessEditor is a layout-free editor for bots, tests and servers.
The client handles reconnects with exponential backoff and jitter, heartbeats, browser online/offline signals (browserConnectivity), presence that waits for the steps it refers to, follow mode (client.follow(clientID)), and the same needs-confirmation / diverged states as SyncClient (client.confirmPending()).
Server
CollabRoom is transport-independent: a gateway creates one per open document and forwards frames.
import { CollabRoom, HistoryStore } from '@nextgensoftwares/folio-plugin-collab';
import { MemoryDocumentServer } from '@nextgensoftwares/folio-sync';
const server = new MemoryDocumentServer(pmSchema, { [docId]: doc });
const room = new CollabRoom(server, docId);
// on connect: room.join(conn) conn = { send(msg), close() }
// on message: room.handle(conn, JSON.parse(frame)) (validated by parseClientMessage)
// on close: room.leave(conn)HistoryStore.forRoom(server, docId, room) records accepted steps for version history.
Realtime over WebSocket
@nextgensoftwares/folio-server-node hosts CollabRoom behind GET /docs/:id/collab (an HTTP/1.1 upgrade; authorize(req, docId, 'collab') runs first). The room uses the same persistent document server as the REST routes, so a push over the socket is validated (version, checksum, mass-delete guard) and fsynced exactly like POST /steps, and REST saves are broadcast to the room.
Who carries saves. While a document is open live, the CollabClient carries every save as steps over the socket. Don't also run a REST SyncClient on that editor: two step channels on one editor would double-apply steps (the checksum would then stop the second). If you must keep REST for something else, give it pollMs: false. The playground's live mode runs only the CollabClient; its REST mode only the SyncClient.
Joining. Everyone who opens the same document id on the same server joins that document's room: there is no pairing step. With autoConnect: false the client connects only when you call client.join() (an "invite"/"join" button).
Connection robustness.
| heartbeat | client ping every 10 s; a socket silent for 30 s, or open without a welcome for 5 s, is recycled; a connect stuck "connecting" for 10 s is retried; the server pings every 30 s and drops sockets that miss a pong |
| reconnect | exponential backoff with jitter (the playground uses 0.5 s → 30 s, ±30%); immediately on the browser's online event; never while the browser reports offline |
| fatal answers | diverged, ahead (client claims a version the server never had), gone (steps since its version were compacted: reopen), full (room cap): the client stops, closes the socket and keeps its edits in the outbox, with no reconnect loop |
| state for the UI | client.status (connecting / synced / saving / offline / …), client.connection.attempts, client.connection.retryAt, client.connection.reconnectNow() |
Measured against the reference server with two browser contexts (Playwright): the server killed for 75 s while both kept typing; each client made 6–7 connection attempts in the first 60 s (gaps 0.7, 2.1, 4.6, 9, 16 s), both showed offline with their edits pending, and when the server came back both reconnected on their next attempt, rebased, pushed and ended on the server's version with identical documents.
Roles, room size and presence policy
// client: a read-only view joins as a reader (receives edits and presence, never pushes)
new CollabClient(editor, transport, { ..., role: 'reader', autoConnect: true });
// server: room policy (CollabGateway `room: (docId) => RoomOptions`)
{
maxMembers: 50, // the 51st gets error 'full'
roleOf: (claimed, clientID, user) => (canEdit(user) ? claimed ?? 'editor' : 'reader'), // never trust the claim
readerPresence: 'viewer', // readers appear without a caret ('hidden': not at all)
readersSeePresence: true, // readers see who else is there
}A push from a reader is refused (invalid) whatever the client does. Readers appear in peers() with role: 'reader'; draw no caret for them (CollaboratorsBar shows them with a dashed ring, remoteLabel says "(viewing)"). Editing-only UI ("Name current version", restore, "Show authors") is hidden from readers unless the host allows it (createCollabPlugin({ readersGetEditingUi: true }), blamePlugin({ visibleTo: 'everyone' })).
Blame: "Show authors"
blamePlugin() is a separate, opt-in plugin:
- Show authors (toolbar) tints text by its last author; hovering shows a card (name, time, version).
- The margin beside each block shows the initials of its latest editor, with a "last edited by X, 3 min ago (vN)" tooltip. Clicking it opens that version (
onOpenVersion, default afolio:open-versionwindow event).
blamePlugin({
enabled: true,
visibleTo: 'editors', // or 'everyone' (readers too)
defaultVisible: false,
color: (author) => author.color,
onOpenVersion: (version, editor) => openHistoryAt(version),
});How it stays correct and cheap:
- Model.
Attributionis a sorted list of spans[from, to) → author, version, time, git-blame style (the last change wins). Every step maps the spans through itsStepMap(the same way remote carets are mapped, so text moved by other people's edits keeps its author) and then overwrites what it inserted or reformatted. It costs O(spans) per step, whatever the document size, and same-author neighbours merge, so a typing burst is one span. - Server.
HistoryStorekeeps the live attribution as it records steps, serves it ashistory.blame, and snapshots it with each checkpoint (a:<version>, delta-coded flat numbers). It can be rebuilt from storage withrebuildBlame(store): the snapshot plus the step log, with authors from the attribution runs. - Client.
BlameSessionloads it once, then follows the live step stream, so it never refetches. For display it also maps the spans through your unsaved steps and credits them to you. - Drawing. A page layer draws only on the pages the view has mounted, from the spans that overlap that page. Exporters skip it: attribution is not exported to PDF or DOCX.
Tests check that every typed character is attributed to the person who typed it on every client and on the server (concurrent edits, lossy links, unsaved edits in flight), and that the live and rebuilt attributions are identical.
Version history
History is append-only: restoring a version creates new steps on top of the current document; nothing is ever rewritten.
HistoryStorekeeps checkpoints as content-addressed block groups shared between versions, compact JSON, gzip-compressed whereCompressionStreamexists;compactHistoryfolds old steps while reproducing the same checksum at every kept version.HistoryClientfetches versions as deltas against what the client already holds and verifies every reconstructed version against the server's checksum.diffDocs(a, b)diffs by block identity, then by characters inside changed blocks;restoreTransaction(state, target)builds the restoring steps.- React:
VersionHistoryPanel(timeline, diff, preview, name, restore),DiffView,CollaboratorsBar(avatars, status, follow). createCollabPlugin({ promptName, showHistory, sidebar })adds "Name current version" and "Version history" toolbar items, shown only for editors with a running client. Withsidebar(defaulttrue) it also contributes two tabs to the host'ssidebarslot:collab.people(PeopleSlot: collaborators, status, follow) andcollab.history(HistorySlot: the version history panel). Pass{ onPreview }to receive version previews, orfalseto skip the tabs. Hosts render them with<SlotTabs name="sidebar">and can hide or move them like any slot item; see Customizing the UI.
Testing collaboration
LocalHub is an in-memory server with simulated links (latency, jitter, drops, duplicates, disconnects; every message JSON round-tripped), and VirtualClock + seededRandom let thousands of simulated seconds of editing by several clients run in milliseconds, reproducibly. The package's convergence tests drive random edits through faulty links and assert every client ends with the server's document.