2026-09-03 00:10:18 +04:00

112 lines
2.8 KiB
JavaScript

/**
* 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);
}
}