const HERMES_WS_URL = 'ws://143.198.27.163/api/world/ws'; export class WebSocketClient { constructor(url = HERMES_WS_URL) { this.url = url; this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; this.reconnectBaseDelay = 1000; this.maxReconnectDelay = 30000; this.socket = null; this.connected = false; this.reconnectTimeout = null; this.messageQueue = []; } 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; 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(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('[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); } _send(message) { this.socket.send(JSON.stringify(message)); } send(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; } } } export const wsClient = new WebSocketClient();