office-engine

Real-time collaboration

Everyone who opens the same base file in the same room edits it together, like Google Docs: changes appear live, collaborators show up as avatars and their position is drawn in their color (DOCX caret and selection, XLSX selected range, PPTX selected shape). Edits made while offline sync on reconnect.

bun run collab   # reference hub on :8787 (DATA=./collab-data or S3_BUCKET=… persists rooms)
bun run dev      # playground: open a file → "Share live" → send the link
// open the room's latest snapshot and join from it: only later edits are replayed
const { snapshot } = await (await fetch(`https://hub.example.com/rooms/${id}`)).json()

<DocViewer
  src={`https://hub.example.com/rooms/${id}/file?seq=${snapshot}`}
  editable
  collab={{ transport: 'wss://hub.example.com/collab', room: id, base: snapshot, user: { name: 'Ann' } }}
/>

base must be the sequence number of the file src points to. Without it the client assumes the original upload (/rooms/:room/file without seq) and replays the whole history.

Without React: mount(el, { src, collab }), <office-viewer src="…" collab="wss://…/collab" room="doc-42" user="Ann" base="42">, or startCollab(client, options) on a DocumentClient (@p10node/office-engine/collab, no React).

How it works

FormatShared asSame thing edited at once
DOCXtext splices per paragraph, paragraph and table statescharacters merge inside a paragraph; split / join / paragraph format: last writer wins
XLSXcell values, formats, layout, fill / sort, row & column structurelast writer wins per cell and property; inserted / deleted rows and columns shift the other edits
PPTXshape text, shape edits, slide listlast writer wins per shape

Storage, snapshots and version history

Saving an edit never rewrites the document:

RoomStore works on any key → bytes store (BlobStore: get / put / list / delete):

KeyContent
{room}/head.jsonlatest snapshot: { epoch, seq, size, name, type, time }
{room}/{epoch}/snap/{seq}.binthe document at seq (0: the uploaded file)
{room}/{epoch}/snap/{seq}.json / .ctxsnapshot metadata / merge context
{room}/{epoch}/ops/{first}-{last}.jsonlordered batches, one segment per flush

Uploading a new file starts a new epoch and deletes the previous one.

Reference server

VariableDefaultMeaning
PORT8787HTTP + WebSocket port
DATA–store rooms in this directory
S3_BUCKET–store rooms in S3; credentials from S3_REGION, S3_ENDPOINT, S3_ACCESS_KEY_ID… (or AWS_*), see Bun.S3Client
PREFIX–key prefix in the store
SNAPSHOT_EVERY500batches between snapshots (0: only when a room goes idle)
KEEP0snapshots kept per room; 0 keeps all (full history), otherwise older snapshots and their batches are deleted
RequestResult
PUT /rooms/:room/filesets the room's document (body = file, header X-File-Name) and starts a new history
GET /rooms/:room{ name, size, seq, snapshot, peers }
GET /rooms/:room/file?seq=Nthe document at N (Range support, header X-Collab-Seq); between snapshots it is built on demand
GET /rooms/:room/filethe uploaded file (seq 0)
GET /rooms/:room/history?after=N{ seq, snapshots: [{ seq, size, time }], edits: [{ seq, time, user }] }
ws://…/collabhub protocol (src/collab/protocol.ts)

Your own backend

import { CollabHub, RoomStore, compact, type BlobStore } from '@p10node/office-engine/collab'
import { DocumentClient } from '@p10node/office-engine/core'

const store = new RoomStore(myBlobStore)              // e.g. S3: get / put / list / delete
const hub = new CollabHub({
  onBatch: (room, b) => store.append(room, b),
  loadLog: (room) => store.load(room),                 // may return a promise
  onIdle: (room) => void takeSnapshot(room),
})

async function takeSnapshot(room: string) {
  const r = hub.rooms.get(room)
  const head = await store.head(room)
  if (!r || !head || r.seq <= head.seq) return
  const client = await DocumentClient.open(new Blob([(await store.file(room, head.seq))!]), { worker: false })
  const snap = await compact(client, { from: (await store.snapshot(room, head.seq))!, batches: r.log.filter((b) => b.seq > head.seq) })
  const file = new Uint8Array(await (await client.call('save')).arrayBuffer())
  client.destroy()
  if (await store.save(room, head, { ...snap, sites: r.nextSite }, file)) hub.snapshot(room, snap)
}

Limitations