| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- /**
- * 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. 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;
- 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 : '');
|