websocket-ts 2.2.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +201 -63
  3. package/dist/cjs/{src/backoff → backoff}/backoff.d.ts +6 -3
  4. package/dist/{esm/src → cjs}/backoff/backoff.d.ts.map +1 -1
  5. package/dist/{esm/src → cjs}/backoff/backoff.js.map +1 -1
  6. package/dist/cjs/{src/backoff → backoff}/constantbackoff.d.ts +2 -2
  7. package/dist/cjs/backoff/constantbackoff.d.ts.map +1 -0
  8. package/dist/cjs/backoff/constantbackoff.js +34 -0
  9. package/dist/cjs/backoff/constantbackoff.js.map +1 -0
  10. package/dist/{esm/src → cjs}/backoff/exponentialbackoff.d.ts +2 -2
  11. package/dist/cjs/backoff/exponentialbackoff.d.ts.map +1 -0
  12. package/dist/cjs/{src/backoff → backoff}/exponentialbackoff.js +19 -27
  13. package/dist/cjs/backoff/exponentialbackoff.js.map +1 -0
  14. package/dist/{esm/src → cjs}/backoff/linearbackoff.d.ts +1 -1
  15. package/dist/cjs/backoff/linearbackoff.d.ts.map +1 -0
  16. package/dist/cjs/{src/backoff → backoff}/linearbackoff.js +23 -31
  17. package/dist/cjs/backoff/linearbackoff.js.map +1 -0
  18. package/dist/cjs/index.d.ts +14 -0
  19. package/dist/cjs/index.d.ts.map +1 -0
  20. package/dist/cjs/index.js +20 -0
  21. package/dist/cjs/index.js.map +1 -0
  22. package/dist/cjs/package.json +1 -0
  23. package/dist/{esm/src → cjs}/queue/array_queue.d.ts +1 -1
  24. package/dist/cjs/queue/array_queue.d.ts.map +1 -0
  25. package/dist/cjs/{src/queue → queue}/array_queue.js +17 -18
  26. package/dist/cjs/queue/array_queue.js.map +1 -0
  27. package/dist/cjs/queue/queue.d.ts.map +1 -0
  28. package/dist/cjs/{src/queue → queue}/queue.js.map +1 -1
  29. package/dist/cjs/{src/queue → queue}/ring_queue.d.ts +1 -1
  30. package/dist/cjs/queue/ring_queue.d.ts.map +1 -0
  31. package/dist/cjs/{src/queue → queue}/ring_queue.js +25 -22
  32. package/dist/cjs/queue/ring_queue.js.map +1 -0
  33. package/dist/cjs/{src/websocket.d.ts → websocket.d.ts} +84 -20
  34. package/dist/cjs/websocket.d.ts.map +1 -0
  35. package/dist/cjs/websocket.js +588 -0
  36. package/dist/cjs/websocket.js.map +1 -0
  37. package/dist/cjs/{src/websocket_buffer.d.ts → websocket_buffer.d.ts} +1 -1
  38. package/dist/cjs/websocket_buffer.d.ts.map +1 -0
  39. package/dist/cjs/websocket_buffer.js.map +1 -0
  40. package/dist/{esm/src → cjs}/websocket_builder.d.ts +22 -13
  41. package/dist/cjs/websocket_builder.d.ts.map +1 -0
  42. package/dist/cjs/websocket_builder.js +236 -0
  43. package/dist/cjs/websocket_builder.js.map +1 -0
  44. package/dist/cjs/websocket_event.d.ts +93 -0
  45. package/dist/cjs/websocket_event.d.ts.map +1 -0
  46. package/dist/cjs/websocket_event.js +27 -0
  47. package/dist/cjs/websocket_event.js.map +1 -0
  48. package/dist/{esm/src → cjs}/websocket_options.d.ts +6 -5
  49. package/dist/cjs/websocket_options.d.ts.map +1 -0
  50. package/dist/cjs/websocket_options.js.map +1 -0
  51. package/dist/{esm/src → cjs}/websocket_retry_options.d.ts +4 -3
  52. package/dist/cjs/websocket_retry_options.d.ts.map +1 -0
  53. package/dist/cjs/websocket_retry_options.js.map +1 -0
  54. package/dist/esm/{src/backoff → backoff}/backoff.d.ts +6 -3
  55. package/dist/{cjs/src → esm}/backoff/backoff.d.ts.map +1 -1
  56. package/dist/{cjs/src → esm}/backoff/backoff.js.map +1 -1
  57. package/dist/esm/{src/backoff → backoff}/constantbackoff.d.ts +2 -2
  58. package/dist/esm/backoff/constantbackoff.d.ts.map +1 -0
  59. package/dist/esm/{src/backoff → backoff}/constantbackoff.js +3 -3
  60. package/dist/esm/backoff/constantbackoff.js.map +1 -0
  61. package/dist/{cjs/src → esm}/backoff/exponentialbackoff.d.ts +2 -2
  62. package/dist/esm/backoff/exponentialbackoff.d.ts.map +1 -0
  63. package/dist/esm/{src/backoff → backoff}/exponentialbackoff.js +6 -5
  64. package/dist/esm/backoff/exponentialbackoff.js.map +1 -0
  65. package/dist/{cjs/src → esm}/backoff/linearbackoff.d.ts +1 -1
  66. package/dist/esm/backoff/linearbackoff.d.ts.map +1 -0
  67. package/dist/esm/{src/backoff → backoff}/linearbackoff.js +8 -7
  68. package/dist/esm/backoff/linearbackoff.js.map +1 -0
  69. package/dist/esm/index.d.ts +14 -0
  70. package/dist/esm/index.d.ts.map +1 -0
  71. package/dist/esm/index.js +9 -0
  72. package/dist/esm/index.js.map +1 -0
  73. package/dist/esm/package.json +1 -0
  74. package/dist/{cjs/src → esm}/queue/array_queue.d.ts +1 -1
  75. package/dist/esm/queue/array_queue.d.ts.map +1 -0
  76. package/dist/esm/queue/array_queue.js.map +1 -0
  77. package/dist/esm/queue/queue.d.ts.map +1 -0
  78. package/dist/esm/{src/queue → queue}/queue.js.map +1 -1
  79. package/dist/esm/{src/queue → queue}/ring_queue.d.ts +1 -1
  80. package/dist/esm/queue/ring_queue.d.ts.map +1 -0
  81. package/dist/esm/{src/queue → queue}/ring_queue.js +7 -3
  82. package/dist/esm/queue/ring_queue.js.map +1 -0
  83. package/dist/esm/{src/websocket.d.ts → websocket.d.ts} +84 -20
  84. package/dist/esm/websocket.d.ts.map +1 -0
  85. package/dist/esm/websocket.js +584 -0
  86. package/dist/esm/websocket.js.map +1 -0
  87. package/dist/esm/{src/websocket_buffer.d.ts → websocket_buffer.d.ts} +1 -1
  88. package/dist/esm/websocket_buffer.d.ts.map +1 -0
  89. package/dist/esm/websocket_buffer.js.map +1 -0
  90. package/dist/{cjs/src → esm}/websocket_builder.d.ts +22 -13
  91. package/dist/esm/websocket_builder.d.ts.map +1 -0
  92. package/dist/esm/{src/websocket_builder.js → websocket_builder.js} +42 -23
  93. package/dist/esm/websocket_builder.js.map +1 -0
  94. package/dist/esm/websocket_event.d.ts +93 -0
  95. package/dist/esm/websocket_event.d.ts.map +1 -0
  96. package/dist/esm/websocket_event.js +24 -0
  97. package/dist/esm/websocket_event.js.map +1 -0
  98. package/dist/{cjs/src → esm}/websocket_options.d.ts +6 -5
  99. package/dist/esm/websocket_options.d.ts.map +1 -0
  100. package/dist/esm/websocket_options.js.map +1 -0
  101. package/dist/{cjs/src → esm}/websocket_retry_options.d.ts +4 -3
  102. package/dist/esm/websocket_retry_options.d.ts.map +1 -0
  103. package/dist/esm/websocket_retry_options.js.map +1 -0
  104. package/package.json +44 -25
  105. package/src/backoff/backoff.ts +6 -3
  106. package/src/backoff/constantbackoff.ts +4 -4
  107. package/src/backoff/exponentialbackoff.ts +7 -6
  108. package/src/backoff/linearbackoff.ts +9 -8
  109. package/src/index.ts +14 -13
  110. package/src/queue/array_queue.ts +1 -1
  111. package/src/queue/ring_queue.ts +12 -8
  112. package/src/websocket.ts +343 -78
  113. package/src/websocket_buffer.ts +1 -3
  114. package/src/websocket_builder.ts +33 -22
  115. package/src/websocket_event.ts +47 -13
  116. package/src/websocket_options.ts +6 -5
  117. package/src/websocket_retry_options.ts +4 -3
  118. package/dist/cjs/src/backoff/constantbackoff.d.ts.map +0 -1
  119. package/dist/cjs/src/backoff/constantbackoff.js +0 -43
  120. package/dist/cjs/src/backoff/constantbackoff.js.map +0 -1
  121. package/dist/cjs/src/backoff/exponentialbackoff.d.ts.map +0 -1
  122. package/dist/cjs/src/backoff/exponentialbackoff.js.map +0 -1
  123. package/dist/cjs/src/backoff/linearbackoff.d.ts.map +0 -1
  124. package/dist/cjs/src/backoff/linearbackoff.js.map +0 -1
  125. package/dist/cjs/src/index.d.ts +0 -14
  126. package/dist/cjs/src/index.d.ts.map +0 -1
  127. package/dist/cjs/src/index.js +0 -20
  128. package/dist/cjs/src/index.js.map +0 -1
  129. package/dist/cjs/src/queue/array_queue.d.ts.map +0 -1
  130. package/dist/cjs/src/queue/array_queue.js.map +0 -1
  131. package/dist/cjs/src/queue/queue.d.ts.map +0 -1
  132. package/dist/cjs/src/queue/ring_queue.d.ts.map +0 -1
  133. package/dist/cjs/src/queue/ring_queue.js.map +0 -1
  134. package/dist/cjs/src/websocket.d.ts.map +0 -1
  135. package/dist/cjs/src/websocket.js +0 -435
  136. package/dist/cjs/src/websocket.js.map +0 -1
  137. package/dist/cjs/src/websocket_buffer.d.ts.map +0 -1
  138. package/dist/cjs/src/websocket_buffer.js.map +0 -1
  139. package/dist/cjs/src/websocket_builder.d.ts.map +0 -1
  140. package/dist/cjs/src/websocket_builder.js +0 -263
  141. package/dist/cjs/src/websocket_builder.js.map +0 -1
  142. package/dist/cjs/src/websocket_event.d.ts +0 -67
  143. package/dist/cjs/src/websocket_event.d.ts.map +0 -1
  144. package/dist/cjs/src/websocket_event.js +0 -22
  145. package/dist/cjs/src/websocket_event.js.map +0 -1
  146. package/dist/cjs/src/websocket_options.d.ts.map +0 -1
  147. package/dist/cjs/src/websocket_options.js.map +0 -1
  148. package/dist/cjs/src/websocket_retry_options.d.ts.map +0 -1
  149. package/dist/cjs/src/websocket_retry_options.js.map +0 -1
  150. package/dist/esm/src/backoff/constantbackoff.d.ts.map +0 -1
  151. package/dist/esm/src/backoff/constantbackoff.js.map +0 -1
  152. package/dist/esm/src/backoff/exponentialbackoff.d.ts.map +0 -1
  153. package/dist/esm/src/backoff/exponentialbackoff.js.map +0 -1
  154. package/dist/esm/src/backoff/linearbackoff.d.ts.map +0 -1
  155. package/dist/esm/src/backoff/linearbackoff.js.map +0 -1
  156. package/dist/esm/src/index.d.ts +0 -14
  157. package/dist/esm/src/index.d.ts.map +0 -1
  158. package/dist/esm/src/index.js +0 -9
  159. package/dist/esm/src/index.js.map +0 -1
  160. package/dist/esm/src/queue/array_queue.d.ts.map +0 -1
  161. package/dist/esm/src/queue/array_queue.js.map +0 -1
  162. package/dist/esm/src/queue/queue.d.ts.map +0 -1
  163. package/dist/esm/src/queue/ring_queue.d.ts.map +0 -1
  164. package/dist/esm/src/queue/ring_queue.js.map +0 -1
  165. package/dist/esm/src/websocket.d.ts.map +0 -1
  166. package/dist/esm/src/websocket.js +0 -357
  167. package/dist/esm/src/websocket.js.map +0 -1
  168. package/dist/esm/src/websocket_buffer.d.ts.map +0 -1
  169. package/dist/esm/src/websocket_buffer.js.map +0 -1
  170. package/dist/esm/src/websocket_builder.d.ts.map +0 -1
  171. package/dist/esm/src/websocket_builder.js.map +0 -1
  172. package/dist/esm/src/websocket_event.d.ts +0 -67
  173. package/dist/esm/src/websocket_event.d.ts.map +0 -1
  174. package/dist/esm/src/websocket_event.js +0 -19
  175. package/dist/esm/src/websocket_event.js.map +0 -1
  176. package/dist/esm/src/websocket_options.d.ts.map +0 -1
  177. package/dist/esm/src/websocket_options.js.map +0 -1
  178. package/dist/esm/src/websocket_retry_options.d.ts.map +0 -1
  179. package/dist/esm/src/websocket_retry_options.js.map +0 -1
  180. package/eslint.config.mjs +0 -43
  181. package/tsconfig.cjs.json +0 -74
  182. package/tsconfig.esm.json +0 -74
  183. package/websocket-ts-2.2.1.tgz +0 -0
  184. /package/dist/cjs/{src/backoff → backoff}/backoff.js +0 -0
  185. /package/dist/cjs/{src/queue → queue}/queue.d.ts +0 -0
  186. /package/dist/cjs/{src/queue → queue}/queue.js +0 -0
  187. /package/dist/cjs/{src/websocket_buffer.js → websocket_buffer.js} +0 -0
  188. /package/dist/cjs/{src/websocket_options.js → websocket_options.js} +0 -0
  189. /package/dist/cjs/{src/websocket_retry_options.js → websocket_retry_options.js} +0 -0
  190. /package/dist/esm/{src/backoff → backoff}/backoff.js +0 -0
  191. /package/dist/esm/{src/queue → queue}/array_queue.js +0 -0
  192. /package/dist/esm/{src/queue → queue}/queue.d.ts +0 -0
  193. /package/dist/esm/{src/queue → queue}/queue.js +0 -0
  194. /package/dist/esm/{src/websocket_buffer.js → websocket_buffer.js} +0 -0
  195. /package/dist/esm/{src/websocket_options.js → websocket_options.js} +0 -0
  196. /package/dist/esm/{src/websocket_retry_options.js → websocket_retry_options.js} +0 -0
package/src/websocket.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { Backoff } from "./backoff/backoff";
2
- import { WebsocketBuffer } from "./websocket_buffer";
1
+ import { Backoff } from "./backoff/backoff.js";
2
+ import { WebsocketBuffer } from "./websocket_buffer.js";
3
3
  import {
4
+ ExhaustedEventDetail,
4
5
  ReconnectEventDetail,
5
6
  RetryEventDetail,
6
7
  WebsocketEvent,
@@ -9,37 +10,97 @@ import {
9
10
  WebsocketEventListeners,
10
11
  WebsocketEventListenerWithOptions,
11
12
  WebsocketEventMap,
12
- } from "./websocket_event";
13
- import { WebsocketOptions } from "./websocket_options";
13
+ } from "./websocket_event.js";
14
+ import { WebsocketOptions } from "./websocket_options.js";
15
+ import { WebsocketConnectionRetryOptions } from "./websocket_retry_options.js";
16
+
17
+ /**
18
+ * A URL or a function that returns a URL. When a function is provided, it is called on each connection attempt,
19
+ * enabling use cases like load balancing, auth token rotation, and failover.
20
+ *
21
+ * The function is called without the websocket as `this` and should only resolve a URL. If, during a
22
+ * connection attempt after construction, it synchronously calls close() or reconnect() on the websocket, the
23
+ * attempt that called it is abandoned without creating a socket. It must not call reconnect() on every
24
+ * invocation, as that recurses indefinitely.
25
+ *
26
+ * If it throws during an automatic retry, that attempt fails with an 'error' event (an ErrorEvent whose
27
+ * 'error' is the thrown value) and retrying continues. During construction and reconnect(), the exception
28
+ * propagates to the caller instead.
29
+ */
30
+ export type UrlProvider = string | (() => string);
14
31
 
15
32
  /**
16
33
  * A websocket wrapper that can be configured to reconnect automatically and buffer messages when the websocket is not connected.
17
34
  */
18
35
  export class Websocket {
19
- private readonly _url: string; // the url to connect to
36
+ private readonly _urlProvider: UrlProvider; // the url or url provider
37
+ private _url!: string; // the last resolved url
20
38
  private readonly _protocols?: string | string[]; // the protocols to use
21
39
 
22
40
  private _closedByUser: boolean = false; // whether the websocket was closed by the user
23
41
  private _lastConnection?: Date; // timestamp of the last connection
24
- private _underlyingWebsocket: WebSocket; // the underlying websocket, e.g. native browser websocket
42
+ private _binaryType?: BinaryType; // binaryType chosen by the user, undefined if never set
43
+ private _underlyingWebsocket!: WebSocket; // the underlying websocket, e.g. native browser websocket; assigned by tryConnect()
25
44
  private retryTimeout?: ReturnType<typeof globalThis.setTimeout>; // timeout for the next retry, if any
26
45
 
27
- private _options: WebsocketOptions &
28
- Required<Pick<WebsocketOptions, "listeners" | "retry">>; // options/config for the websocket
46
+ // incremented by close() and reconnect() to invalidate in-flight lifecycle
47
+ // work: event handlers and retry timers belonging to an older generation
48
+ // must not commit further state transitions or create sockets
49
+ private _connectionGeneration = 0;
50
+
51
+ // for each listener-registration made with an AbortSignal, the function that
52
+ // unhooks the 'abort'-handler again; used to avoid piling up dead handlers on
53
+ // long-lived signals when listeners are removed by other means
54
+ private readonly abortHandlerCleanups = new WeakMap<object, () => void>();
55
+
56
+ // options/config for the websocket; internally the retry options and the
57
+ // listeners for every event-type are always present, even when the
58
+ // user-supplied options omitted them
59
+ private _options: Omit<WebsocketOptions, "listeners" | "retry"> & {
60
+ readonly retry: WebsocketConnectionRetryOptions;
61
+ readonly listeners: WebsocketEventListeners;
62
+ };
29
63
 
30
64
  /**
31
65
  * Creates a new websocket.
32
66
  *
33
- * @param url to connect to.
67
+ * @param url to connect to, or a function that returns a URL.
34
68
  * @param protocols optional protocols to use.
35
69
  * @param options optional options to use.
70
+ * @throws Error if retry options (maxRetries, instantReconnect) are set without a backoff.
71
+ * @throws Error if maxRetries is neither Infinity nor a non-negative integer.
36
72
  */
37
73
  constructor(
38
- url: string,
74
+ url: UrlProvider,
39
75
  protocols?: string | string[],
40
76
  options?: WebsocketOptions,
41
77
  ) {
42
- this._url = url;
78
+ if (
79
+ options?.retry?.backoff === undefined &&
80
+ (options?.retry?.maxRetries !== undefined ||
81
+ options?.retry?.instantReconnect !== undefined)
82
+ ) {
83
+ // retrying is driven by the backoff; without one, the other retry options would silently do nothing
84
+ throw new Error(
85
+ "Retry options (maxRetries, instantReconnect) require a backoff to be configured",
86
+ );
87
+ }
88
+ const maxRetries = options?.retry?.maxRetries;
89
+ if (
90
+ maxRetries !== undefined &&
91
+ maxRetries !== Infinity &&
92
+ (!Number.isInteger(maxRetries) || maxRetries < 0)
93
+ ) {
94
+ // NaN would silently never exhaust, negative values exhaust before any
95
+ // retry, and fractions break the promise that the exhausted-detail
96
+ // retries equal the configured limit; fail fast like the backoffs do.
97
+ // Infinity is accepted as an explicit 'no limit', like undefined
98
+ throw new Error(
99
+ "MaxRetries must be undefined, Infinity or a non-negative integer",
100
+ );
101
+ }
102
+
103
+ this._urlProvider = url;
43
104
  this._protocols = protocols;
44
105
 
45
106
  // make a copy of the options to prevent the user from changing them
@@ -51,20 +112,45 @@ export class Websocket {
51
112
  backoff: options?.retry?.backoff,
52
113
  },
53
114
  listeners: {
54
- open: [...(options?.listeners?.open ?? [])],
55
- close: [...(options?.listeners?.close ?? [])],
56
- error: [...(options?.listeners?.error ?? [])],
57
- message: [...(options?.listeners?.message ?? [])],
58
- retry: [...(options?.listeners?.retry ?? [])],
59
- reconnect: [...(options?.listeners?.reconnect ?? [])],
115
+ open: [],
116
+ close: [],
117
+ error: [],
118
+ message: [],
119
+ retry: [],
120
+ reconnect: [],
121
+ exhausted: [],
60
122
  },
61
123
  };
62
124
 
63
- this._underlyingWebsocket = this.tryConnect();
125
+ try {
126
+ // register the initial listeners through addEventListener so that
127
+ // listener options (e.g. 'signal') are honored for them as well; the
128
+ // generic helper keeps each event's listeners typed for that event
129
+ const register = <K extends WebsocketEvent>(type: K) =>
130
+ options?.listeners?.[type]?.forEach((l) =>
131
+ this.addEventListener(type, l.listener, l.options),
132
+ );
133
+ Object.values(WebsocketEvent).forEach(register);
134
+
135
+ // this first attempt always assigns the underlying websocket: the URL
136
+ // provider is called without the instance as 'this', so it cannot reach
137
+ // close() or reconnect() to supersede it
138
+ this.tryConnect();
139
+ } catch (error) {
140
+ // the caller never receives this instance and cannot remove its
141
+ // listeners, so unhook their abort-handlers; otherwise a long-lived
142
+ // signal would retain the failed instance and everything it references
143
+ Object.values(this._options.listeners).forEach((listeners) =>
144
+ listeners.forEach((l: object) => this.cleanupAbortHandler(l)),
145
+ );
146
+ throw error;
147
+ }
64
148
  }
65
149
 
66
150
  /**
67
- * Getter for the url.
151
+ * Getter for the url of the most recently constructed underlying websocket. This is not
152
+ * necessarily the URL of the last successful connection, and a URL that the WebSocket
153
+ * constructor rejected, or whose connection attempt was superseded, is not recorded.
68
154
  *
69
155
  * @return the url.
70
156
  */
@@ -127,12 +213,14 @@ export class Websocket {
127
213
  }
128
214
 
129
215
  /**
130
- * Getter for the last 'open' event, e.g. the last time the websocket was connected.
216
+ * Getter for the time of the last 'open' event, i.e. the last time the websocket was connected.
131
217
  *
132
- * @return the last 'open' event, or undefined if the websocket was never connected.
218
+ * @return the time of the last 'open' event, or undefined if the websocket was never connected.
133
219
  */
134
220
  get lastConnection(): Date | undefined {
135
- return this._lastConnection;
221
+ // defensive copy: retry/reconnect/exhausted event details are derived
222
+ // from the internal timestamp, so callers must not be able to mutate it
223
+ return this._lastConnection && new Date(this._lastConnection);
136
224
  }
137
225
 
138
226
  /**
@@ -186,11 +274,13 @@ export class Websocket {
186
274
  }
187
275
 
188
276
  /**
189
- * Setter for the binaryType of the underlying websocket.
277
+ * Setter for the binaryType of the underlying websocket. The value is remembered
278
+ * and re-applied whenever a new underlying websocket is created after a reconnect.
190
279
  *
191
280
  * @param value to set, 'blob' or 'arraybuffer'.
192
281
  */
193
282
  set binaryType(value: BinaryType) {
283
+ this._binaryType = value;
194
284
  this._underlyingWebsocket.binaryType = value;
195
285
  }
196
286
 
@@ -203,7 +293,7 @@ export class Websocket {
203
293
  * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send
204
294
  * @param data to send.
205
295
  */
206
- public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
296
+ public send(data: string | Blob | BufferSource): void {
207
297
  if (this.closedByUser) return; // no-op if closed by user
208
298
 
209
299
  if (
@@ -216,20 +306,47 @@ export class Websocket {
216
306
  }
217
307
 
218
308
  /**
219
- * Close the websocket. No connection-retry will be attempted after this.
309
+ * Close the websocket. No automatic connection-retry will be attempted after this,
310
+ * until reconnect() is called.
220
311
  *
221
312
  * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close
222
313
  * @param code optional close code.
223
314
  * @param reason optional close reason.
224
315
  */
225
316
  public close(code?: number, reason?: string): void {
317
+ this._connectionGeneration++; // invalidate in-flight lifecycle work and pending retries
226
318
  this.cancelScheduledConnectionRetry(); // cancel any scheduled retries
227
319
  this._closedByUser = true; // mark websocket as closed by user
228
320
  this._underlyingWebsocket.close(code, reason); // close underlying websocket with provided code and reason
229
321
  }
230
322
 
231
323
  /**
232
- * Adds an event listener for the given event-type.
324
+ * (Re-)connects the websocket immediately, regardless of its current state:
325
+ * any scheduled retry is cancelled, the backoff and its retry-counter are
326
+ * reset, the URL provider is resolved again and a new connection is opened.
327
+ * This also revives a websocket that was closed by the user or that gave up
328
+ * after exceeding maxRetries (see the 'exhausted' event).
329
+ *
330
+ * Typical uses: a "reconnect" button after the 'exhausted' event, reacting to
331
+ * the browser's 'online' event, or forcing the URL provider to pick up a new
332
+ * auth token without waiting for the connection to drop.
333
+ *
334
+ * @throws whatever the URL provider or the WebSocket constructor throws.
335
+ */
336
+ public reconnect(): void {
337
+ this._connectionGeneration++; // invalidate in-flight lifecycle work and pending retries
338
+ this.cancelScheduledConnectionRetry(); // cancel any scheduled retries
339
+ this._closedByUser = false; // revive if the websocket was closed by the user
340
+ this.backoff?.reset(); // start the next disconnection episode with a fresh retry budget
341
+ this.clearWebsocket(); // detach and close the current underlying websocket
342
+ this.tryConnect();
343
+ }
344
+
345
+ /**
346
+ * Adds an event listener for the given event-type. When an AbortSignal is
347
+ * provided in the options, aborting it removes exactly this registration;
348
+ * a listener whose signal is already aborted is never registered, mirroring
349
+ * the native EventTarget.
233
350
  *
234
351
  * @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
235
352
  * @param type of the event to add the listener for.
@@ -241,24 +358,56 @@ export class Websocket {
241
358
  listener: WebsocketEventListener<K>,
242
359
  options?: WebsocketEventListenerOptions,
243
360
  ): void {
244
- this._options.listeners[type].push({ listener, options }); // add listener to list of listeners
361
+ const signal = options?.signal;
362
+ if (signal?.aborted) {
363
+ return; // the signal is already aborted, never register the listener
364
+ }
365
+
366
+ const entry: WebsocketEventListenerWithOptions<K> = { listener, options };
367
+
368
+ if (signal !== undefined) {
369
+ // aborting the signal removes exactly this registration; the same
370
+ // listener-function may be registered again with a different signal
371
+ const removeOnAbort = () => {
372
+ this.abortHandlerCleanups.delete(entry);
373
+ (this._options.listeners[
374
+ type
375
+ ] as WebsocketEventListenerWithOptions<K>[]) = this._options.listeners[
376
+ type
377
+ ].filter((l) => l !== entry);
378
+ };
379
+ signal.addEventListener("abort", removeOnAbort, { once: true });
380
+ this.abortHandlerCleanups.set(entry, () =>
381
+ signal.removeEventListener("abort", removeOnAbort),
382
+ );
383
+ }
384
+
385
+ this._options.listeners[type].push(entry); // add listener to list of listeners
245
386
  }
246
387
 
247
388
  /**
248
- * Removes one or more event listener for the given event-type that match the given listener and options.
389
+ * Removes all event listeners for the given event-type that match the given listener.
390
+ *
391
+ * Matching is done by listener identity only, mirroring the native EventTarget:
392
+ * the options a listener was added with are ignored when matching.
249
393
  *
250
394
  * @param type of the event to remove the listener for.
251
395
  * @param listener to remove.
252
- * @param options that were used when the listener was added.
396
+ * @param options ignored when matching, present for backward compatibility.
253
397
  */
254
398
  public removeEventListener<K extends WebsocketEvent>(
255
399
  type: K,
256
400
  listener: WebsocketEventListener<K>,
401
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
257
402
  options?: WebsocketEventListenerOptions,
258
403
  ): void {
259
404
  const isListenerNotToBeRemoved = (
260
405
  l: WebsocketEventListenerWithOptions<K>,
261
- ) => l.listener !== listener || l.options !== options;
406
+ ) => l.listener !== listener;
407
+
408
+ this._options.listeners[type]
409
+ .filter((l) => !isListenerNotToBeRemoved(l))
410
+ .forEach((l) => this.cleanupAbortHandler(l)); // unhook abort-handlers of removed listeners
262
411
 
263
412
  (this._options.listeners[type] as WebsocketEventListenerWithOptions<K>[]) =
264
413
  this._options.listeners[type].filter(isListenerNotToBeRemoved); // only keep listeners that are not to be removed
@@ -266,12 +415,26 @@ export class Websocket {
266
415
 
267
416
  /**
268
417
  * Creates a new browser-native websocket and connects it to the given URL with the given protocols
269
- * and adds all event listeners to the browser-native websocket.
270
- *
271
- * @return the created browser-native websocket which is also stored in the '_underlyingWebsocket' property.
418
+ * and adds all event listeners to the browser-native websocket. The new websocket is stored in the
419
+ * '_underlyingWebsocket' property, unless the URL provider superseded this attempt by calling close()
420
+ * or reconnect().
272
421
  */
273
- private tryConnect(): WebSocket {
274
- this._underlyingWebsocket = new WebSocket(this.url, this.protocols); // create new browser-native websocket and add all event listeners
422
+ private tryConnect(): void {
423
+ const generation = this._connectionGeneration;
424
+ const urlProvider = this._urlProvider; // call the provider without the wrapper as receiver
425
+ const url = typeof urlProvider === "function" ? urlProvider() : urlProvider;
426
+ // the URL provider is user code and may have re-entered close() or
427
+ // reconnect(); that call now owns the lifecycle, so this attempt must
428
+ // neither open a socket after close() nor overwrite (and thereby orphan)
429
+ // the socket a nested reconnect() already committed
430
+ if (generation !== this._connectionGeneration) return;
431
+
432
+ const socket = new WebSocket(url, this.protocols); // create new browser-native websocket and add all event listeners
433
+ this._url = url;
434
+ this._underlyingWebsocket = socket;
435
+ if (this._binaryType !== undefined) {
436
+ this._underlyingWebsocket.binaryType = this._binaryType; // re-apply the user-chosen binaryType
437
+ }
275
438
  this._underlyingWebsocket.addEventListener(
276
439
  WebsocketEvent.open,
277
440
  this.handleOpenEvent,
@@ -288,8 +451,6 @@ export class Websocket {
288
451
  WebsocketEvent.message,
289
452
  this.handleMessageEvent,
290
453
  );
291
-
292
- return this._underlyingWebsocket;
293
454
  }
294
455
 
295
456
  /**
@@ -353,23 +514,55 @@ export class Websocket {
353
514
  type: K,
354
515
  event: WebsocketEventMap[K],
355
516
  ) {
356
- const eventListeners: WebsocketEventListeners[K] =
357
- this._options.listeners[type];
358
- const newEventListeners: WebsocketEventListeners[K] = [];
359
-
360
- eventListeners.forEach(({ listener, options }) => {
361
- listener(this, event); // invoke listener with event
362
-
363
- if (
364
- options === undefined ||
365
- options.once === undefined ||
366
- !options.once
367
- ) {
368
- newEventListeners.push({ listener, options }); // only keep listener if it isn't a once-listener
517
+ // iterate over a snapshot so that listeners registered during dispatch are
518
+ // not invoked in this round, while mutations of the live list persist
519
+ const snapshot: WebsocketEventListenerWithOptions<K>[] = [
520
+ ...this._options.listeners[type],
521
+ ];
522
+
523
+ snapshot.forEach((listenerWithOptions) => {
524
+ // re-read the live list on every invocation: a listener may have
525
+ // added/removed listeners of this type, or replaced the list entirely
526
+ const listeners: WebsocketEventListenerWithOptions<K>[] =
527
+ this._options.listeners[type];
528
+
529
+ const index = listeners.indexOf(listenerWithOptions);
530
+ if (index === -1) {
531
+ return; // listener was removed during dispatch, don't invoke it
532
+ }
533
+ if (listenerWithOptions.options?.once) {
534
+ listeners.splice(index, 1); // remove once-listener before invoking it
535
+ this.cleanupAbortHandler(listenerWithOptions); // unhook its abort-handler, if any
536
+ }
537
+
538
+ try {
539
+ listenerWithOptions.listener(this, event); // invoke listener with event
540
+ } catch (error) {
541
+ // a throwing listener must neither stop the remaining listeners nor the
542
+ // lifecycle work that follows dispatch (retry scheduling, reconnection),
543
+ // mirroring how the native EventTarget isolates listener exceptions
544
+ this.reportListenerError(error);
369
545
  }
370
546
  });
547
+ }
371
548
 
372
- this._options.listeners[type] = newEventListeners; // replace old listeners with new listeners that don't include once-listeners
549
+ /**
550
+ * Reports an exception thrown by a user-provided event listener. Uses
551
+ * globalThis.reportError where available (browsers); otherwise the exception
552
+ * is rethrown asynchronously so it still reaches the runtime's global
553
+ * error handling (e.g. 'uncaughtException' in Node.js) without breaking
554
+ * the dispatch it escaped from.
555
+ *
556
+ * @param error the exception thrown by the listener.
557
+ */
558
+ private reportListenerError(error: unknown) {
559
+ if (typeof globalThis.reportError === "function") {
560
+ globalThis.reportError(error);
561
+ } else {
562
+ globalThis.setTimeout(() => {
563
+ throw error;
564
+ }, 0);
565
+ }
373
566
  }
374
567
 
375
568
  /**
@@ -382,35 +575,63 @@ export class Websocket {
382
575
  type: K,
383
576
  event: WebsocketEventMap[K],
384
577
  ) {
578
+ // a listener may re-enter close() or reconnect() during dispatch; both bump
579
+ // the generation, in which case the transitions below must not be committed
580
+ const generation = this._connectionGeneration;
581
+
385
582
  switch (type) {
386
583
  case WebsocketEvent.close:
387
584
  this.dispatchEvent(type, event);
585
+ if (generation !== this._connectionGeneration) break; // a listener already closed/reconnected
388
586
  this.scheduleConnectionRetryIfNeeded(); // schedule a new connection retry if the websocket was closed by the server
389
587
  break;
390
588
 
391
- case WebsocketEvent.open:
392
- if (this.backoff !== undefined && this._lastConnection !== undefined) {
393
- // websocket was reconnected, dispatch reconnect event and reset backoff
589
+ case WebsocketEvent.open: {
590
+ if (this.backoff !== undefined && this.backoff.retries > 0) {
591
+ // a retry preceded this open, so the websocket was reconnected; this
592
+ // includes recovery from a server that was unavailable at construction,
593
+ // where no previous connection exists yet
394
594
  const detail: ReconnectEventDetail = {
395
595
  retries: this.backoff.retries,
396
- lastConnection: new Date(this._lastConnection),
596
+ lastConnection:
597
+ this._lastConnection && new Date(this._lastConnection),
397
598
  };
599
+ this.backoff.reset(); // reset before dispatch, so listeners observe a fresh retry budget
398
600
  const event: CustomEvent<ReconnectEventDetail> =
399
601
  new CustomEvent<ReconnectEventDetail>(WebsocketEvent.reconnect, {
400
602
  detail,
401
603
  });
402
604
  this.dispatchEvent(WebsocketEvent.reconnect, event);
403
- this.backoff.reset();
605
+ if (generation !== this._connectionGeneration) break;
404
606
  }
405
607
  this._lastConnection = new Date();
406
608
  this.dispatchEvent(type, event); // dispatch open event and send buffered data
609
+ if (generation !== this._connectionGeneration) break;
407
610
  this.sendBufferedData();
408
611
  break;
612
+ }
409
613
 
410
614
  case WebsocketEvent.retry:
411
615
  this.dispatchEvent(type, event); // dispatch retry event and try to connect
616
+ if (generation !== this._connectionGeneration) break; // this attempt was superseded during dispatch
412
617
  this.clearWebsocket(); // clear the old websocket
413
- this.tryConnect();
618
+ try {
619
+ this.tryConnect();
620
+ } catch (error) {
621
+ // the url provider or websocket-construction threw; surface the thrown
622
+ // value as an 'error' event and keep the retry chain alive under the
623
+ // usual rules. Native error events are plain Events, so listeners can
624
+ // tell this case apart with 'instanceof ErrorEvent'
625
+ this.dispatchEvent(
626
+ WebsocketEvent.error,
627
+ new ErrorEvent(WebsocketEvent.error, {
628
+ error,
629
+ message: error instanceof Error ? error.message : "",
630
+ }),
631
+ );
632
+ if (generation !== this._connectionGeneration) break;
633
+ this.scheduleConnectionRetryIfNeeded();
634
+ }
414
635
  break;
415
636
 
416
637
  default:
@@ -420,18 +641,24 @@ export class Websocket {
420
641
  }
421
642
 
422
643
  /**
423
- * Sends buffered data if there is a buffer defined.
644
+ * Sends buffered data if there is a buffer defined. Draining stops as soon as the
645
+ * websocket is no longer open, e.g. when a listener closed it during the drain;
646
+ * remaining elements stay buffered for the next successful connection. Without
647
+ * this guard, send() would re-add each read element to the buffer, cycling forever.
424
648
  */
425
649
  private sendBufferedData() {
426
650
  if (this.buffer === undefined) {
427
651
  return; // no buffer defined, nothing to send
428
652
  }
429
653
 
430
- for (
431
- let ele = this.buffer.read();
432
- ele !== undefined;
433
- ele = this.buffer.read()
654
+ while (
655
+ !this.closedByUser &&
656
+ this._underlyingWebsocket.readyState === this._underlyingWebsocket.OPEN
434
657
  ) {
658
+ const ele = this.buffer.read();
659
+ if (ele === undefined) {
660
+ return; // buffer is empty
661
+ }
435
662
  this.send(ele); // send buffered data
436
663
  }
437
664
  }
@@ -456,27 +683,50 @@ export class Websocket {
456
683
  this.handleEvent(WebsocketEvent.retry, event);
457
684
  };
458
685
 
459
- // create retry event detail, depending on the 'instantReconnect' option
686
+ // when the maximum number of retries is exceeded, give up and dispatch the
687
+ // exhausted event; checked before advancing the backoff so that the counter
688
+ // equals the number of retries actually performed
689
+ if (
690
+ this._options.retry.maxRetries !== undefined &&
691
+ this.backoff.retries >= this._options.retry.maxRetries
692
+ ) {
693
+ const detail: ExhaustedEventDetail = {
694
+ retries: this.backoff.retries,
695
+ lastConnection: this._lastConnection && new Date(this._lastConnection),
696
+ };
697
+ const event: CustomEvent<ExhaustedEventDetail> =
698
+ new CustomEvent<ExhaustedEventDetail>(WebsocketEvent.exhausted, {
699
+ detail,
700
+ });
701
+ this.dispatchEvent(WebsocketEvent.exhausted, event);
702
+ return;
703
+ }
704
+
705
+ // the backoff is reset on every successful reconnect, so a retry-count of
706
+ // zero means this is the first retry of the current disconnection episode
707
+ const isFirstRetryOfEpisode = this.backoff.retries === 0;
708
+
709
+ // advance the backoff so the retry-count is accurate and maxRetries applies;
710
+ // with 'instantReconnect' only the episode's first retry is instant. Browsers
711
+ // store timer delays as signed 32-bit integers, so longer delays would
712
+ // overflow and fire (almost) immediately; cap them at the maximum instead
713
+ const backoff = Math.min(this.backoff.next(), 2 ** 31 - 1);
460
714
  const retryEventDetail: RetryEventDetail = {
461
715
  backoff:
462
- this._options.retry.instantReconnect === true ? 0 : this.backoff.next(),
463
- retries:
464
- this._options.retry.instantReconnect === true
716
+ this._options.retry.instantReconnect === true && isFirstRetryOfEpisode
465
717
  ? 0
466
- : this.backoff.retries,
467
- lastConnection: this._lastConnection,
718
+ : backoff,
719
+ retries: this.backoff.retries,
720
+ lastConnection: this._lastConnection && new Date(this._lastConnection), // copy so listeners can't mutate our state
468
721
  };
469
722
 
470
- // schedule a new connection-retry if the maximum number of retries is not reached yet
471
- if (
472
- this._options.retry.maxRetries === undefined ||
473
- retryEventDetail.retries <= this._options.retry.maxRetries
474
- ) {
475
- this.retryTimeout = globalThis.setTimeout(
476
- () => handleRetryEvent(retryEventDetail),
477
- retryEventDetail.backoff,
478
- );
479
- }
723
+ // no generation check is needed here: close() and reconnect() bump the
724
+ // generation and synchronously cancel this timeout, so a superseded
725
+ // attempt can never fire
726
+ this.retryTimeout = globalThis.setTimeout(() => {
727
+ this.retryTimeout = undefined;
728
+ handleRetryEvent(retryEventDetail);
729
+ }, retryEventDetail.backoff);
480
730
  }
481
731
 
482
732
  /**
@@ -484,5 +734,20 @@ export class Websocket {
484
734
  */
485
735
  private cancelScheduledConnectionRetry() {
486
736
  globalThis.clearTimeout(this.retryTimeout);
737
+ this.retryTimeout = undefined;
738
+ }
739
+
740
+ /**
741
+ * Unhooks the 'abort'-handler that was registered on the AbortSignal of the
742
+ * given listener-registration, if there is one.
743
+ *
744
+ * @param entry the listener-registration to unhook the abort-handler for.
745
+ */
746
+ private cleanupAbortHandler(entry: object) {
747
+ const cleanup = this.abortHandlerCleanups.get(entry);
748
+ if (cleanup !== undefined) {
749
+ cleanup();
750
+ this.abortHandlerCleanups.delete(entry);
751
+ }
487
752
  }
488
753
  }
@@ -1,9 +1,7 @@
1
1
  /**
2
2
  * A WebsocketBuffer is used to store messages temporarily until they can be sent.
3
3
  */
4
- export interface WebsocketBuffer<
5
- E = string | ArrayBufferLike | Blob | ArrayBufferView,
6
- > {
4
+ export interface WebsocketBuffer<E = string | Blob | BufferSource> {
7
5
  /**
8
6
  * Adds an element to the buffer.
9
7
  * @param element the element to add