class DashboardWebSocket { constructor({ onGamesUpdate, onShutdown } = {}) { this._socket = null; this._reconnectTimer = null; this._shuttingDown = false; this._pendingRequests = new Map(); this._requestSeq = 0; this._onGamesUpdate = onGamesUpdate || (() => {}); this._onShutdown = onShutdown || (() => {}); } get isShuttingDown() { return this._shuttingDown; } connect() { if (this._shuttingDown) return; if (this._socket && ( this._socket.readyState === WebSocket.OPEN || this._socket.readyState === WebSocket.CONNECTING )) return; const wsUrl = this._buildUrl(); try { this._socket = new WebSocket(wsUrl); } catch { this._scheduleReconnect(); return; } this._socket.addEventListener("message", (event) => { let payload = null; try { payload = JSON.parse(event.data); } catch { return; } if (!payload || !payload.type) return; if (payload.type === "dashboard_ws_shutdown") { this._shuttingDown = true; if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } this._rejectAll("Server shutting down"); if (this._socket) this._socket.close(); this._onShutdown(); return; } if (payload.type === "dashboard_game_replay") { const requestId = String(payload.request_id || ""); if (!requestId) return; const pending = this._pendingRequests.get(requestId); if (!pending) return; this._pendingRequests.delete(requestId); window.clearTimeout(pending.timeoutId); if (payload.error) { pending.reject(new Error(String(payload.error))); return; } pending.resolve(payload.replay || null); return; } if (payload.type === "dashboard_games_update") { this._onGamesUpdate(payload); } }); this._socket.addEventListener("close", () => { this._socket = null; this._rejectAll("Dashboard websocket disconnected"); if (!this._shuttingDown) this._scheduleReconnect(); }); this._socket.addEventListener("error", () => { if (this._socket) this._socket.close(); }); } waitForOpen(timeoutMs = 4000) { if (this._shuttingDown) return Promise.resolve(false); if (!this._socket || this._socket.readyState === WebSocket.CLOSED) this.connect(); if (this._socket && this._socket.readyState === WebSocket.OPEN) return Promise.resolve(true); return new Promise((resolve) => { if (!this._socket) { resolve(false); return; } const socketRef = this._socket; let settled = false; const cleanup = () => { socketRef.removeEventListener("open", onOpen); socketRef.removeEventListener("close", onClose); socketRef.removeEventListener("error", onError); window.clearTimeout(timeoutId); }; const finish = (value) => { if (settled) return; settled = true; cleanup(); resolve(value); }; const onOpen = () => finish(true); const onClose = () => finish(false); const onError = () => finish(false); const timeoutId = window.setTimeout(() => finish(false), timeoutMs); socketRef.addEventListener("open", onOpen); socketRef.addEventListener("close", onClose); socketRef.addEventListener("error", onError); }); } async requestReplay(gameId) { const isOpen = await this.waitForOpen(); if (!isOpen || !this._socket || this._socket.readyState !== WebSocket.OPEN) { throw new Error("Dashboard websocket unavailable"); } const requestId = `replay-${Date.now()}-${this._requestSeq++}`; return await new Promise((resolve, reject) => { const timeoutId = window.setTimeout(() => { this._pendingRequests.delete(requestId); reject(new Error(`Replay websocket timeout for ${gameId}`)); }, 4000); this._pendingRequests.set(requestId, { resolve, reject, timeoutId }); try { this._socket.send(JSON.stringify({ type: "dashboard_game_replay_request", request_id: requestId, game_id: gameId, })); } catch (error) { window.clearTimeout(timeoutId); this._pendingRequests.delete(requestId); reject(error); } }); } shutdown() { this._shuttingDown = true; if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } this._rejectAll("Dashboard unloading"); if (this._socket) { this._socket.close(); this._socket = null; } } _buildUrl() { const protocol = window.location.protocol === "https:" ? "wss" : "ws"; return `${protocol}://${window.location.host}/dashboard/ws/games`; } _scheduleReconnect() { if (this._shuttingDown) return; if (this._reconnectTimer) return; this._reconnectTimer = window.setTimeout(() => { this._reconnectTimer = null; this.connect(); }, 1500); } _rejectAll(message) { for (const pending of this._pendingRequests.values()) { window.clearTimeout(pending.timeoutId); pending.reject(new Error(message)); } this._pendingRequests.clear(); } }