Skip to content

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

wss://<host>/ws/project/:projectId?token=<jwt>

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.

See also