Skip to content

Latest commit

 

History

History
131 lines (99 loc) · 7.08 KB

File metadata and controls

131 lines (99 loc) · 7.08 KB

Wire protocol

Everything travels over one WebSocket per session, as JSON text frames. The shared message types live in packages/protocol and are the source of truth; the JetBrains plugin duplicates them in Kotlin and must track them.

Two roles connect to the same endpoint:

  • the host (the editor extension), authenticated by a host token, allowed to write;
  • a viewer (the browser page), authenticated by an invite cookie, read-only.

Connecting

GET wss://<server>/ws/<project> with Upgrade: websocket. <project> must match [a-z0-9][a-z0-9_-]{0,63}; each project id maps to its own Durable Object.

Auth, checked in this order:

  1. Authorization: Bearer <host token>. The connection becomes the project's host if the token exists in the HOST_TOKENS KV namespace; unknown tokens are rejected with 403. A project has at most one live host, so a second host connection closes the first with code 4000.
  2. A sb_view_<project>=<invite token> cookie. The connection becomes a viewer if the token verifies (see Invites). Anything else is rejected with 403 before the upgrade completes.

The first frame the server sends to everyone is policy:

{ "type": "policy", "maxBytes": 524288 }

Viewers then immediately receive the current file tree; the host does not:

{ "type": "tree", "paths": ["src/app.ts", "README.md"] }

Host → server

Message Shape Semantics
snapshot_begin {} Starts a full snapshot; the server begins tracking which paths the snapshot mentions.
file_put { path, hash, content } Upserts one file. If hash equals the stored hash, subscribers are not notified. A path new to the tree broadcasts tree_update to every viewer.
file_delete { path } Removes a file and broadcasts tree_update.
snapshot_end {} Ends the snapshot. Files that were present before snapshot_begin but absent from the snapshot get deleted (stale cleanup).
mint_invite { ttlSeconds } Mints a signed invite; the server replies with invite.
rotate_view_secret {} Invalidates every invite and viewer session issued so far and disconnects current viewers (close 4001); the server replies with ok.
delete_project {} Sends project_deleted to viewers, wipes storage (files and the view secret), and closes every connection with 4001.

Every file_put is validated against the policy (path shape, size cap, text content, hard blocks) and rejected with an error frame if it fails. Hard blocks can never be overridden. Messages on one connection are processed strictly in order.

Viewer → server

Message Shape Semantics
subscribe { path } Subscribes to one file. The server replies with a full file frame and pushes a new one on every content change. A new subscribe replaces the previous subscription, so a viewer follows at most one file.

Role violations (a viewer sending host messages, and vice versa) get an error frame. Ten consecutive invalid messages from a viewer close its connection with code 1008. The host's connection is never closed for invalid messages: losing the live broadcast over a malformed frame would hurt the viewers more than it protects the room.

Heartbeat. A viewer also sends the bare text frame ping (not JSON) every HEARTBEAT_INTERVAL_MS, and the server answers pong. The reply comes from the Durable Object's WebSocket auto-response, so it never wakes a hibernating room and never counts as an invalid message. No pong within HEARTBEAT_TIMEOUT_MS means the link is dead even if the socket still reports itself open, as it does for minutes after a network drop. The viewer then drops that socket and reconnects. The same timeout bounds a hanging handshake.

Server → client

Message Sent to When
policy both First frame after connect.
tree viewers Right after policy, full list of paths.
tree_update all viewers { added, removed } on any tree change.
file the subscriber { path, hash, content } on subscribe and on every change of that file.
invite the host { url, expiresAt }, where url is site-relative: /<project>?token=<invite token>.
ok the host Confirms rotate_view_secret.
error sender { message } for any invalid message.
project_deleted viewers Before the sockets close on delete_project, or when the idle-TTL alarm collects the project (see self-hosting.md).

Close codes: 4000 means the host was replaced by a newer host connection; 4001 means invites were rotated or the project was deleted; 1008 means too many invalid messages.

Invites

An invite token is "<expiryUnix>.<hmac>" where the MAC is HMAC-SHA-256("<project>.<expiryUnix>", viewSecret) in lowercase hex. The view secret is random per project, created on first use and persisted in the Durable Object. Both rotate_view_secret and delete_project replace it, which is what invalidates old tokens.

The viewer never sees the WebSocket handshake details. Opening https://<server>/<project>?token=... verifies the token, 302-redirects to the clean URL, and sets the sb_view_<project> cookie (HttpOnly; Secure; SameSite=Lax). The cookie doesn't hold the invite token itself: it holds a session token signed the same way, with an expiry VIEWER_SESSION_SECONDS (400 days) out. So the invite's own expiry only limits how long the link can be opened. A viewer who opened it in time stays in the project, and a tab reopened the next day works without a token in the URL, until the host rotates the view secret (Revoke Invite Links) or deletes the project.

Tokens that don't match ^\d+\.[0-9a-f]{64}$ are rejected before any storage is touched.

File policy

Defined in packages/protocol/src/policy.ts and whose size cap is published to every client as the policy frame. Any text file passes, whatever its name or extension, as long as it clears all of:

  • maxBytes: 512 KiB per file;
  • text: valid UTF-8 with no NUL bytes (git's binary heuristic), so binaries don't broadcast;
  • not under .git/ — the one hard block, since git internals are never project content.

There is no built-in list of secret files: .env, keys and tokens are broadcast unless the project's .gitignore excludes them, so a teaching project can share a demo .env on purpose.

Clients apply the same policy before sending (plus the project's .gitignore); the server enforces it again on every file_put.

Timing constants

  • DEBOUNCE_MS = 300: editors coalesce change bursts per file for this long before re-reading.
  • RECONNECT_MIN_MS = 1000, RECONNECT_MAX_MS = 30000: client reconnect backoff bounds.
  • HEARTBEAT_INTERVAL_MS = 20000, HEARTBEAT_TIMEOUT_MS = 10000: viewer ping cadence and how long a ping (or a WebSocket handshake) may go unanswered before the connection is treated as dead. A viewer that goes back online retries immediately instead of waiting out its backoff.