websocket.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. /**
  2. * WebSocket client for real-time panel updates.
  3. *
  4. * Public API (kept stable for index.html / inbounds.html / xray.html):
  5. * - connect() — open the connection (idempotent)
  6. * - disconnect() — close and stop reconnecting
  7. * - on(event, callback) — subscribe to event
  8. * - off(event, callback) — unsubscribe
  9. * - send(data) — send JSON to the server
  10. * - isConnected — boolean, current state
  11. * - reconnectAttempts — number, attempts since last success
  12. * - maxReconnectAttempts — number, give-up threshold
  13. *
  14. * Built-in events:
  15. * 'connected', 'disconnected', 'error', 'message',
  16. * plus any server-emitted message type (status, traffic, client_stats, ...).
  17. */
  18. class WebSocketClient {
  19. static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
  20. static #BASE_RECONNECT_MS = 1000;
  21. static #MAX_RECONNECT_MS = 30_000;
  22. // After exhausting maxReconnectAttempts we switch to a polite slow-retry
  23. // cadence rather than giving up forever — a panel that recovers an hour
  24. // later should reconnect without a manual page reload.
  25. static #SLOW_RETRY_MS = 60_000;
  26. constructor(basePath = '') {
  27. this.basePath = basePath;
  28. this.maxReconnectAttempts = 10;
  29. this.reconnectAttempts = 0;
  30. this.isConnected = false;
  31. this.ws = null;
  32. this.shouldReconnect = true;
  33. this.reconnectTimer = null;
  34. this.listeners = new Map(); // event → Set<callback>
  35. }
  36. // Open the connection. Safe to call repeatedly — no-op if already
  37. // open/connecting. Re-enables reconnects if previously disabled. Cancels
  38. // any pending reconnect timer so an external connect() can't race a
  39. // delayed retry into spawning a second socket.
  40. connect() {
  41. if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
  42. return;
  43. }
  44. this.shouldReconnect = true;
  45. this.#cancelReconnect();
  46. this.#openSocket();
  47. }
  48. // Close the connection and stop any pending reconnect attempt. Resets the
  49. // attempt counter so a future connect() starts fresh from the small backoff.
  50. disconnect() {
  51. this.shouldReconnect = false;
  52. this.#cancelReconnect();
  53. this.reconnectAttempts = 0;
  54. if (this.ws) {
  55. try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
  56. this.ws = null;
  57. }
  58. this.isConnected = false;
  59. }
  60. // Subscribe to an event. Re-subscribing the same callback is a no-op.
  61. on(event, callback) {
  62. if (typeof callback !== 'function') return;
  63. let set = this.listeners.get(event);
  64. if (!set) {
  65. set = new Set();
  66. this.listeners.set(event, set);
  67. }
  68. set.add(callback);
  69. }
  70. // Unsubscribe from an event.
  71. off(event, callback) {
  72. const set = this.listeners.get(event);
  73. if (!set) return;
  74. set.delete(callback);
  75. if (set.size === 0) this.listeners.delete(event);
  76. }
  77. // Send JSON to the server. Drops silently if not connected — callers
  78. // should rely on connect()/server pushes rather than client-initiated sends.
  79. send(data) {
  80. if (this.ws && this.ws.readyState === WebSocket.OPEN) {
  81. this.ws.send(JSON.stringify(data));
  82. }
  83. }
  84. // ───── internals ─────
  85. #openSocket() {
  86. const url = this.#buildUrl();
  87. let socket;
  88. try {
  89. socket = new WebSocket(url);
  90. } catch (err) {
  91. console.error('WebSocket: failed to construct connection', err);
  92. this.#emit('error', err);
  93. this.#scheduleReconnect();
  94. return;
  95. }
  96. this.ws = socket;
  97. socket.addEventListener('open', () => {
  98. this.isConnected = true;
  99. this.reconnectAttempts = 0;
  100. this.#emit('connected');
  101. });
  102. socket.addEventListener('message', (event) => this.#onMessage(event));
  103. socket.addEventListener('error', (event) => {
  104. // Browsers fire 'error' before 'close' on failure. We surface it for
  105. // consumers (so polling fallbacks can engage) but don't log every blip
  106. // — bad networks would flood the console otherwise.
  107. this.#emit('error', event);
  108. });
  109. socket.addEventListener('close', () => {
  110. this.isConnected = false;
  111. this.ws = null;
  112. this.#emit('disconnected');
  113. if (this.shouldReconnect) this.#scheduleReconnect();
  114. });
  115. }
  116. #buildUrl() {
  117. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  118. let basePath = this.basePath || '';
  119. if (basePath && !basePath.endsWith('/')) basePath += '/';
  120. return `${protocol}//${window.location.host}${basePath}ws`;
  121. }
  122. #onMessage(event) {
  123. const data = event.data;
  124. // Reject oversized payloads up front. We compare actual UTF-8 byte
  125. // length (via Blob.size) against the limit — string.length counts
  126. // UTF-16 code units, which can undercount real bytes by up to 4× for
  127. // payloads with non-ASCII characters and bypass the cap.
  128. if (typeof data === 'string') {
  129. const byteLen = new Blob([data]).size;
  130. if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
  131. console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
  132. try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
  133. return;
  134. }
  135. }
  136. let message;
  137. try {
  138. message = JSON.parse(data);
  139. } catch (err) {
  140. console.error('WebSocket: invalid JSON message', err);
  141. return;
  142. }
  143. if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
  144. console.error('WebSocket: malformed message envelope');
  145. return;
  146. }
  147. this.#emit(message.type, message.payload, message.time);
  148. this.#emit('message', message);
  149. }
  150. #emit(event, ...args) {
  151. const set = this.listeners.get(event);
  152. if (!set) return;
  153. for (const callback of set) {
  154. try {
  155. callback(...args);
  156. } catch (err) {
  157. console.error(`WebSocket: handler for "${event}" threw`, err);
  158. }
  159. }
  160. }
  161. #scheduleReconnect() {
  162. if (!this.shouldReconnect) return;
  163. this.#cancelReconnect();
  164. let base;
  165. if (this.reconnectAttempts < this.maxReconnectAttempts) {
  166. this.reconnectAttempts += 1;
  167. // Exponential backoff inside the active window.
  168. const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
  169. base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
  170. } else {
  171. // Active window exhausted — keep trying once a minute. The page-level
  172. // polling fallback runs in parallel; this just brings WS back when the
  173. // network recovers.
  174. base = WebSocketClient.#SLOW_RETRY_MS;
  175. }
  176. // ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
  177. const delay = base * (0.75 + Math.random() * 0.5);
  178. this.reconnectTimer = setTimeout(() => {
  179. this.reconnectTimer = null;
  180. this.#openSocket();
  181. }, delay);
  182. }
  183. #cancelReconnect() {
  184. if (this.reconnectTimer !== null) {
  185. clearTimeout(this.reconnectTimer);
  186. this.reconnectTimer = null;
  187. }
  188. }
  189. }
  190. // Global instance — basePath is set by page.html before this script loads.
  191. window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : '');