112 lines
2.8 KiB
JavaScript
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);
|
|
}
|
|
}
|