Robutler

host.collab

host.collab is the App SDK surface for realtime multi-user rooms backed by the Hocuspocus collab pod and Yjs CRDTs. The host checks the workspace ACL, then mints a short-lived JWT your app uses to dial the pod with the Yjs provider of your choice (@hocuspocus/provider). The host does not bundle a Y.Doc for you: you bring your own CRDT runtime and open the socket with the token.

Collab is gated by the manifest collab flag. Only an app whose widget.json declares collab: true may mint a room-scope (code-based lobby) token, and the registry uses that flag to authorize the mint. See the manifest reference.

API

interface CollabNamespace {
  getToken(scope: 'workspace' | 'item' | 'room', scopeId: string): Promise<CollabTokenResult>;
  getTurnCredentials(): Promise<{ turn: TurnCredentials | null }>;
  room(contentId: string): CollabRoomApi;
}

interface CollabTokenResult {
  token: string;          // pass to new HocuspocusProvider({ token })
  wsUrl: string;          // absolute wss:// URL to dial
  roomId: string;         // Hocuspocus document name: new HocuspocusProvider({ name: roomId })
  expiresAt: string;      // ISO-8601 token expiry
  identity: ParticipantIdentity; // write into awareness.setLocalStateField('user', identity)
}

interface ParticipantIdentity { id: string; displayName: string; color: string }

interface CollabRoomApi {
  generateCode(len?: number): string;          // fresh shareable code (default len 6, charset A-Z/2-9)
  join(code: string): Promise<CollabTokenResult>; // token for the <contentId>:<code> lobby
}

interface TurnCredentials {
  username: string;
  credential: string;
  urls: string[];   // pass straight into RTCIceServer.urls
  expiresAt: number; // wall-clock unix-seconds
}

Room scopes

getToken(scope, scopeId) keys off one of three scopes:

ScopescopeIdUse
workspacea workspace idWorkspace-wide presence and shared cursors across the canvas.
itema workspace item idA per-item Yjs subdoc, one room per canvas item.
room<contentId>:<roomCode>A code-based lobby under a collab-enabled app's content id.

The document name on the wire is the returned roomId.

Join a room

import { HocuspocusProvider } from 'https://esm.sh/@hocuspocus/provider';
import * as Y from 'https://esm.sh/yjs';

await host.ready();

const { token, wsUrl, roomId, identity } = await host.collab.getToken(
  'item',
  host.workspace.itemId,
);

const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({ url: wsUrl, name: roomId, token, document: ydoc });

// Identify yourself in awareness so peers see your name + cursor color.
provider.awareness.setLocalStateField('user', identity);

Tokens are short-lived (expiresAt). Re-mint when you reconnect after an expiry.

Never open the editor before the room has synced

This is the single most important rule on this page, and the easiest to get wrong. A Y.Doc lives in tab memory. Until the provider has synced at least once, your local doc is an island: it looks like the document, it is empty or partial, and nothing typed into it has anywhere durable to go.

A boot that waits for sync with a timeout is the trap:

// WRONG — the timeout is the bug, not a safety net.
await Promise.race([once(provider, 'synced'), sleep(4000)]);
mountEditor();   // may now be editing an island

Any client whose room never arrives (dead route, a 502 in front of the collab service, WebSocket blocked by a proxy, a backgrounded tab whose timers are throttled, a rate-limited token mint) falls through the timeout and mounts a fully editable surface over an empty doc. Everything the user types is discarded on the next reload, and a cold-seed step can duplicate the body when the room finally merges in. Wait for the real event instead:

// RIGHT — resolves only on a real sync; show a "connecting" state meanwhile.
await new Promise((res) => provider.on('synced', res));
mountEditor();

The rule to hold onto: accept edits only where they have a durable home — a synced room, or a local store (below). If you have neither, render a connecting/cannot-reach state where the editor would be. A gate that stays shut is recoverable; a gate that lies is not.

Distinguish these two states in your UI, because they make opposite promises:

StateMeaningSafe to edit?
never syncedthe room has not been reached this sessionno
synced, then droppedreached earlier, connection since lostyes, Yjs replays on reconnect

Telling a user "your edits will sync when you reconnect" is only true in the second case. Saying it in the first is a lie that costs them their work.

Offline edits (y-indexeddb)

Adding a local store makes offline edits durable across a reload, and lets the gate above open while disconnected. Do not attach it straight to the live doc:

// WRONG — a stale local copy merges back and resurrects deleted content.
new IndexeddbPersistence(roomId, ydoc);

