/** * WebSocket client for live robot state. * * Replaces polling entirely: the backend pushes connection state, speech * lifecycle and history updates. Reconnects on its own with capped backoff, so * restarting the server does not require reloading the page. */ const PING_INTERVAL = 5000; const MIN_BACKOFF = 500; const MAX_BACKOFF = 8000; export class RobotSocket extends EventTarget { constructor() { super(); this.ws = null; this.backoff = MIN_BACKOFF; this.pingTimer = null; this.rtt = null; this.closedByUs = false; } get url() { const scheme = location.protocol === 'https:' ? 'wss' : 'ws'; return `${scheme}://${location.host}/ws`; } get isOpen() { return this.ws && this.ws.readyState === WebSocket.OPEN; } connect() { this.closedByUs = false; if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; } let ws; try { ws = new WebSocket(this.url); } catch { this.#scheduleReconnect(); return; } this.ws = ws; ws.onopen = () => { this.backoff = MIN_BACKOFF; this.#emit('link', { up: true }); this.#startPing(); }; ws.onmessage = (event) => { let message; try { message = JSON.parse(event.data); } catch { return; } if (message.type === 'pong') { const sent = message.data && message.data.t; if (typeof sent === 'number') this.rtt = Math.max(0, Math.round(performance.now() - sent)); this.#emit('rtt', { rtt: this.rtt }); return; } this.#emit(message.type, message.data || {}); }; ws.onclose = () => { this.#stopPing(); this.#emit('link', { up: false }); if (!this.closedByUs) this.#scheduleReconnect(); }; ws.onerror = () => { /* onclose always follows; handled there */ }; } close() { this.closedByUs = true; this.#stopPing(); if (this.ws) this.ws.close(); } send(type, data = {}) { if (!this.isOpen) return false; this.ws.send(JSON.stringify({ type, ...data })); return true; } requestStatus() { this.send('status'); } requestReconnect() { this.send('reconnect'); } // -- internals ------------------------------------------------------------ #emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); } #startPing() { this.#stopPing(); const ping = () => this.send('ping', { t: performance.now() }); ping(); this.pingTimer = setInterval(ping, PING_INTERVAL); } #stopPing() { if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } } #scheduleReconnect() { const delay = this.backoff; this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF); setTimeout(() => this.connect(), delay); } }