Skip to main content

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. The standalone server uses a custom WebSocket relay (see standalone/server/src/ws.ts); other deployments commonly use:

  • y-websocket for self-hosted WebSocket relays
  • y-webrtc for peer-to-peer
  • y-indexeddb for offline persistence (layered alongside any other transport)
  • Any HTTP/3 stream or BroadcastChannel if your room is browser-local

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.