From 600c19c26aa24a5e704b45d9b193b38f6f265569 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:53:36 -0400 Subject: [PATCH] docs: document Hermes provider fallback chain in ws-client.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a module-level JSDoc block explaining: - The Hermes gateway's role as the single entry point for all AI provider responses (Claude → Gemini → Perplexity → fallback) - The full connection lifecycle (connect → onopen → onmessage → onclose → _scheduleReconnect) - The exponential backoff schedule (1s, 2s, 4s … capped at 30s, up to 10 attempts, then ws-failed) - The message queue that buffers outbound messages while disconnected - A reference table of all dispatched CustomEvent names and payloads Also adds @param/@returns JSDoc to each method for IDE discoverability. Fixes #287 --- ws-client.js | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/ws-client.js b/ws-client.js index 7ae5fb0..45ca02a 100644 --- a/ws-client.js +++ b/ws-client.js @@ -1,18 +1,77 @@ +/** + * ws-client.js — Hermes Gateway WebSocket Client + * + * Manages the persistent WebSocket connection between the Nexus (browser) and + * the Hermes agent gateway. Hermes is the sovereign orchestration layer that + * routes AI provider responses, Gitea PR events, visitor presence, and chat + * messages into the 3D world. + * + * ## Provider Fallback Chain + * + * The Hermes gateway itself manages provider selection (Claude → Gemini → + * Perplexity → fallback). From the Nexus client's perspective, all providers + * arrive through the single WebSocket endpoint below. The client's + * responsibility is to stay connected so no events are dropped. + * + * Connection lifecycle: + * + * 1. connect() — opens WebSocket to HERMES_WS_URL + * 2. onopen — flushes any queued messages; fires 'ws-connected' + * 3. onmessage — JSON-parses frames; dispatches typed CustomEvents + * 4. onclose / onerror — fires 'ws-disconnected'; triggers _scheduleReconnect() + * 5. _scheduleReconnect — exponential backoff (1s → 2s → 4s … ≤ 30s) up to + * 10 attempts, then fires 'ws-failed' and gives up + * + * Message queue: messages sent while disconnected are buffered in + * `this.messageQueue` and flushed on the next successful connection. + * + * ## Dispatched CustomEvents + * + * | type | CustomEvent name | Payload (event.detail) | + * |-------------------|--------------------|------------------------------------| + * | chat / chat-message | chat-message | { type, text, sender?, … } | + * | status-update | status-update | { type, status, agent?, … } | + * | pr-notification | pr-notification | { type, action, pr, … } | + * | player-joined | player-joined | { type, id, name?, … } | + * | player-left | player-left | { type, id, … } | + * | (connection) | ws-connected | { url } | + * | (connection) | ws-disconnected | { code } | + * | (terminal) | ws-failed | — | + */ + +/** Primary Hermes gateway endpoint. */ const HERMES_WS_URL = 'ws://143.198.27.163/api/world/ws'; +/** + * WebSocketClient — resilient WebSocket wrapper with exponential-backoff + * reconnection and an outbound message queue. + */ export class WebSocketClient { + /** + * @param {string} [url] - WebSocket endpoint (defaults to HERMES_WS_URL) + */ constructor(url = HERMES_WS_URL) { this.url = url; + /** Number of reconnect attempts since last successful connection. */ this.reconnectAttempts = 0; + /** Hard cap on reconnect attempts before emitting 'ws-failed'. */ this.maxReconnectAttempts = 10; + /** Initial backoff delay in ms (doubles each attempt). */ this.reconnectBaseDelay = 1000; + /** Maximum backoff delay in ms. */ this.maxReconnectDelay = 30000; + /** @type {WebSocket|null} */ this.socket = null; this.connected = false; + /** @type {ReturnType|null} */ this.reconnectTimeout = null; + /** Messages queued while disconnected; flushed on reconnect. */ this.messageQueue = []; } + /** + * Open the WebSocket connection. No-ops if already open or connecting. + */ connect() { if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) { return; @@ -60,6 +119,12 @@ export class WebSocketClient { }; } + /** + * Route an inbound Hermes message to the appropriate CustomEvent. + * Unrecognised types are logged at debug level and dropped. + * + * @param {{ type: string, [key: string]: unknown }} data + */ _route(data) { switch (data.type) { case 'chat': @@ -88,6 +153,19 @@ export class WebSocketClient { } } + /** + * Schedule the next reconnect attempt using exponential backoff. + * + * Backoff schedule (base 1 s, cap 30 s): + * attempt 1 → 1 s + * attempt 2 → 2 s + * attempt 3 → 4 s + * attempt 4 → 8 s + * attempt 5 → 16 s + * attempt 6+ → 30 s (capped) + * + * After maxReconnectAttempts the client emits 'ws-failed' and stops trying. + */ _scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.warn('[hermes] Max reconnection attempts reached — giving up'); @@ -106,10 +184,20 @@ export class WebSocketClient { }, delay); } + /** + * Low-level send — caller must ensure socket is open. + * @param {object} message + */ _send(message) { this.socket.send(JSON.stringify(message)); } + /** + * Send a message to Hermes. If not currently connected the message is + * buffered and will be delivered on the next successful connection. + * + * @param {object} message + */ send(message) { if (this.connected && this.socket && this.socket.readyState === WebSocket.OPEN) { this._send(message); @@ -118,6 +206,10 @@ export class WebSocketClient { } } + /** + * Intentionally close the connection and cancel any pending reconnect. + * After calling disconnect() the client will not attempt to reconnect. + */ disconnect() { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); @@ -131,4 +223,5 @@ export class WebSocketClient { } } +/** Shared singleton WebSocket client — imported by app.js. */ export const wsClient = new WebSocketClient(); -- 2.43.0