websocket.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. type WebSocketListener = (...args: unknown[]) => void;
  2. interface WebSocketMessage {
  3. type: string;
  4. payload?: unknown;
  5. time?: unknown;
  6. }
  7. export class WebSocketClient {
  8. static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024;
  9. static #BASE_RECONNECT_MS = 1000;
  10. static #MAX_RECONNECT_MS = 30_000;
  11. static #SLOW_RETRY_MS = 60_000;
  12. basePath: string;
  13. maxReconnectAttempts: number;
  14. reconnectAttempts: number;
  15. isConnected: boolean;
  16. private ws: WebSocket | null;
  17. private shouldReconnect: boolean;
  18. private reconnectTimer: ReturnType<typeof setTimeout> | null;
  19. private listeners: Map<string, Set<WebSocketListener>>;
  20. constructor(basePath = '') {
  21. this.basePath = basePath;
  22. this.maxReconnectAttempts = 10;
  23. this.reconnectAttempts = 0;
  24. this.isConnected = false;
  25. this.ws = null;
  26. this.shouldReconnect = true;
  27. this.reconnectTimer = null;
  28. this.listeners = new Map();
  29. }
  30. connect(): void {
  31. if (
  32. this.ws &&
  33. (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
  34. ) {
  35. return;
  36. }
  37. this.shouldReconnect = true;
  38. this.#cancelReconnect();
  39. this.#openSocket();
  40. }
  41. disconnect(): void {
  42. this.shouldReconnect = false;
  43. this.#cancelReconnect();
  44. this.reconnectAttempts = 0;
  45. if (this.ws) {
  46. try {
  47. this.ws.close(1000, 'client disconnect');
  48. } catch {}
  49. this.ws = null;
  50. }
  51. this.isConnected = false;
  52. }
  53. on(event: string, callback: WebSocketListener): void {
  54. if (typeof callback !== 'function') return;
  55. let set = this.listeners.get(event);
  56. if (!set) {
  57. set = new Set();
  58. this.listeners.set(event, set);
  59. }
  60. set.add(callback);
  61. }
  62. off(event: string, callback: WebSocketListener): void {
  63. const set = this.listeners.get(event);
  64. if (!set) return;
  65. set.delete(callback);
  66. if (set.size === 0) this.listeners.delete(event);
  67. }
  68. send(data: unknown): void {
  69. if (this.ws && this.ws.readyState === WebSocket.OPEN) {
  70. this.ws.send(JSON.stringify(data));
  71. }
  72. }
  73. #openSocket(): void {
  74. const url = this.#buildUrl();
  75. let socket: WebSocket;
  76. try {
  77. socket = new WebSocket(url);
  78. } catch (err) {
  79. console.error('WebSocket: failed to construct connection', err);
  80. this.#emit('error', err);
  81. this.#scheduleReconnect();
  82. return;
  83. }
  84. this.ws = socket;
  85. socket.addEventListener('open', () => {
  86. if (this.ws !== socket) return;
  87. this.isConnected = true;
  88. this.reconnectAttempts = 0;
  89. this.#emit('connected');
  90. });
  91. socket.addEventListener('message', (event) => {
  92. if (this.ws !== socket) return;
  93. this.#onMessage(event);
  94. });
  95. socket.addEventListener('error', (event) => {
  96. if (this.ws !== socket) return;
  97. this.#emit('error', event);
  98. });
  99. socket.addEventListener('close', () => {
  100. if (this.ws !== socket) return;
  101. this.isConnected = false;
  102. this.ws = null;
  103. this.#emit('disconnected');
  104. if (this.shouldReconnect) this.#scheduleReconnect();
  105. });
  106. }
  107. #buildUrl(): string {
  108. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  109. let basePath = this.basePath || '/';
  110. if (!basePath.startsWith('/')) basePath = '/' + basePath;
  111. if (!basePath.endsWith('/')) basePath += '/';
  112. return `${protocol}//${window.location.host}${basePath}ws`;
  113. }
  114. #onMessage(event: MessageEvent): void {
  115. const data = event.data;
  116. if (typeof data === 'string') {
  117. const byteLen = new Blob([data]).size;
  118. if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
  119. console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
  120. try {
  121. this.ws?.close(1009, 'message too big');
  122. } catch {}
  123. return;
  124. }
  125. }
  126. let message: unknown;
  127. try {
  128. message = JSON.parse(typeof data === 'string' ? data : '');
  129. } catch (err) {
  130. console.error('WebSocket: invalid JSON message', err);
  131. return;
  132. }
  133. if (
  134. !message ||
  135. typeof message !== 'object' ||
  136. typeof (message as { type?: unknown }).type !== 'string'
  137. ) {
  138. console.error('WebSocket: malformed message envelope');
  139. return;
  140. }
  141. const msg = message as WebSocketMessage;
  142. this.#emit(msg.type, msg.payload, msg.time);
  143. this.#emit('message', msg);
  144. }
  145. #emit(event: string, ...args: unknown[]): void {
  146. const set = this.listeners.get(event);
  147. if (!set) return;
  148. for (const callback of set) {
  149. try {
  150. callback(...args);
  151. } catch (err) {
  152. console.error(`WebSocket: handler for "${event}" threw`, err);
  153. }
  154. }
  155. }
  156. #scheduleReconnect(): void {
  157. if (!this.shouldReconnect) return;
  158. this.#cancelReconnect();
  159. let base: number;
  160. if (this.reconnectAttempts < this.maxReconnectAttempts) {
  161. this.reconnectAttempts += 1;
  162. const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
  163. base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
  164. } else {
  165. base = WebSocketClient.#SLOW_RETRY_MS;
  166. }
  167. const delay = base * (0.75 + Math.random() * 0.5);
  168. this.reconnectTimer = setTimeout(() => {
  169. this.reconnectTimer = null;
  170. if (!this.shouldReconnect) return;
  171. this.#openSocket();
  172. }, delay);
  173. }
  174. #cancelReconnect(): void {
  175. if (this.reconnectTimer !== null) {
  176. clearTimeout(this.reconnectTimer);
  177. this.reconnectTimer = null;
  178. }
  179. }
  180. }
  181. let sharedClient: WebSocketClient | null = null;
  182. export function getSharedWebSocketClient(): WebSocketClient {
  183. if (sharedClient) return sharedClient;
  184. const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
  185. sharedClient = new WebSocketClient(basePath);
  186. return sharedClient;
  187. }