Live Collaboration Sessions
⚠️ TODO(live-sessions): currently disabled. The shared-session feature is not fully developed and is wired off at the application boundary:
window.SESSION = new SessionSyncController()is commented out insrc/app.ts, and the companion UI pluginplugins/session-controls/is shipped with"enabled": false. The source undersrc/classes/session/remains in the tree so the work can be resumed without re-implementing transport/providers. To bring it back: uncomment the singleton wiring insrc/app.tsand flipenabled/permaLoadinplugins/session-controls/include.json. All existing callsites already usewindow.SESSION?.…(optional chain), so leaving the singleton undefined is a safe no-op for the rest of the app.
xOpat supports real-time multi-user sessions where each peer's viewport, cursor,
visualization, and opted-in module state stay in sync. Transport is WebRTC
peer-to-peer; the default signalling mode is a URL fragment exchanged out-of-band
(no backend infra required). An optional signalling relay (through the existing
HttpClient) upgrades invites to one-click.
The feature lives in core under src/classes/session/, exposed as
window.SESSION. A thin UI plugin (plugins/session-controls/) mounts the
start/join affordances into the existing Share dropdown.
Lifecycle
idle → hosting → connected → reconnecting → connected | leaving → idle
idle → joining → connected → reconnecting → connected | leaving → idle
Observable events on window.SESSION (mirrored on VIEWER_MANAGER):
| Event | Payload |
|---|---|
session-started |
{ role: SessionRole, sessionId: string } |
session-ended |
{ reason: string } |
session-peer-joined |
{ peer: SessionPeer } |
session-peer-dropped |
{ peer: SessionPeer, reason: string } |
session-role-changed |
{ role, previous } |
session-reconnecting |
{ peerId?: string } |
session-state-changed |
{ state, previous } |
session-host-lost |
{ hostUserId } (guest-only; precedes failover) |
session-bootstrap-complete |
{} (guest-only, after applySnapshot pass) |
The SessionSyncProvider contract
Every module that participates implements this contract and registers from its
constructor or pluginReady():
window.SESSION?.registerProvider({
id: 'my-feature',
scope: 'per-viewer', // or 'global'
priority: 40, // lower applies first during bootstrap
snapshot(viewer) { // bootstrap payload; called on host
return exportMyState(viewer);
},
applySnapshot(data, viewer) { // called on guest during bootstrap
return importMyState(data, viewer);
},
subscribe(emit) { // wire post-events to `emit(delta)`
const handler = (e) => emit({
providerId: 'my-feature',
viewerUniqueId: e.viewer?.uniqueId,
intentId: '', // filled in by SESSION
sourceUserId: '', // filled in by SESSION
kind: 'my-kind',
payload: serializeChange(e),
});
listenToModule(handler);
return () => stopListening(handler);
},
async applyDelta(delta, meta) {
// MUST be idempotent w.r.t. delta.intentId — the same intent may be
// re-delivered after a host failover. See "Intent idempotency".
if (meta.bootstrap) return; // bootstrap handled via applySnapshot
await applyChangeLocally(delta.payload, delta.viewerUniqueId);
},
});
Echo suppression. Providers use a local _applyingRemote flag (or
WeakMap<Owner, boolean>) that applyDelta sets before mutating state and
subscribe reads to skip emission. The cancellable *-before-* events in
modules like annotations are not hijacked for echo suppression — they stay
free for authentic user-authorization logic.
Concrete example — visualization
The visualization provider (src/classes/session/providers/visualization.ts)
broadcasts a full live-state snapshot keyed by viewer.uniqueId. The
per-viewer payload is built via UTILITIES.exportLiveVisualization(viewer)
in src/layers.js, which captures each shader layer's cache (where UI
controls like the opacity slider persist values), state
(visible/use_mode/use_blend), type, and the layerOrder. Apply uses
UTILITIES.importLiveVisualization(viewer, payload), which writes the
incoming cache/state/type onto the live shaderConfig and routes
each mutated layer through drawer._applyShaderConfigMutationRequest so
the renderer's controls re-init from the new cache. Echo suppression is
via the per-viewer viewer.__sessionApplyingRemote flag honoured by
UTILITIES._emitShaderConfigUpdate. Structural changes (viewer count,
data array, layer shape, layer type) trigger a heavy reopen via
APPLICATION_CONTEXT.openViewerWith, followed by a light pass to restore
each viewer's cache on top of the freshly built renderers.
Concrete example — annotations
The annotations module (modules/annotations/annotations.js) emits a full
per-viewer fabric snapshot on any mutation event (annotation-create,
annotation-delete, annotation-replace, annotation-preset-change,
annotation-add-comment, annotation-delete-comment, layer-added,
layer-removed). Echo suppression is via a WeakMap<FabricWrapper, boolean>.
Delta payload is what fabric.export() returns; applyDelta calls
fabric.import(payload, { inheritSession: true, history: false }).
Incremental per-annotation deltas are a v2 concern.
Role model
- View deltas (cursor, viewport) — every peer broadcasts directly; last-write-wins. No conflict is possible because each peer owns its own cursor/viewport payload.
- Mutating deltas (annotations, visualization changes, any
provider-scoped write) — guest sends an
INTENTover the DataChannel to host. Host applies locally, its post-event subscribe fires, and the resulting authoritativeDELTAis broadcast. Guest applies the returned delta with_applyingRemote=true; the outbox entry keyed byintentIdclears on round-trip.
Guest-side annotation-before-create is not cancelled by the sync layer;
the local creation proceeds and the host's broadcast DELTA re-imports the
same state on both sides. Because applyDelta is idempotent, this is safe.
Compatibility declarations
Every plugin/module declares one of the following in its include.json:
| Value | Meaning |
|---|---|
"sessionCompatible": "provider" |
Actively registers a SessionSyncProvider. Fully supported. |
"sessionCompatible": true |
Safe in a session (no cross-peer side effects) but does not sync. |
"sessionCompatible": false |
Incompatible. Start/join a session is refused while loaded. |
| omitted | Unknown. Start-session modal warns listing the element; user may proceed. |
The start-session flow scans all loaded plugins/modules:
- Any
falsefound → refuse; show an error listing the offenders. - Any undeclared found → show a confirm dialog listing them. The plugins stay on; user can proceed at their own risk.
- Otherwise → proceed silently.
Provisioning guests via join URL
The host does not ask the guest to match its plugin set — it provisions
the guest. The join URL is built from
UTILITIES.serializeApp(includedPluginsList = hostPluginIds, withCookies = false),
carrying the host's exact plugin set, the full app config snapshot,
bypassCookies=true, and permaLoadPlugins=false so no cached plugin state
leaks in. The guest's page loads exactly the host's set from scratch.
Transport
- Topology: full mesh up to 6 peers; host fans out (star) beyond that. One ordered + reliable DataChannel per pair. Heartbeats at 2 s, soft-timeout at 6 s (reconnecting banner), hard-timeout at 20 s (failover).
- Signalling — zero-infra default: host gzip+base64url-encodes the SDP
offer into
#session=<blob>. Guest opens the URL, generates an answer, pastes it back into the host's "Add peer" field. ICE is not trickled — candidates are inline in the SDP. - Signalling — optional relay: set
server.secure.sessionSharing.signalingEndpointto enable anHttpClient/callServer-backed relay that exchanges offer/answer for one-click invites.
ICE servers — no public defaults
xOpat ships with an empty ICE server list. STUN (Session Traversal Utilities for NAT) is a small service that tells a peer its own public IP:port so peers can describe themselves to each other; it does not relay media/data. TURN actually relays traffic when direct peer-to-peer fails (e.g. both peers behind symmetric NATs).
- Peers on the same LAN / host-network: no STUN needed.
- Peers on different networks: require at least a STUN server. The start modal shows an inline notice when none is configured.
- Production multi-network: add STUN (any public one, or self-hosted coturn) and optionally TURN for pathological NAT cases.
"server": {
"secure": {
"sessionSharing": {
"iceServers": [
{ "urls": ["stun:stun.example.com:3478"] },
{ "urls": ["turn:turn.example.com:3478"], "username": "u", "credential": "p" }
],
"signalingEndpoint": "session/signal" // optional relay
}
}
}
Failover
At session start, every HELLO carries a rankList = [hostUserId, ...guestUserIdsByJoinOrder].
On hard-timeout of the current host:
- All peers transition to
reconnecting; emission is paused. - Survivors exchange a small
CANDIDATE_HOST { rank, lastSeenSeq }. Highest rank wins, ties broken by lowest user id. - Promoted peer is now authoritative using its own locally-applied state
(near-complete; it had been applying every host delta). It re-meshes and
sends a fresh
HELLO+bootstrap-digest. - Peers compare digest. Mismatch → re-bootstrap from the new host.
- Pending outbox
INTENTs flush to the new host with their originalintentId— providers dedupe by id.
Old host rejoining
An old host returning after failover joins as a guest. New host remains authoritative. The old host's local state is replaced via normal bootstrap.
Solo continue & final save
The host owns "final save." When everyone else leaves, the remaining peer is by definition the host (possibly via promotion). Two cases:
- "End session" → normal xOpat save/upload flow runs; plugin lock releases.
- All other peers dropped → solo peer remains host-of-one; the Share menu shows "Save and end"; plugin lock held until the user ends the session.
Intent idempotency — a contract
A provider's applyDelta MUST be safe to call twice with the same
delta.intentId. The core's seen-intent LRU dedupes at the delivery layer,
but after a failover an INTENT flushed from an outbox may land on a new
host that has already processed the original. Providers typically achieve
idempotency by:
- Using content-addressed identifiers where possible (e.g. a server-assigned annotation id rather than a local counter).
- Tracking a bounded LRU of seen
intentIds and short-circuiting if revisited. - Applying idempotent state overwrites (the annotations
fabric.importapproach — it's a whole-viewer replace keyed by object ids).
Plugin lock
While SESSION.isActive() returns true, UTILITIES.loadPlugin(id) throws
SessionLockedError. Lock engages on entering hosting/joining and
releases on idle. The pre-existing before-plugin-load event is
non-cancellable, so the check is made explicit at the top of loadPlugin
(src/loader.ts:1919).
Side effects and plugins
Third-party plugins may wire upload-on-create hooks (e.g. an annotation that
saves to a server on annotation-create). Under the host-authoritative
model, mutations fire once — on the host's machine — so these side effects
are not duplicated across peers. Plugin authors targeting session-aware
behaviour should read meta.role in their own post-event handlers:
OSDAnnotations.instance().addFabricHandler('annotation-create', (e) => {
if (window.SESSION?.getRole?.() === 'guest') return; // guest; host will upload
uploadToServer(e.object);
});
Troubleshooting
- "No ICE configured" notice in the start modal — peers on the same LAN
will still connect. Cross-network requires
iceServersinserver.secure.sessionSharing. - "URL fragment too large" — use the paste-offer fallback in the invite modal. Typical causes: unusually long SDP due to many ICE candidates.
- Guest joined but no content appears — bootstrap payload was likely
delivered but a provider's
applySnapshotthrew. Check console; provider-level errors are caught and logged but don't re-throw. - Plugin won't load after session start — the lock is working as
intended. Leave the session first. If needed programmatically, check
window.SESSION.isActive().
See also
src/types/session.d.ts— ambient public types (SessionSyncProvider,SessionDelta,SessionIntent,SessionLockedError, …)src/classes/session/— implementation (singleton, transport, providers)src/EVENTS.md—session-*events with payload shapessrc/MULTI_VIEWPORTS.md— per-viewer contract, still applies under syncplugins/session-controls/— UI pluginmodules/annotations/annotations.js— reference provider registration