Real-time Collaboration¶
Multiple people can edit the same Doable project simultaneously, the same way Google Docs or Figma works. Cursors, selections, and edits sync in real time. The mechanism is Yjs CRDTs over WebSockets.
Pieces involved¶
| Piece | Role |
|---|---|
| Y.Doc | A CRDT document. One per Doable project. Holds a Y.Map of files, each containing a Y.Text for the file content. |
| Awareness | Yjs side-channel for ephemeral state (cursor positions, selections, user color/name). |
y-websocket (client) |
The Monaco editor uses y-monaco + y-websocket to bind a Y.Text to the editor model. |
services/ws |
Custom WebSocket server (Hono + ws) that hosts one room per project. |
services/api |
Authenticates connections, persists snapshots, notifies on AI-driven file writes. |
Wire-level flow¶
Browser A Browser B
│ │
│ WSS /ws/project/:projectId?token=… │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ services/ws ── Yjs sync + awareness │
└────────────────┬─────────────────────────┬───────────────┘
│ persist on idle │ /internal/ws/notify
▼ ▼
PROJECTS_ROOT/… services/api ── recompute thumbnails,
broadcast file events
- Each browser opens
wss://…/ws/project/:projectId?token=<jwt>. - The WS service verifies the JWT against the API (or decodes locally with
JWT_SECRET) and looks up the project's room, creating it if it's the first connection. - Yjs sync messages flow bidirectionally; the server merges updates into the room's authoritative
Y.Doc. - Awareness updates (cursor moves) are broadcast without persistence.
- After a debounce window with no edits, the WS service writes affected files to disk under
PROJECTS_ROOT/<projectId>/. - When the AI edits a file (via the
docoretool path through the API), the API: - Writes the file directly to disk.
- Calls
services/wsoverINTERNAL_SECRET-signed HTTP to rebroadcast the new content into the room, so collaborators see the AI's changes appear in their editor.
Source map¶
- WS server:
services/ws/src/index.ts, Hono bootstrap and upgrade handler. - Message routing:
services/ws/src/message-handler.ts. - Rooms & presence:
services/ws/src/collaboration/,services/ws/src/rooms/. - Frontend binding:
apps/web/src/modules/editor/: Monaco +y-monaco+y-websocket. - AI to WS notification:
services/api/src/ai/yjs-bridge.ts. - Design comments:
apps/web/src/modules/editor/visual-edit/sticky-notes/: comment pins, threads, overlay layer.
Design comments¶
Design comments are pin-based annotations on the visual preview canvas, modeled after Figma's comment system. They flow through the same WebSocket room as code collaboration but use a separate message channel.
Data model¶
Each comment is stored in design_comments (PostgreSQL) with:
| Column | Type | Purpose |
|---|---|---|
id |
UUID | Primary key |
project_id |
UUID | FK to projects |
user_id |
UUID | Author |
display_name |
text | Author name shown on pin |
user_color |
text | Hex color for the pin |
x_percent, y_percent |
real | Position as percentage of canvas dimensions |
selector |
text | CSS selector of the target element (optional) |
page_path |
text | Route path the comment was placed on |
content |
text | Comment body |
parent_id |
UUID | Self-referencing FK for threaded replies |
resolved |
boolean | Whether the comment is resolved |
Message flow¶
User A places comment
│
├─ WS: design-comment:add ──▶ services/ws ──▶ broadcast design-comment:added
│ │
│ └─▶ POST /internal/design-comments/:projectId
│ │
│ ▼
│ PostgreSQL persist
│
└─ User B receives design-comment:added ──▶ pin appears on their canvas
The same pattern applies for resolve, unresolve, and delete actions.
Conflict resolution¶
CRDTs are conflict-free by definition: every operation is commutative and associative. Two simultaneous edits at the same position end up in a deterministic order, identical on every client.
This means:
- ✅ No locks. No "someone else is editing this file" modal.
- ✅ Offline edits sync automatically when reconnecting.
- ⚠️ It is theoretically possible for two users + the AI to all change overlapping lines at the same instant. The result is well-defined but may be semantically ugly. The editor never breaks, but the code may need a quick fixup. In practice this is extremely rare.
Persistence¶
- Live state lives in memory inside
services/ws. - Snapshots are written to
PROJECTS_ROOT/<projectId>/on a debounce. - History isn't kept by Yjs itself; for that, Doable uses the per-project Git store (
services/api/src/version-control/). Every meaningful change creates a commit you can browse and roll back from the UI.
Scaling notes¶
- A single WS process handles thousands of small rooms easily.
- For multi-replica deployments, set
REDIS_URLand Doable's KV store will share room to presence state across replicas. (Yjs sync itself is room-affine; sticky sessions or a Y-redis adapter is required to scale a single hot room across replicas; for typical workloads this is unnecessary.)
Disabling collaboration¶
If you only ever expect single-user use, you can:
- Skip running
services/ws(the editor falls back to local-only mode). - Set
NEXT_PUBLIC_WS_URL=empty; the editor will warn about presence being unavailable but otherwise work.
See also¶
- Architecture Overview.
- API: WebSocket Protocol: message types on the wire.