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
collabflag. Only an app whosewidget.jsondeclarescollab: truemay mint aroom-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:
| Scope | scopeId | Use |
|---|---|---|
workspace | a workspace id | Workspace-wide presence and shared cursors across the canvas. |
item | a workspace item id | A 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 islandAny 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:
| State | Meaning | Safe to edit? |
|---|---|---|
| never synced | the room has not been reached this session | no |
| synced, then dropped | reached earlier, connection since lost | yes, 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:
- load the store into a scratch
Y.Doc(never the live one) - wait for a real room sync
- diff by state-vector containment —
Y.diffUpdatecounts the stored delete set as missing history and reports divergence for healthy documents - nothing local-only → discard the staging doc and attach for ongoing writes
- local-only content → write a checkpoint of the server state first, then merge, so there is a restore point that predates the merge
- 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-indexeddbmust resolve itsyjsimport 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
readertoken (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 uppercasedjoin(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 indicatorswebrtc.*: multi-party RTC signalinguser: the top-level identity field (set it only with theidentityfromgetToken)
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.
Related
- host.documents: seed a room's starting state and keep durable version history
- host.live: 1 to N broadcast (not N to N)
- widget.json manifest: the
collabflag that gates room tokens