-
Notifications
You must be signed in to change notification settings - Fork 17
/
websocket.ts
488 lines (440 loc) · 15.1 KB
/
websocket.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
import { Backoff } from "./backoff/backoff";
import { WebsocketBuffer } from "./websocket_buffer";
import {
ReconnectEventDetail,
RetryEventDetail,
WebsocketEvent,
WebsocketEventListener,
WebsocketEventListenerOptions,
WebsocketEventListeners,
WebsocketEventListenerWithOptions,
WebsocketEventMap,
} from "./websocket_event";
import { WebsocketOptions } from "./websocket_options";
/**
* A websocket wrapper that can be configured to reconnect automatically and buffer messages when the websocket is not connected.
*/
export class Websocket {
private readonly _url: string; // the url to connect to
private readonly _protocols?: string | string[]; // the protocols to use
private _closedByUser: boolean = false; // whether the websocket was closed by the user
private _lastConnection?: Date; // timestamp of the last connection
private _underlyingWebsocket: WebSocket; // the underlying websocket, e.g. native browser websocket
private retryTimeout?: ReturnType<typeof globalThis.setTimeout>; // timeout for the next retry, if any
private _options: WebsocketOptions &
Required<Pick<WebsocketOptions, "listeners" | "retry">>; // options/config for the websocket
/**
* Creates a new websocket.
*
* @param url to connect to.
* @param protocols optional protocols to use.
* @param options optional options to use.
*/
constructor(
url: string,
protocols?: string | string[],
options?: WebsocketOptions,
) {
this._url = url;
this._protocols = protocols;
// make a copy of the options to prevent the user from changing them
this._options = {
buffer: options?.buffer,
retry: {
maxRetries: options?.retry?.maxRetries,
instantReconnect: options?.retry?.instantReconnect,
backoff: options?.retry?.backoff,
},
listeners: {
open: [...(options?.listeners?.open ?? [])],
close: [...(options?.listeners?.close ?? [])],
error: [...(options?.listeners?.error ?? [])],
message: [...(options?.listeners?.message ?? [])],
retry: [...(options?.listeners?.retry ?? [])],
reconnect: [...(options?.listeners?.reconnect ?? [])],
},
};
this._underlyingWebsocket = this.tryConnect();
}
/**
* Getter for the url.
*
* @return the url.
*/
get url(): string {
return this._url;
}
/**
* Getter for the protocols.
*
* @return the protocols, or undefined if none were provided.
*/
get protocols(): string | string[] | undefined {
return this._protocols;
}
/**
* Getter for the buffer.
*
* @return the buffer, or undefined if none was provided.
*/
get buffer(): WebsocketBuffer | undefined {
return this._options.buffer;
}
/**
* Getter for the maxRetries.
*
* @return the maxRetries, or undefined if none was provided (no limit).
*/
get maxRetries(): number | undefined {
return this._options.retry.maxRetries;
}
/**
* Getter for the instantReconnect.
*
* @return the instantReconnect, or undefined if none was provided.
*/
get instantReconnect(): boolean | undefined {
return this._options.retry.instantReconnect;
}
/**
* Getter for the backoff.
*
* @return the backoff, or undefined if none was provided.
*/
get backoff(): Backoff | undefined {
return this._options.retry.backoff;
}
/**
* Whether the websocket was closed by the user. A websocket is closed by the user if the close().
*
* @return true if the websocket was closed by the user, false otherwise.
*/
get closedByUser(): boolean {
return this._closedByUser;
}
/**
* Getter for the last 'open' event, e.g. the last time the websocket was connected.
*
* @return the last 'open' event, or undefined if the websocket was never connected.
*/
get lastConnection(): Date | undefined {
return this._lastConnection;
}
/**
* Getter for the underlying websocket. This can be used to access the browser's native websocket directly.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
* @return the underlying websocket.
*/
get underlyingWebsocket(): WebSocket {
return this._underlyingWebsocket;
}
/**
* Getter for the readyState of the underlying websocket.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
* @return the readyState of the underlying websocket.
*/
get readyState(): number {
return this._underlyingWebsocket.readyState;
}
/**
* Getter for the bufferedAmount of the underlying websocket.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/bufferedAmount
* @return the bufferedAmount of the underlying websocket.
*/
get bufferedAmount(): number {
return this._underlyingWebsocket.bufferedAmount;
}
/**
* Getter for the extensions of the underlying websocket.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/extensions
* @return the extensions of the underlying websocket.
*/
get extensions(): string {
return this._underlyingWebsocket.extensions;
}
/**
* Getter for the binaryType of the underlying websocket.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/binaryType
* @return the binaryType of the underlying websocket.
*/
get binaryType(): BinaryType {
return this._underlyingWebsocket.binaryType;
}
/**
* Setter for the binaryType of the underlying websocket.
*
* @param value to set, 'blob' or 'arraybuffer'.
*/
set binaryType(value: BinaryType) {
this._underlyingWebsocket.binaryType = value;
}
/**
* Sends data over the websocket.
*
* If the websocket is not connected and a buffer was provided on creation, the data will be added to the buffer.
* If no buffer was provided or the websocket was closed by the user, the data will be dropped.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send
* @param data to send.
*/
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
if (this.closedByUser) return; // no-op if closed by user
if (
this._underlyingWebsocket.readyState === this._underlyingWebsocket.OPEN
) {
this._underlyingWebsocket.send(data); // websocket is connected, send data
} else if (this.buffer !== undefined) {
this.buffer.add(data); // websocket is not connected, add data to buffer
}
}
/**
* Close the websocket. No connection-retry will be attempted after this.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close
* @param code optional close code.
* @param reason optional close reason.
*/
public close(code?: number, reason?: string): void {
this.cancelScheduledConnectionRetry(); // cancel any scheduled retries
this._closedByUser = true; // mark websocket as closed by user
this._underlyingWebsocket.close(code, reason); // close underlying websocket with provided code and reason
}
/**
* Adds an event listener for the given event-type.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
* @param type of the event to add the listener for.
* @param listener to add.
* @param options to use when adding the listener.
*/
public addEventListener<K extends WebsocketEvent>(
type: K,
listener: WebsocketEventListener<K>,
options?: WebsocketEventListenerOptions,
): void {
this._options.listeners[type].push({ listener, options }); // add listener to list of listeners
}
/**
* Removes one or more event listener for the given event-type that match the given listener and options.
*
* @param type of the event to remove the listener for.
* @param listener to remove.
* @param options that were used when the listener was added.
*/
public removeEventListener<K extends WebsocketEvent>(
type: K,
listener: WebsocketEventListener<K>,
options?: WebsocketEventListenerOptions,
): void {
const isListenerNotToBeRemoved = (
l: WebsocketEventListenerWithOptions<K>,
) => l.listener !== listener || l.options !== options;
(this._options.listeners[type] as WebsocketEventListenerWithOptions<K>[]) =
this._options.listeners[type].filter(isListenerNotToBeRemoved); // only keep listeners that are not to be removed
}
/**
* Creates a new browser-native websocket and connects it to the given URL with the given protocols
* and adds all event listeners to the browser-native websocket.
*
* @return the created browser-native websocket which is also stored in the '_underlyingWebsocket' property.
*/
private tryConnect(): WebSocket {
this._underlyingWebsocket = new WebSocket(this.url, this.protocols); // create new browser-native websocket and add all event listeners
this._underlyingWebsocket.addEventListener(
WebsocketEvent.open,
this.handleOpenEvent,
);
this._underlyingWebsocket.addEventListener(
WebsocketEvent.close,
this.handleCloseEvent,
);
this._underlyingWebsocket.addEventListener(
WebsocketEvent.error,
this.handleErrorEvent,
);
this._underlyingWebsocket.addEventListener(
WebsocketEvent.message,
this.handleMessageEvent,
);
return this._underlyingWebsocket;
}
/**
* Removes all event listeners from the browser-native websocket and closes it.
*/
private clearWebsocket() {
this._underlyingWebsocket.removeEventListener(
WebsocketEvent.open,
this.handleOpenEvent,
);
this._underlyingWebsocket.removeEventListener(
WebsocketEvent.close,
this.handleCloseEvent,
);
this._underlyingWebsocket.removeEventListener(
WebsocketEvent.error,
this.handleErrorEvent,
);
this._underlyingWebsocket.removeEventListener(
WebsocketEvent.message,
this.handleMessageEvent,
);
this._underlyingWebsocket.close();
}
/**
* Handles the 'open' event of the browser-native websocket.
* @param event to handle.
*/
private handleOpenEvent = (event: Event) =>
this.handleEvent(WebsocketEvent.open, event);
/**
* Handles the 'error' event of the browser-native websocket.
* @param event to handle.
*/
private handleErrorEvent = (event: Event) =>
this.handleEvent(WebsocketEvent.error, event);
/**
* Handles the 'close' event of the browser-native websocket.
* @param event to handle.
*/
private handleCloseEvent = (event: CloseEvent) =>
this.handleEvent(WebsocketEvent.close, event);
/**
* Handles the 'message' event of the browser-native websocket.
* @param event to handle.
*/
private handleMessageEvent = (event: MessageEvent) =>
this.handleEvent(WebsocketEvent.message, event);
/**
* Dispatch an event to all listeners of the given event-type.
*
* @param type of the event to dispatch.
* @param event to dispatch.
*/
private dispatchEvent<K extends WebsocketEvent>(
type: K,
event: WebsocketEventMap[K],
) {
const eventListeners: WebsocketEventListeners[K] =
this._options.listeners[type];
const newEventListeners: WebsocketEventListeners[K] = [];
eventListeners.forEach(({ listener, options }) => {
listener(this, event); // invoke listener with event
if (
options === undefined ||
options.once === undefined ||
!options.once
) {
newEventListeners.push({ listener, options }); // only keep listener if it isn't a once-listener
}
});
this._options.listeners[type] = newEventListeners; // replace old listeners with new listeners that don't include once-listeners
}
/**
* Handles the given event by dispatching it to all listeners of the given event-type.
*
* @param type of the event to handle.
* @param event to handle.
*/
private handleEvent<K extends WebsocketEvent>(
type: K,
event: WebsocketEventMap[K],
) {
switch (type) {
case WebsocketEvent.close:
this.dispatchEvent(type, event);
this.scheduleConnectionRetryIfNeeded(); // schedule a new connection retry if the websocket was closed by the server
break;
case WebsocketEvent.open:
if (this.backoff !== undefined && this._lastConnection !== undefined) {
// websocket was reconnected, dispatch reconnect event and reset backoff
const detail: ReconnectEventDetail = {
retries: this.backoff.retries,
lastConnection: new Date(this._lastConnection),
};
const event: CustomEvent<ReconnectEventDetail> =
new CustomEvent<ReconnectEventDetail>(WebsocketEvent.reconnect, {
detail,
});
this.dispatchEvent(WebsocketEvent.reconnect, event);
this.backoff.reset();
}
this._lastConnection = new Date();
this.dispatchEvent(type, event); // dispatch open event and send buffered data
this.sendBufferedData();
break;
case WebsocketEvent.retry:
this.dispatchEvent(type, event); // dispatch retry event and try to connect
this.clearWebsocket(); // clear the old websocket
this.tryConnect();
break;
default:
this.dispatchEvent(type, event); // dispatch event to all listeners of the given event-type
break;
}
}
/**
* Sends buffered data if there is a buffer defined.
*/
private sendBufferedData() {
if (this.buffer === undefined) {
return; // no buffer defined, nothing to send
}
for (
let ele = this.buffer.read();
ele !== undefined;
ele = this.buffer.read()
) {
this.send(ele); // send buffered data
}
}
/**
* Schedules a connection-retry if there is a backoff defined and the websocket was not closed by the user.
*/
private scheduleConnectionRetryIfNeeded() {
if (this.closedByUser) {
return; // user closed the websocket, no retry
}
if (this.backoff === undefined) {
return; // no backoff defined, no retry
}
// handler dispatches the retry event to all listeners of the retry event-type
const handleRetryEvent = (detail: RetryEventDetail) => {
const event: CustomEvent<RetryEventDetail> = new CustomEvent(
WebsocketEvent.retry,
{ detail },
);
this.handleEvent(WebsocketEvent.retry, event);
};
// create retry event detail, depending on the 'instantReconnect' option
const retryEventDetail: RetryEventDetail = {
backoff:
this._options.retry.instantReconnect === true ? 0 : this.backoff.next(),
retries:
this._options.retry.instantReconnect === true
? 0
: this.backoff.retries,
lastConnection: this._lastConnection,
};
// schedule a new connection-retry if the maximum number of retries is not reached yet
if (
this._options.retry.maxRetries === undefined ||
retryEventDetail.retries <= this._options.retry.maxRetries
) {
this.retryTimeout = globalThis.setTimeout(
() => handleRetryEvent(retryEventDetail),
retryEventDetail.backoff,
);
}
}
/**
* Cancels the scheduled connection-retry, if there is one.
*/
private cancelScheduledConnectionRetry() {
globalThis.clearTimeout(this.retryTimeout);
}
}