Files
3x-ui/web/assets/js/websocket.js
MHSanaei b84b58ef21 fix(websocket): guard stale events and disconnect race in JS client
Two subtle race conditions in the browser WebSocket client:

1. Stale-event clobber. When connect() is called while the old socket is
   in CLOSING state, the readyState guard falls through and a new socket
   is assigned to this.ws. The old socket's queued close event then
   nulls out this.ws, silently breaking send() until the next reconnect.
   Same risk for delayed open/error/message handlers.

2. Reconnect-after-disconnect. clearTimeout() does not cancel a callback
   that has already fired but whose macrotask has not yet run. If
   disconnect() lands in that window, the queued reconnect callback
   still calls #openSocket() and resurrects the connection.

Every event handler now bails out if this.ws no longer points at the
socket that fired the event, and the reconnect timer callback re-checks
shouldReconnect before opening a new socket.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 00:00:10 +02:00

228 lines
7.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* WebSocket client for real-time panel updates.
*
* Public API (kept stable for index.html / inbounds.html / xray.html):
* - connect() — open the connection (idempotent)
* - disconnect() — close and stop reconnecting
* - on(event, callback) — subscribe to event
* - off(event, callback) — unsubscribe
* - send(data) — send JSON to the server
* - isConnected — boolean, current state
* - reconnectAttempts — number, attempts since last success
* - maxReconnectAttempts — number, give-up threshold
*
* Built-in events:
* 'connected', 'disconnected', 'error', 'message',
* plus any server-emitted message type (status, traffic, client_stats, ...).
*/
class WebSocketClient {
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
static #BASE_RECONNECT_MS = 1000;
static #MAX_RECONNECT_MS = 30_000;
// After exhausting maxReconnectAttempts we switch to a polite slow-retry
// cadence rather than giving up forever — a panel that recovers an hour
// later should reconnect without a manual page reload.
static #SLOW_RETRY_MS = 60_000;
constructor(basePath = '') {
this.basePath = basePath;
this.maxReconnectAttempts = 10;
this.reconnectAttempts = 0;
this.isConnected = false;
this.ws = null;
this.shouldReconnect = true;
this.reconnectTimer = null;
this.listeners = new Map(); // event → Set<callback>
}
// Open the connection. Safe to call repeatedly — no-op if already
// open/connecting. Re-enables reconnects if previously disabled. Cancels
// any pending reconnect timer so an external connect() can't race a
// delayed retry into spawning a second socket.
connect() {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.shouldReconnect = true;
this.#cancelReconnect();
this.#openSocket();
}
// Close the connection and stop any pending reconnect attempt. Resets the
// attempt counter so a future connect() starts fresh from the small backoff.
disconnect() {
this.shouldReconnect = false;
this.#cancelReconnect();
this.reconnectAttempts = 0;
if (this.ws) {
try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
this.ws = null;
}
this.isConnected = false;
}
// Subscribe to an event. Re-subscribing the same callback is a no-op.
on(event, callback) {
if (typeof callback !== 'function') return;
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(callback);
}
// Unsubscribe from an event.
off(event, callback) {
const set = this.listeners.get(event);
if (!set) return;
set.delete(callback);
if (set.size === 0) this.listeners.delete(event);
}
// Send JSON to the server. Drops silently if not connected — callers
// should rely on connect()/server pushes rather than client-initiated sends.
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
// ───── internals ─────
#openSocket() {
const url = this.#buildUrl();
let socket;
try {
socket = new WebSocket(url);
} catch (err) {
console.error('WebSocket: failed to construct connection', err);
this.#emit('error', err);
this.#scheduleReconnect();
return;
}
this.ws = socket;
// Every handler must check `this.ws !== socket` first. A previous socket
// can still fire events (especially `close`) after we've moved on to a
// new one — e.g. connect() called while the old socket is in CLOSING
// state. Without the guard, a stale close would null out the freshly
// opened socket and silently break send().
socket.addEventListener('open', () => {
if (this.ws !== socket) return;
this.isConnected = true;
this.reconnectAttempts = 0;
this.#emit('connected');
});
socket.addEventListener('message', (event) => {
if (this.ws !== socket) return;
this.#onMessage(event);
});
socket.addEventListener('error', (event) => {
if (this.ws !== socket) return;
// Browsers fire 'error' before 'close' on failure. We surface it for
// consumers (so polling fallbacks can engage) but don't log every blip
// — bad networks would flood the console otherwise.
this.#emit('error', event);
});
socket.addEventListener('close', () => {
if (this.ws !== socket) return;
this.isConnected = false;
this.ws = null;
this.#emit('disconnected');
if (this.shouldReconnect) this.#scheduleReconnect();
});
}
#buildUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let basePath = this.basePath || '';
if (basePath && !basePath.endsWith('/')) basePath += '/';
return `${protocol}//${window.location.host}${basePath}ws`;
}
#onMessage(event) {
const data = event.data;
// Reject oversized payloads up front. We compare actual UTF-8 byte
// length (via Blob.size) against the limit — string.length counts
// UTF-16 code units, which can undercount real bytes by up to 4× for
// payloads with non-ASCII characters and bypass the cap.
if (typeof data === 'string') {
const byteLen = new Blob([data]).size;
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
return;
}
}
let message;
try {
message = JSON.parse(data);
} catch (err) {
console.error('WebSocket: invalid JSON message', err);
return;
}
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
console.error('WebSocket: malformed message envelope');
return;
}
this.#emit(message.type, message.payload, message.time);
this.#emit('message', message);
}
#emit(event, ...args) {
const set = this.listeners.get(event);
if (!set) return;
for (const callback of set) {
try {
callback(...args);
} catch (err) {
console.error(`WebSocket: handler for "${event}" threw`, err);
}
}
}
#scheduleReconnect() {
if (!this.shouldReconnect) return;
this.#cancelReconnect();
let base;
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts += 1;
// Exponential backoff inside the active window.
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
} else {
// Active window exhausted — keep trying once a minute. The page-level
// polling fallback runs in parallel; this just brings WS back when the
// network recovers.
base = WebSocketClient.#SLOW_RETRY_MS;
}
// ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
const delay = base * (0.75 + Math.random() * 0.5);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
// clearTimeout doesn't cancel a callback that has already fired but
// whose macrotask hasn't run yet — re-check shouldReconnect here so
// disconnect() called in that window can't be overridden.
if (!this.shouldReconnect) return;
this.#openSocket();
}, delay);
}
#cancelReconnect() {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
// Global instance — basePath is set by page.html before this script loads.
window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : '');