3x-ui/web/assets/js/websocket.js
lolka1333 d14c54d21d ws/inbounds: realtime fixes + perf for 10k+ client inbounds
- hub: dedup, throttle, panic-restart, deadlock fix, race tests
- client: backoff cap + slow-retry instead of giving up
- broadcast: delta-only payload, count-based invalidate fallback
- filter: fix empty online list (Inbound has no .id, use dbInbound.toInbound)
- perf: O(N²)→O(N) traffic merge, bulk delete, /setEnable endpoint
- traffic: monotonic all_time + UI clamp + propagate in delta handler
- session: persist on update/logout (fixes logout-after-password-change)
- ui: protocol tags flex, traffic bar normalize
2026-04-28 05:22:39 +02:00

208 lines
6.9 KiB
JavaScript

/**
* 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;
socket.addEventListener('open', () => {
this.isConnected = true;
this.reconnectAttempts = 0;
this.#emit('connected');
});
socket.addEventListener('message', (event) => this.#onMessage(event));
socket.addEventListener('error', (event) => {
// 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', () => {
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. event.data is a string for
// text frames; .length is the character count which is always ≤ byte
// count, so checking it is a conservative gate.
if (typeof data === 'string' && data.length > WebSocketClient.#MAX_PAYLOAD_BYTES) {
console.error(`WebSocket: payload too large (${data.length} chars), 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;
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 : '');