Skip to content

Relay Hub & WebSocket Frame Routing

dazeb edited this page Sep 17, 2026 · 2 revisions

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.

Module Responsibilities

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
Loading

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.

Boot and Configuration

startServer(config) performs the wiring in index.mjs:

  1. Creates RELAY_DATA_DIR if needed.
  2. Opens <dataDir>/store.json through loadStore, defaulting to { users: [], invites: [] }.
  3. Defines persist() as saveStore(storeFile, store).
  4. Creates the hub with createHub({ store, requireAuth: true, devAuth, persist }).
  5. Creates the admin handler with the same store, hub, adminToken, and persist.
  6. Starts http.createServer(handler) and routes upgrade events to hub.handleUpgrade.
  7. Starts a 10-second metrics interval that logs only when framesRelayed changed: 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.

Connection Registry and Session State

The hub keeps all live routing state in two in-memory maps:

  • hosts: keyed by host:<login> for normal hosts or host:dev-<login> under dev auth. Each value is { kind: 'host', id, login, ws, pub }.
  • clients: keyed by client:<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 framesRelayed and bytesRelayed are 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.

Authentication and Pairing

Every socket must send hello as its first valid message. A non-hello first message closes the socket with AUTH.

Host hello

helloHost requires a non-empty login. Outside dev auth, it finds the user by login and requires:

  • requireAuth is true in the composition root.
  • The user exists.
  • msg.token is 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>.

Client hello and invite redemption

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:

  1. Resume: If an existing client session has the same clientId and inviteCode === msg.invite, the hub reattaches the socket, updates pub, sends peers frames through pairAndAck, and flushes the offline queue. The invite is not redeemed again.
  2. Fresh pairing: The hub calls redeemInvite(store, invite) and persists immediately. It then resolves the host by host:<inv.hostLogin> or, under dev auth, host:dev-<inv.hostLogin>. If the host is missing or not open, the client receives HOST-OFFLINE; otherwise a client session is created and pairAndAck runs.

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
Loading

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.

Wire Frame Types and Routing Rules

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-supplied from.
  • For a host session, to must start with client:. The target must exist in clients; otherwise NOBODY-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 appends out to 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 throws NOBODY-HOME; direct-offer is not buffered.
  • For a client session, the hub ignores to for lookup and routes to the paired hostSessionKey. If that host is missing or not open, it throws NOBODY-HOME.

Payload Opacity

The relay never inspects payload contents:

  • routeFrame only reads msg.t.
  • deliver only reads msg.to and constructs out with an authoritative from.
  • Envelope fields such as nonce and ciphertext are spread through untouched.
  • bytesRelayed is computed with Buffer.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.

Edge Conditions

  • Malformed JSON or a non-object message closes the socket with BADFRAME.
  • Missing or invalid hello closes with AUTH.
  • Unknown hello.role closes with BADROLE.
  • Failed host token verification closes with AUTH.
  • Invalid or unusable invite codes surface store errors such as UNKNOWN, EXPIRED, REVOKED, or EXHAUSTED.
  • 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-offer frames are not buffered; only frame frames enter the client offline queue.
  • The client queue is process-local; relay restart loses buffered frames and live sessions.
  • The hub sends REPLACED to 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_TOKEN is required unless dev auth is enabled.
  • RELAY_DEV_AUTH=1 is rejected in production and on non-loopback binds.

Extension Points

  • New control frames: add handling inside routeFrame before falling through to BADFRAME.
  • Routing policy: deliver is the place to add new recipient resolution, authorization, queueing, or fan-out behavior while keeping payloads opaque.
  • Offline buffering: OFFLINE_QUEUE_CAP and the queue mutation policy in deliver control offline client behavior.
  • Hub construction: createHub({ store, requireAuth, devAuth, persist }) is the seam used by index.mjs and tests.
  • Invite policy: TTL, max uses, and active quota live in store.mjs defaults and createInvite options.
  • Admin surface: createAdminHandler centralizes bearer-token HTTP routes without touching WebSocket routing.
  • Store persistence: loadStore and saveStore isolate 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

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally