websocket.ts 5.3 KB

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