WebSocket Protocol¶
The services/ws server hosts one room per project. Clients are typically the Doable web editor, but any Yjs-aware client can connect.
URL¶
For local dev: ws://localhost:4001/project/:projectId?token=<jwt>.
The token is a regular Doable JWT. The WS server validates it against the API (or decodes locally with JWT_SECRET) and looks up the user's role on the project.
Subprotocol¶
The wire protocol is the standard y-websocket binary protocol:
| Message type | Direction | Meaning |
|---|---|---|
messageSync (0) |
both | Yjs sync round-trip (state vector + updates) |
messageAwareness (1) |
both | Awareness state (cursor, selection, presence) |
messageAuth (2) |
server to client | Auth-related signaling |
messageQueryAwareness (3) |
client to server | Bootstrap awareness on connect |
You normally don't read this layer directly. Use y-websocket on the client and let it handle framing.
Frontend usage¶
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(
process.env.NEXT_PUBLIC_WS_URL!, // ws://localhost:4001
`project/${projectId}?token=${token}`, // path
ydoc,
);
const yFiles = ydoc.getMap("files"); // Map<path, Y.Text>
// Bind a Y.Text to Monaco
import { MonacoBinding } from "y-monaco";
const yText = yFiles.get("src/App.tsx") as Y.Text;
new MonacoBinding(yText, monacoModel, new Set([editor]), provider.awareness);
Document shape¶
The room's root Y.Doc contains:
files: Y.Map<string, Y.Text>keyed by relative file path.meta: Y.Map<string, any>for non-content metadata (last save, build status, etc.).
Awareness state¶
Each client publishes:
{
user: {
id: string,
name: string,
color: string, // hex, used for cursors
avatarUrl: string | null,
},
cursor: {
file: string,
selection: { start: number, end: number } | null,
} | null,
}
Server to API hooks¶
When a room is mutated, the WS server may call back to the API over INTERNAL_SECRET-signed HTTP for:
POST /internal/projects/:id/files-changed: debounced; triggers thumbnail regeneration and analytics events.POST /internal/projects/:id/presence: sends presence updates to non-room consumers (the workspace dashboard "active now" pill).POST /internal/design-comments/:id: persists a new design comment to PostgreSQL.
Design comment messages¶
In addition to the binary Yjs sync protocol, the WebSocket carries JSON messages for design comments. These use the same connection but are handled separately from Yjs frames.
Client to Server¶
| Type | Payload | Description |
|---|---|---|
design-comment:add |
{ content, xPercent, yPercent, selector?, pagePath?, parentId? } |
Place a new comment or reply |
design-comment:resolve |
{ commentId } |
Mark a comment as resolved |
design-comment:unresolve |
{ commentId } |
Reopen a resolved comment |
design-comment:delete |
{ commentId } |
Delete a comment |
Server to Client (broadcast)¶
| Type | Payload | Description |
|---|---|---|
design-comment:added |
Full comment object (id, user, position, content, timestamps) | A new comment was placed |
design-comment:resolved |
{ commentId, resolvedBy, resolvedAt } |
A comment was resolved |
design-comment:unresolved |
{ commentId } |
A comment was reopened |
design-comment:deleted |
{ commentId } |
A comment was removed |
All messages are JSON-encoded and wrapped in the room's message framing.
Disconnect & reconnect¶
y-websocket reconnects automatically with exponential backoff. On reconnect it sends a state vector; the server replies with the missing updates so the client converges to the latest state.