Yjs has no notion of "this local copy is obsolete"; it merges whatever it finds. A store holding an old copy of a document that was since edited (or restored from a version) merges that copy back in, and the user watches text they deleted reappear. Load it into a staging doc, and merge only once you know what actually diverged:

  1. load the store into a scratch Y.Doc (never the live one)
  2. wait for a real room sync
  3. diff by state-vector containmentY.diffUpdate counts the stored delete set as missing history and reports divergence for healthy documents
  4. nothing local-only → discard the staging doc and attach for ongoing writes
  5. local-only content → write a checkpoint of the server state first, then merge, so there is a restore point that predates the merge
  6. local-only content older than your window (a week is reasonable) → do not auto-merge at all; offer it as a separate copy

Step 6 matters because a large stale merge interleaves damage into the text rather than layering it on top, which no checkpoint really rescues.

First-party apps use /widgets/shared/collab-offline.js, which implements all six steps; createOfflineLayer({ Y, name: tok.roomId, ydoc, enabled }) is the whole surface. Two constraints when you wire it:

  • y-indexeddb must resolve its yjs import to the same module URL your app imports, or you get two Yjs instances and CRDT identity silently breaks. Put both in the page import map.
  • Skip the store for a reader token (see below). Persisting a viewer's edits builds a private copy that can never reach the room.

IndexedDB is a durable cache, not a guarantee: browsers evict it under storage pressure, private windows may not persist at all, and quota errors surface as failed writes. Say "saved on this device", never "safe".

Respect the token's role

getToken returns a token whose role is reader when the viewer cannot write the underlying content (a view-only share link, for example). The collab service drops doc writes from a reader while still delivering presence, so a reader's session looks completely healthy from inside the app.

If you leave the editing surface enabled for them, they will type into something that reaches nobody and vanishes on reload. Two things are required, and /widgets/shared/collab-readonly.js implements both so every app behaves and reads identically:

1. Neutralise the write surface. Pick the chokepoint your app actually has:

import {
  isReaderToken, blockWrites, freezeYTypes,
  mountReaderBanner, createBlockedToast, READER_NOTICE,
} from '/widgets/shared/collab-readonly.js';

if (isReaderToken(tok)) {
  const toast = createBlockedToast(document.body, READER_NOTICE);

  // (a) app exposes a model api → name the mutating methods explicitly
  blockWrites(model, ['set', 'insertRows', 'deleteRows', /* … */], toast);

  // (b) app mutates Y types directly (canvas apps) → freeze the shared types
  freezeYTypes([objects, edges, strokes, meta], toast);

  // (c) app wraps a text editor → use its own read-only switch
  editor.setEditable(false);

  mountReaderBanner(document.body, READER_NOTICE);
}

freezeYTypes shadows the mutating methods on those instances. Remote updates, cold-seed and offline merges are unaffected — they go through Yjs internals (Y.applyUpdate), not these methods — so a reader still receives and renders everything live. They simply cannot originate a change.

The method list in blockWrites is deliberately explicit rather than inferred: a guard that silently misses a mutation path is worse than no guard, because it looks like it works.

2. Say why, persistently. Use READER_NOTICE rather than your own wording, so the explanation is identical across apps. It is plain language on purpose: a viewer should not have to know what a token or a room is.

Code-based lobby rooms

host.collab.room(contentId) is sugar for shareable lobby rooms under a collab-enabled app. Generate a code, share it, and join:

const lobby = host.collab.room(myContentId);
const code = lobby.generateCode();          // e.g. 'K7P2QX'
const { token, wsUrl, roomId } = await lobby.join(code); // code is uppercased

join(code) mints a token for the <contentId>:<code> room. This path is what the collab manifest flag gates: a room token cannot be used to reach arbitrary item or workspace rooms.

TURN credentials for WebRTC

If your app does WebRTC (a video huddle, a voice call), call getTurnCredentials only when the call actually starts, not on mount. It triggers a TURN-provider round-trip, so it is deliberately not bundled into getToken (which runs on every collab mount).

const { turn } = await host.collab.getTurnCredentials();

const pc = new RTCPeerConnection({
  iceServers: turn
    ? [{ urls: turn.urls, username: turn.username, credential: turn.credential }]
    : [], // null → no relay provider configured; run STUN-only
});

turn is null when no relay provider is configured; fall back to STUN-only in that case.

Reserved awareness namespaces

When you publish awareness updates, do not spoof host-owned keys. The collab pod drops inbound updates from app origins that touch:

  • presence.*: canvas cursors and follow-me (host chrome)
  • comment.*: thread typing indicators
  • webrtc.*: multi-party RTC signaling
  • user: the top-level identity field (set it only with the identity from getToken)

Errors

  • service_unavailable: the collab pod or Redis is briefly unavailable; retry with backoff.
  • ACL failures and token-reuse replay rejections surface per the error codes.

On this page