websocket.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  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. // Every handler must check `this.ws !== socket` first. A previous socket
  98. // can still fire events (especially `close`) after we've moved on to a
  99. // new one — e.g. connect() called while the old socket is in CLOSING
  100. // state. Without the guard, a stale close would null out the freshly
  101. // opened socket and silently break send().
  102. socket.addEventListener('open', () => {
  103. if (this.ws !== socket) return;
  104. this.isConnected = true;
  105. this.reconnectAttempts = 0;
  106. this.#emit('connected');
  107. });
  108. socket.addEventListener('message', (event) => {
  109. if (this.ws !== socket) return;
  110. this.#onMessage(event);
  111. });
  112. socket.addEventListener('error', (event) => {
  113. if (this.ws !== socket) return;
  114. // Browsers fire 'error' before 'close' on failure. We surface it for
  115. // consumers (so polling fallbacks can engage) but don't log every blip
  116. // — bad networks would flood the console otherwise.
  117. this.#emit('error', event);
  118. });
  119. socket.addEventListener('close', () => {
  120. if (this.ws !== socket) return;
  121. this.isConnected = false;
  122. this.ws = null;
  123. this.#emit('disconnected');
  124. if (this.shouldReconnect) this.#scheduleReconnect();
  125. });
  126. }
  127. #buildUrl() {
  128. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  129. let basePath = this.basePath || '';
  130. if (basePath && !basePath.endsWith('/')) basePath += '/';
  131. return `${protocol}//${window.location.host}${basePath}ws`;
  132. }
  133. #onMessage(event) {
  134. const data = event.data;
  135. // Reject oversized payloads up front. We compare actual UTF-8 byte
  136. // length (via Blob.size) against the limit — string.length counts
  137. // UTF-16 code units, which can undercount real bytes by up to 4× for
  138. // payloads with non-ASCII characters and bypass the cap.
  139. if (typeof data === 'string') {
  140. const byteLen = new Blob([data]).size;
  141. if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
  142. console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
  143. try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
  144. return;
  145. }
  146. }
  147. let message;
  148. try {
  149. message = JSON.parse(data);
  150. } catch (err) {
  151. console.error('WebSocket: invalid JSON message', err);
  152. return;
  153. }
  154. if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
  155. console.error('WebSocket: malformed message envelope');
  156. return;
  157. }
  158. this.#emit(message.type, message.payload, message.time);
  159. this.#emit('message', message);
  160. }
  161. #emit(event, ...args) {
  162. const set = this.listeners.get(event);
  163. if (!set) return;
  164. for (const callback of set) {
  165. try {
  166. callback(...args);
  167. } catch (err) {
  168. console.error(`WebSocket: handler for "${event}" threw`, err);
  169. }
  170. }
  171. }
  172. #scheduleReconnect() {
  173. if (!this.shouldReconnect) return;
  174. this.#cancelReconnect();
  175. let base;
  176. if (this.reconnectAttempts < this.maxReconnectAttempts) {
  177. this.reconnectAttempts += 1;
  178. // Exponential backoff inside the active window.
  179. const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
  180. base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
  181. } else {
  182. // Active window exhausted — keep trying once a minute. The page-level
  183. // polling fallback runs in parallel; this just brings WS back when the
  184. // network recovers.
  185. base = WebSocketClient.#SLOW_RETRY_MS;
  186. }
  187. // ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
  188. const delay = base * (0.75 + Math.random() * 0.5);
  189. this.reconnectTimer = setTimeout(() => {
  190. this.reconnectTimer = null;
  191. // clearTimeout doesn't cancel a callback that has already fired but
  192. // whose macrotask hasn't run yet — re-check shouldReconnect here so
  193. // disconnect() called in that window can't be overridden.
  194. if (!this.shouldReconnect) return;
  195. this.#openSocket();
  196. }, delay);
  197. }
  198. #cancelReconnect() {
  199. if (this.reconnectTimer !== null) {
  200. clearTimeout(this.reconnectTimer);
  201. this.reconnectTimer = null;
  202. }
  203. }
  204. }
  205. // Global instance — basePath is set by page.html before this script loads.
  206. window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : '');