Real-time collaboration
Apollon's collaboration layer is built on Yjs. Set collaborationEnabled: true in the constructor options, then wire your transport. The editor doesn't care whether messages travel over WebSocket, WebRTC, BroadcastChannel, or anything else — it hands you opaque base64 strings to ship and accepts them back.
const editor = new ApollonEditor(container, {
type: UMLDiagramType.ClassDiagram,
collaborationEnabled: true,
})
// Outbound: the editor calls your callback when it has bytes to send.
editor.sendBroadcastMessage((base64) => transport.send(base64))
// Inbound: forward every received frame back to the editor.
transport.onMessage((base64) => editor.receiveBroadcastedMessage(base64))
A complete transport (BroadcastChannel)
A full, working transport using the browser's BroadcastChannel — two tabs of
the same origin edit one diagram live. Swap BroadcastChannel for your
WebSocket/WebRTC channel; the editor calls are identical.
import { ApollonEditor } from "@tumaet/apollon"
export function connectCollaboration(editor: ApollonEditor, room = "apollon") {
const channel = new BroadcastChannel(room)
// Register the outbound sink before anything else — broadcasts are no-ops until set.
editor.sendBroadcastMessage((base64) => channel.postMessage(base64))
// Forward every inbound frame to the editor; it demuxes by message type.
channel.onmessage = (e: MessageEvent<string>) =>
editor.receiveBroadcastedMessage(e.data)
// On (re)connect: pull peers' document + presence, then push our own.
channel.postMessage(ApollonEditor.generateInitialSyncMessage())
channel.postMessage(ApollonEditor.generateInitialAwarenessSyncMessage())
editor.broadcastFullState()
return () => channel.close()
}
Who's online
subscribeToCollaboratorChanges fires with the current collaborators — each a
CollaboratorInfo ({ id, name, color, imageUrl?, clientIds, isLocal }):
import { ApollonEditor, type CollaboratorInfo } from "@tumaet/apollon"
function watchCollaborators(editor: ApollonEditor) {
const subId = editor.subscribeToCollaboratorChanges(
(collaborators: CollaboratorInfo[]) => {
const remote = collaborators.filter((c) => !c.isLocal)
console.log(`${remote.length} other editor(s) online`)
}
)
return () => editor.unsubscribe(subId)
}
Backing transports
Any Yjs-compatible transport works. With the frame API above you relay the editor's messages yourself; providers that work on a Y.Doc attach through a host-owned document. The standalone server uses a custom WebSocket relay (see standalone/server/src/ws.ts); other deployments commonly use:
y-websocketfor self-hosted WebSocket relaysy-webrtcfor peer-to-peery-indexeddbfor offline persistence (layered alongside any other transport)- Any HTTP/3 stream or BroadcastChannel if your room is browser-local
Bring your own Y.Doc and awareness
The providers above, and hosts that already run a Yjs session of their own, work on a Y.Doc object rather than on relayed frames. Hand the editor that document, and optionally the awareness bound to it, through the collaboration option:
import * as Y from "yjs"
import { WebsocketProvider } from "y-websocket"
const ydoc = new Y.Doc()
const provider = new WebsocketProvider(serverUrl, roomName, ydoc)
// Mount only once the document has its existing content.
provider.once("sync", () => {
const editor = new ApollonEditor(container, {
collaboration: {
ydoc,
awareness: provider.awareness,
},
})
})
The editor then keeps its diagram in your document and shows cursors, selections and presence from your awareness. You do not call sendBroadcastMessage or receiveBroadcastedMessage: the provider syncs the document, and the editor emits no frames unless a send function is registered.
Rules that apply to a host-owned document:
- Mount after the document has loaded. Construct the editor only after the provider's first sync, and after an asynchronous persistence provider such as y-indexeddb has finished loading. On a document that holds no diagram the constructor writes a title, a diagram type and an id. If the existing content arrives later, those writes compete with the stored values and can replace them.
- One active editor per document. Mount only one editor on each
Y.Docobject. A second mount throws until the first editor is destroyed. Each peer can use its own document connected through a provider. - The document is the source of truth. If it already holds a diagram, the editor shows that diagram and the
modeloption does not write to it.modelonly seeds a document that holds no diagram yet. - Read-only layout stays local. Content can expand nodes in the displayed model without writing that geometry to the document.
editor.modelincludes these display dimensions;readModelFromYDocpreserves the document geometry. - The editor never destroys what it did not create.
destroy()leaves your document and your awareness alive. It removes the fields the editor wrote to the awareness (cursor,viewport,selectedElementId,followingClientId,draggingNodes) and keepsuser. - Destroy the editor before the document. The editor's observers hold on to the document until
destroy()runs. - Identity comes from your awareness. Peers are shown from the
userfield ({ name, color, id?, imageUrl? }) of each awareness state. Set it yourself, or passcollaboration.userand the editor writes it for you. - Passing
awarenessenables collaboration and its visuals by default; theshow*toggles still apply.awarenessrequiresydocand must be bound to it. - Do not use the transaction origin
"store"for your own writes to the document. The editor reserves it for local edits and ignores it when syncing its view. - Writes from the model helpers count as remote changes. A mounted editor renders them, and they never enter a user's undo history.
- The interactive selection stays local to an editor. The selection of the Highlight view is not synced between peers, with or without a host document. The editor reads it from the document once when it mounts on an existing diagram, and writes it once when it seeds a document from
model. Changes made in the Highlight view afterwards are not written to the document, so a host that saves withreadModelFromYDocdoes not capture them; take them fromeditor.modelinstead. A later host write does not refresh the selection of a mounted editor. - Other shared types in the same document are left alone, so the host can keep its own data next to the diagram. The editor uses the top-level maps
nodes,edges,assessmentsanddiagramMetadata.
Reading and writing a Y.Doc without an editor
A host sometimes has to turn a model into a Yjs document, or back, where no editor runs: when it loads a file into a shared document before the UI exists, when it saves, or on a server. The DOM-free entry point @tumaet/apollon/model converts between a model and a document in the layout the editor uses:
import {
importDiagram,
writeModelToYDoc,
readModelFromYDoc,
hasModelInYDoc,
clearModelFromYDoc,
} from "@tumaet/apollon/model"
// load: file content into the shared document
writeModelToYDoc(ydoc, importDiagram(JSON.parse(fileContent)))
// save: shared document back to file content
const model = readModelFromYDoc(ydoc) // null while the document holds no diagram
| Function | Description |
|---|---|
writeModelToYDoc(ydoc, model, origin?) | Writes a current model, replacing any diagram in the document. Synchronous, one transaction. Normalize older formats with importDiagram first. |
readModelFromYDoc(ydoc) | Returns the diagram as a UMLModel, or null if there is none. Elements are ordered by id, so every peer reads the same result. |
hasModelInYDoc(ydoc) | Whether the document holds a diagram. An empty diagram of a chosen type counts. |
clearModelFromYDoc(ydoc, origin?) | Removes the diagram and leaves other shared types alone. |
For untrusted files, enforce size limits and validate the normalized model against the model schema. importDiagram converts supported formats; it does not fully validate model data.
The model id and interactive block are stored in the document as well, so a read after a write returns the same model. The writes run under the transaction origin MODEL_DOC_ORIGIN unless you pass your own origin.
Awareness (cursors, selections, follow)
Awareness state — who's online, where their cursor is, what they have selected, and where their viewport sits — rides on the same channel as document updates. The editor manages awareness internally; you don't need to wire anything beyond sendBroadcastMessage / receiveBroadcastedMessage.
The presence bar, cursors, selection highlights, and viewport-following are toggled per-feature on the collaboration option:
const editor = new ApollonEditor(container, {
collaboration: {
enabled: true,
user: { name: "Ada", color: "#1c7ed6" },
showPresence: true, // avatar bar (top-right)
showCursors: true, // live remote cursors
showSelectionHighlights: true, // highlight peers' selected elements
showFollow: true, // click a peer's avatar to mirror their viewport
},
})
When showFollow is on, clicking a collaborator's avatar follows their viewport. The follower sees the editor framed in that person's color and a banner naming them with a Stop button; the followed user sees a "followed by N" badge. Any local pan/zoom hands control back and stops following.
Server-side integration
If you want to drive the wire protocol from a Node server (integration tests, headless conversion, replication), use the unstable @tumaet/apollon/internals subpath:
import {
createHeadlessSync,
MessageType,
type YjsSync,
} from "@tumaet/apollon/internals"
/internals is explicitly not covered by SemVer. The standalone server's integration tests pin against it.