From cd0f762c12c3df1825b0b597eff03d64d4570e9e Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:32:04 -0400 Subject: [PATCH 1/2] feat: connect ws-client to Hermes gateway with message routing Replace stub WebSocket URL (wss://localhost:8080) with real Hermes gateway at ws://143.198.27.163/api/world/ws. Add exponential backoff reconnect (up to 10 attempts, max 30s), proper ws-connected / ws-disconnected / ws-failed lifecycle events, and routing for chat, status-update, and pr-notification message types. Fixes #210 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 8 ++++ ws-client.js | 108 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 88 insertions(+), 28 deletions(-) diff --git a/app.js b/app.js index 485320f..8f3c1e6 100644 --- a/app.js +++ b/app.js @@ -455,6 +455,14 @@ window.addEventListener('chat-message', (/** @type {CustomEvent} */ event) => { } }); +window.addEventListener('status-update', (/** @type {CustomEvent} */ event) => { + console.log('[hermes] Status update:', event.detail); +}); + +window.addEventListener('pr-notification', (/** @type {CustomEvent} */ event) => { + console.log('[hermes] PR notification:', event.detail); +}); + // === SOVEREIGNTY EASTER EGG === const SOVEREIGNTY_WORD = 'sovereignty'; let sovereigntyBuffer = ''; diff --git a/ws-client.js b/ws-client.js index fb278d8..7ae5fb0 100644 --- a/ws-client.js +++ b/ws-client.js @@ -1,9 +1,11 @@ +const HERMES_WS_URL = 'ws://143.198.27.163/api/world/ws'; + export class WebSocketClient { - constructor(url = 'wss://localhost:8080') { + constructor(url = HERMES_WS_URL) { this.url = url; this.reconnectAttempts = 0; - this.maxReconnectAttempts = 5; - this.reconnectDelay = 1000; + this.maxReconnectAttempts = 10; + this.reconnectBaseDelay = 1000; this.maxReconnectDelay = 30000; this.socket = null; this.connected = false; @@ -12,71 +14,121 @@ export class WebSocketClient { } connect() { - if (this.socket && this.socket.readyState === WebSocket.OPEN) { + if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) { return; } - this.socket = new WebSocket(this.url); + 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; - this.messageQueue.forEach(msg => this.send(msg)); + this.messageQueue.forEach(msg => this._send(msg)); this.messageQueue = []; - window.dispatchEvent(new CustomEvent('player-joined', { detail: { id: 'system', name: 'System' } })); + window.dispatchEvent(new CustomEvent('ws-connected', { detail: { url: this.url } })); }; this.socket.onmessage = (event) => { - const data = JSON.parse(event.data); - switch (data.type) { - case 'player-joined': - window.dispatchEvent(new CustomEvent('player-joined', { detail: data })); - break; - case 'player-left': - window.dispatchEvent(new CustomEvent('player-left', { detail: data })); - break; - case 'chat-message': - window.dispatchEvent(new CustomEvent('chat-message', { detail: data })); - break; + let data; + try { + data = JSON.parse(event.data); + } catch (err) { + console.warn('[hermes] Unparseable message:', event.data); + return; } + this._route(data); }; - this.socket.onclose = () => { + this.socket.onclose = (event) => { this.connected = false; - this.reconnect(); + 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 = (error) => { - console.error('WebSocket error:', error); + this.socket.onerror = () => { + // onclose fires after onerror; logging here would be redundant noise + console.warn('[hermes] WebSocket error — waiting for close event'); }; } - reconnect() { + _route(data) { + switch (data.type) { + 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); + } + } + + _scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.warn('Max reconnection attempts reached.'); + 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(); - }, Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay)); + }, delay); + } + + _send(message) { + this.socket.send(JSON.stringify(message)); } send(message) { - if (this.connected) { - this.socket.send(JSON.stringify(message)); + if (this.connected && this.socket && this.socket.readyState === WebSocket.OPEN) { + this._send(message); } else { this.messageQueue.push(message); } } 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; } } } -// Initialize and export a singleton instance export const wsClient = new WebSocketClient(); -- 2.43.0 From d6d8b03f99d825eee1f30f22c785a49635b6f834 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:35:51 -0400 Subject: [PATCH 2/2] fix: use wss:// for Hermes gateway and add _destroyed flag to disconnect - Switch HERMES_WS_URL from ws:// to wss:// for encrypted transport - Add _destroyed flag so disconnect() permanently stops reconnect loop - Guard connect() and _scheduleReconnect() against _destroyed state - Set this.connected = false in disconnect() for consistent state Refs #210 Co-Authored-By: Claude Sonnet 4.6 --- ws-client.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ws-client.js b/ws-client.js index 7ae5fb0..01ece36 100644 --- a/ws-client.js +++ b/ws-client.js @@ -1,4 +1,4 @@ -const HERMES_WS_URL = 'ws://143.198.27.163/api/world/ws'; +const HERMES_WS_URL = 'wss://143.198.27.163/api/world/ws'; export class WebSocketClient { constructor(url = HERMES_WS_URL) { @@ -11,9 +11,11 @@ export class WebSocketClient { this.connected = false; this.reconnectTimeout = null; this.messageQueue = []; + this._destroyed = false; } connect() { + if (this._destroyed) return; if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) { return; } @@ -51,7 +53,9 @@ export class WebSocketClient { this.socket = null; console.warn(`[hermes] Connection closed (code=${event.code})`); window.dispatchEvent(new CustomEvent('ws-disconnected', { detail: { code: event.code } })); - this._scheduleReconnect(); + if (!this._destroyed) { + this._scheduleReconnect(); + } }; this.socket.onerror = () => { @@ -89,6 +93,7 @@ export class WebSocketClient { } _scheduleReconnect() { + if (this._destroyed) return; if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.warn('[hermes] Max reconnection attempts reached — giving up'); window.dispatchEvent(new CustomEvent('ws-failed')); @@ -119,15 +124,16 @@ export class WebSocketClient { } disconnect() { + this._destroyed = true; 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; } + this.connected = false; } } -- 2.43.0