/** * 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'; const SESSION_STORAGE_KEY = 'hermes-session'; /** * 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 = []; this.session = null; } /** * Persist session data to localStorage so it survives page reloads. * @param {Object} data Arbitrary session payload (token, id, etc.) */ saveSession(data) { try { localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ ...data, savedAt: Date.now() })); this.session = data; console.log('[hermes] Session saved'); } catch (err) { console.warn('[hermes] Could not save session:', err); } } /** * Restore session data from localStorage. * @returns {Object|null} Previously saved session, or null if none. */ loadSession() { try { const raw = localStorage.getItem(SESSION_STORAGE_KEY); if (!raw) return null; const data = JSON.parse(raw); this.session = data; console.log('[hermes] Session loaded (savedAt:', new Date(data.savedAt).toISOString(), ')'); return data; } catch (err) { console.warn('[hermes] Could not load session:', err); return null; } } /** * Remove any persisted session from localStorage. */ clearSession() { try { localStorage.removeItem(SESSION_STORAGE_KEY); this.session = null; console.log('[hermes] Session cleared'); } catch (err) { console.warn('[hermes] Could not clear session:', err); } } /** * 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; } try { this.socket = new WebSocket(this.url); } catch (err) { console.error('[hermes] WebSocket construction failed:', err); this._scheduleReconnect(); return; } this.socket.onopen = () => { console.log('[hermes] Connected to Hermes gateway'); this.connected = true; this.reconnectAttempts = 0; // Restore session if available; send it as the first frame so the server // can resume the previous session rather than creating a new one. const existing = this.loadSession(); if (existing?.token) { this._send({ type: 'session-resume', token: existing.token }); } this.messageQueue.forEach(msg => this._send(msg)); this.messageQueue = []; window.dispatchEvent(new CustomEvent('ws-connected', { detail: { url: this.url } })); }; this.socket.onmessage = (event) => { let data; try { data = JSON.parse(event.data); } catch (err) { console.warn('[hermes] Unparseable message:', event.data); return; } this._route(data); }; this.socket.onclose = (event) => { this.connected = false; this.socket = null; console.warn(`[hermes] Connection closed (code=${event.code})`); window.dispatchEvent(new CustomEvent('ws-disconnected', { detail: { code: event.code } })); this._scheduleReconnect(); }; this.socket.onerror = () => { // onclose fires after onerror; logging here would be redundant noise console.warn('[hermes] WebSocket error — waiting for close event'); }; } /** * 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 'session-init': // Server issued a new session token — persist it for future reconnects. if (data.token) { this.saveSession({ token: data.token, clientId: data.clientId }); } window.dispatchEvent(new CustomEvent('session-init', { detail: data })); break; case 'chat': case 'chat-message': window.dispatchEvent(new CustomEvent('chat-message', { detail: data })); break; case 'status-update': window.dispatchEvent(new CustomEvent('status-update', { detail: data })); break; case 'pr-notification': window.dispatchEvent(new CustomEvent('pr-notification', { detail: data })); break; case 'player-joined': window.dispatchEvent(new CustomEvent('player-joined', { detail: data })); break; case 'player-left': window.dispatchEvent(new CustomEvent('player-left', { detail: data })); break; default: console.debug('[hermes] Unhandled message type:', data.type, data); } } /** * 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'); window.dispatchEvent(new CustomEvent('ws-failed')); return; } const delay = Math.min( this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay ); console.log(`[hermes] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})`); this.reconnectTimeout = setTimeout(() => { this.reconnectAttempts++; this.connect(); }, 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); } else { this.messageQueue.push(message); } } /** * 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); this.reconnectTimeout = null; } this.maxReconnectAttempts = 0; // prevent auto-reconnect after intentional disconnect if (this.socket) { this.socket.close(); this.socket = null; } } } /** Shared singleton WebSocket client — imported by app.js. */ export const wsClient = new WebSocketClient();