-
Notifications
You must be signed in to change notification settings - Fork 0
Relay Hub & WebSocket Frame Routing
The relay is a standalone ESM Node service under relay/. relay/src/index.mjs composes an HTTP server, a JSON-backed auth/invite store, and a WebSocket hub. The hub authenticates every socket, keeps in-memory host/client registries, pairs clients to hosts, and forwards opaque frames. It does not decrypt, inspect, transform, or persist terminal payloads; its visibility is limited to routing metadata and peer public keys.
| File | Responsibility |
|---|---|
relay/src/index.mjs |
Composition root. Loads config, opens the store, creates the hub and admin handler, attaches WebSocket upgrade handling, starts HTTP listening, and emits count-only metrics. |
relay/src/hub.mjs |
The relay core. Owns the ws server, connection registry, hello authentication, invite redemption, peer pairing, frame routing, offline client buffering, and stats. |
relay/src/store.mjs |
JSON store for users and invites, with atomic writes via temp-file + rename, invite creation/redemption/revocation, quota/TTL/max-use rules, and StoreError codes. |
relay/src/admin.mjs |
HTTP admin surface on the same server: /healthz without auth, then bearer-token /admin/* routes for stats, invite revocation, and user removal. |
relay/src/github-auth.mjs |
Provides hashToken, used by the hub to compare a host token against the stored tokenHash. |
The hub’s normal call chain is:
startServer → loadStore → createHub → http.createServer → server.on('upgrade') → hub.handleUpgrade → ws connection → hello → handleHello → helloHost / helloClient → routeFrame → deliver.
flowchart LR
subgraph RelayProcess[relay process]
HTTP[HTTP server / index.mjs]
ADMIN[admin handler]
HUB[WebSocket hub / hub.mjs]
STORE[(store.json users + invites)]
end
HTTP --> ADMIN
HTTP -->|upgrade| HUB
HUB -->|invite create/redeem persist| STORE
HOST[host socket] <-->|hello, peers, frame, direct-offer| HUB
CLIENT[client socket] <-->|hello, peers, frame, direct-offer| HUB
HUB -->|from host:<login>| CLIENT
HUB -->|from client:<login>| HOST
HUB -. frame queue while client offline .-> CLIENT
Key nodes: the HTTP server handles admin requests and delegates upgrade events to the hub; the hub owns all WebSocket state; the store only persists users and invites; host and client sockets exchange control frames and routed payload frames; offline buffering applies only to frame messages for disconnected clients.
startServer(config) performs the wiring in index.mjs:
- Creates
RELAY_DATA_DIRif needed. - Opens
<dataDir>/store.jsonthroughloadStore, defaulting to{ users: [], invites: [] }. - Defines
persist()assaveStore(storeFile, store). - Creates the hub with
createHub({ store, requireAuth: true, devAuth, persist }). - Creates the admin handler with the same store, hub,
adminToken, andpersist. - Starts
http.createServer(handler)and routesupgradeevents tohub.handleUpgrade. - Starts a 10-second metrics interval that logs only when
framesRelayedchanged: frame count, byte count, connected hosts, connected clients, buffered count.
Configuration defaults and validation:
| Env | Default | Notes |
|---|---|---|
PORT |
8788 |
HTTP/WebSocket listen port. |
RELAY_BIND |
127.0.0.1 |
Bind address. |
RELAY_DATA_DIR |
./data |
Store directory. |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET
|
empty | OAuth device-flow config. |
ADMIN_TOKEN |
empty | Required for /admin/* unless RELAY_DEV_AUTH=1. |
RELAY_DEV_AUTH |
off | Dev bypass; forbidden when NODE_ENV=production and allowed only on loopback binds. |
isLoopbackBind accepts localhost, ::1, and 127.x.x.x. Invalid dev config or a missing admin token causes the process to refuse startup.
The hub keeps all live routing state in two in-memory maps:
-
hosts: keyed byhost:<login>for normal hosts orhost:dev-<login>under dev auth. Each value is{ kind: 'host', id, login, ws, pub }. -
clients: keyed byclient:<login|invite>. Each value is{ kind: 'client', id, login, ws, pub, inviteCode, hostSessionKey, queue }.
Important state rules:
- Host disconnect removes the host registry entry, but only if the closing socket is still the registered socket.
- Client disconnect keeps the client session and sets
ws = null, allowing offline frame buffering and later invite resume. - The offline queue is per client and capped at
OFFLINE_QUEUE_CAP = 100; overflow drops the oldest queued message. - Counters
framesRelayedandbytesRelayedare in-memory only. -
stats()reports connected hosts/clients, relayed frames/bytes, total buffered messages, and active invite count. - Users and invites are persisted; sockets, queues, and session maps are not.
Every socket must send hello as its first valid message. A non-hello first message closes the socket with AUTH.
helloHost requires a non-empty login. Outside dev auth, it finds the user by login and requires:
-
requireAuthis true in the composition root. - The user exists.
-
msg.tokenis a string. -
user.tokenHash === hashToken(msg.token).
If a second host connects with the same login, the old host receives { t: 'error', code: 'REPLACED' } and is closed. The new socket becomes host:<login>. The hub replies with { t: 'peers', peer: null }, indicating registration with no paired client yet.
Under devAuth, the login is prefixed with dev-, and the host ID becomes host:dev-<login>.
helloClient requires a non-empty invite. The client ID is derived from msg.login if present, otherwise from the invite string.
There are two paths:
-
Resume: If an existing client session has the same
clientIdandinviteCode === msg.invite, the hub reattaches the socket, updatespub, sendspeersframes throughpairAndAck, and flushes the offline queue. The invite is not redeemed again. -
Fresh pairing: The hub calls
redeemInvite(store, invite)and persists immediately. It then resolves the host byhost:<inv.hostLogin>or, under dev auth,host:dev-<inv.hostLogin>. If the host is missing or not open, the client receivesHOST-OFFLINE; otherwise a client session is created andpairAndAckruns.
Because redemption is persisted before host lookup, an attempt against an offline host still consumes the invite.
pairAndAck forwards public keys only: the host receives the client login/public key, and the client receives the host login/public key. The hub does not derive or use the shared key.
sequenceDiagram
participant H as Host
participant R as Relay Hub
participant C as Client
H->>R: hello(role=host, login, token, pub)
R->>R: verify token hash; register host:<login>
R-->>H: peers(peer=null)
C->>R: hello(role=client, invite, login?, pub)
R->>R: redeemInvite + persist
R->>H: peers(peer={login,pub})
R-->>C: peers(peer={login,pub})
C->>R: frame/direct-offer (to, opaque)
R->>H: from=client:<login>, opaque
H->>R: frame (to=client:<login>, opaque)
R->>C: from=host:<login>, opaque
Key nodes: hello is the only unauthenticated frame; invite redemption is durable before pairing; peers only exchanges public keys; routeFrame/deliver attach an authoritative from and move opaque payloads.
routeFrame recognizes a small control surface:
| Frame | Direction | Hub behavior |
|---|---|---|
hello |
socket → hub, first only | Authenticates and registers host or client. |
peers |
hub → host/client | Delivers peer login and public key. |
frame |
host ↔ client | Routed by deliver; may be queued for an offline client. |
direct-offer |
host ↔ client | Routed by deliver; never queued for an offline client. |
invite-create |
host → hub | Host-only. Mints and persists an invite, then replies { t: 'invite', code }. |
invite |
hub → host | Invite creation result. |
error |
hub → socket | Error code without payload contents. |
Routing details in deliver:
- The hub constructs
out = { ...msg, from: session.id }, overwriting any client-suppliedfrom. - For a host session,
tomust start withclient:. The target must exist inclients; otherwiseNOBODY-HOME. - If the target client socket is open, the hub increments counters and sends immediately.
- If the target client is offline and the message type is
frame, the hub appendsoutto that client’s queue, applies the 100-message cap, and increments counters. - If the target client is offline and the message type is not
frame, the hub throwsNOBODY-HOME;direct-offeris not buffered. - For a client session, the hub ignores
tofor lookup and routes to the pairedhostSessionKey. If that host is missing or not open, it throwsNOBODY-HOME.
The relay never inspects payload contents:
-
routeFrameonly readsmsg.t. -
deliveronly readsmsg.toand constructsoutwith an authoritativefrom. - Envelope fields such as nonce and ciphertext are spread through untouched.
-
bytesRelayedis computed withBuffer.byteLength(JSON.stringify(out))for accounting only. - Logs contain counts, IDs, and error codes, never frame or envelope contents.
This is why the hub can forward encrypted terminal traffic without knowing the terminal protocol, pane state, or message semantics.
- Malformed JSON or a non-object message closes the socket with
BADFRAME. - Missing or invalid
hellocloses withAUTH. - Unknown
hello.rolecloses withBADROLE. - Failed host token verification closes with
AUTH. - Invalid or unusable invite codes surface store errors such as
UNKNOWN,EXPIRED,REVOKED, orEXHAUSTED. - Invite creation over quota surfaces
QUOTA. - Fresh client pairing against a missing/open-less host returns
HOST-OFFLINE. - Host-to-client routing to an unknown client, or client-to-host routing when the host is offline, returns
NOBODY-HOME. -
direct-offerframes are not buffered; onlyframeframes enter the client offline queue. - The client queue is process-local; relay restart loses buffered frames and live sessions.
- The hub sends
REPLACEDto a superseded host socket before closing it. - Client resume requires the same derived client ID and the same invite code; otherwise it is treated as fresh pairing.
-
ADMIN_TOKENis required unless dev auth is enabled. -
RELAY_DEV_AUTH=1is rejected in production and on non-loopback binds.
-
New control frames: add handling inside
routeFramebefore falling through toBADFRAME. -
Routing policy:
deliveris the place to add new recipient resolution, authorization, queueing, or fan-out behavior while keeping payloads opaque. -
Offline buffering:
OFFLINE_QUEUE_CAPand the queue mutation policy indelivercontrol offline client behavior. -
Hub construction:
createHub({ store, requireAuth, devAuth, persist })is the seam used byindex.mjsand tests. -
Invite policy: TTL, max uses, and active quota live in
store.mjsdefaults andcreateInviteoptions. -
Admin surface:
createAdminHandlercentralizes bearer-token HTTP routes without touching WebSocket routing. -
Store persistence:
loadStoreandsaveStoreisolate file format and atomic-write behavior from the hub.
Sources: relay/src/index.mjs, relay/src/hub.mjs, relay/src/store.mjs, relay/src/admin.mjs
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance