1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 | import * as Events from './events';
|
9 |
|
10 | const getGlobalWebSocket = (): WebSocket | undefined => {
|
11 | if (typeof WebSocket !== 'undefined') {
|
12 |
|
13 | return WebSocket;
|
14 | }
|
15 | };
|
16 |
|
17 |
|
18 |
|
19 |
|
20 | const isWebSocket = (w: any) => typeof w !== 'undefined' && !!w && w.CLOSING === 2;
|
21 |
|
22 | export type Event = Events.Event;
|
23 | export type ErrorEvent = Events.ErrorEvent;
|
24 | export type CloseEvent = Events.CloseEvent;
|
25 |
|
26 | export type Options = {
|
27 | WebSocket?: any;
|
28 | maxReconnectionDelay?: number;
|
29 | minReconnectionDelay?: number;
|
30 | reconnectionDelayGrowFactor?: number;
|
31 | minUptime?: number;
|
32 | connectionTimeout?: number;
|
33 | maxRetries?: number;
|
34 | maxEnqueuedMessages?: number;
|
35 | startClosed?: boolean;
|
36 | debug?: boolean;
|
37 | };
|
38 |
|
39 | const DEFAULT = {
|
40 | maxReconnectionDelay: 10000,
|
41 | minReconnectionDelay: 1000 + Math.random() * 4000,
|
42 | minUptime: 5000,
|
43 | reconnectionDelayGrowFactor: 1.3,
|
44 | connectionTimeout: 4000,
|
45 | maxRetries: Infinity,
|
46 | maxEnqueuedMessages: Infinity,
|
47 | startClosed: false,
|
48 | debug: false,
|
49 | };
|
50 |
|
51 | export type UrlProvider = string | (() => string) | (() => Promise<string>);
|
52 |
|
53 | export type Message = string | ArrayBuffer | Blob | ArrayBufferView;
|
54 |
|
55 | export type ListenersMap = {
|
56 | error: Array<Events.WebSocketEventListenerMap['error']>;
|
57 | message: Array<Events.WebSocketEventListenerMap['message']>;
|
58 | open: Array<Events.WebSocketEventListenerMap['open']>;
|
59 | close: Array<Events.WebSocketEventListenerMap['close']>;
|
60 | };
|
61 |
|
62 | export default class ReconnectingWebSocket {
|
63 | private _ws?: WebSocket;
|
64 | private _listeners: ListenersMap = {
|
65 | error: [],
|
66 | message: [],
|
67 | open: [],
|
68 | close: [],
|
69 | };
|
70 | private _retryCount = -1;
|
71 | private _uptimeTimeout: any;
|
72 | private _connectTimeout: any;
|
73 | private _shouldReconnect = true;
|
74 | private _connectLock = false;
|
75 | private _binaryType: BinaryType = 'blob';
|
76 | private _closeCalled = false;
|
77 | private _messageQueue: Message[] = [];
|
78 |
|
79 | private readonly _url: UrlProvider;
|
80 | private readonly _protocols?: string | string[];
|
81 | private readonly _options: Options;
|
82 |
|
83 | constructor(url: UrlProvider, protocols?: string | string[], options: Options = {}) {
|
84 | this._url = url;
|
85 | this._protocols = protocols;
|
86 | this._options = options;
|
87 | if (this._options.startClosed) {
|
88 | this._shouldReconnect = false;
|
89 | }
|
90 | this._connect();
|
91 | }
|
92 |
|
93 | static get CONNECTING() {
|
94 | return 0;
|
95 | }
|
96 | static get OPEN() {
|
97 | return 1;
|
98 | }
|
99 | static get CLOSING() {
|
100 | return 2;
|
101 | }
|
102 | static get CLOSED() {
|
103 | return 3;
|
104 | }
|
105 |
|
106 | get CONNECTING() {
|
107 | return ReconnectingWebSocket.CONNECTING;
|
108 | }
|
109 | get OPEN() {
|
110 | return ReconnectingWebSocket.OPEN;
|
111 | }
|
112 | get CLOSING() {
|
113 | return ReconnectingWebSocket.CLOSING;
|
114 | }
|
115 | get CLOSED() {
|
116 | return ReconnectingWebSocket.CLOSED;
|
117 | }
|
118 |
|
119 | get binaryType() {
|
120 | return this._ws ? this._ws.binaryType : this._binaryType;
|
121 | }
|
122 |
|
123 | set binaryType(value: BinaryType) {
|
124 | this._binaryType = value;
|
125 | if (this._ws) {
|
126 | this._ws.binaryType = value;
|
127 | }
|
128 | }
|
129 |
|
130 | /**
|
131 | * Returns the number or connection retries
|
132 | */
|
133 | get retryCount(): number {
|
134 | return Math.max(this._retryCount, 0);
|
135 | }
|
136 |
|
137 | /**
|
138 | * The number of bytes of data that have been queued using calls to send() but not yet
|
139 | * transmitted to the network. This value resets to zero once all queued data has been sent.
|
140 | * This value does not reset to zero when the connection is closed; if you keep calling send(),
|
141 | * this will continue to climb. Read only
|
142 | */
|
143 | get bufferedAmount(): number {
|
144 | const bytes = this._messageQueue.reduce((acc, message) => {
|
145 | if (typeof message === 'string') {
|
146 | acc += message.length;
|
147 | } else if (message instanceof Blob) {
|
148 | acc += message.size;
|
149 | } else {
|
150 | acc += message.byteLength;
|
151 | }
|
152 | return acc;
|
153 | }, 0);
|
154 | return bytes + (this._ws ? this._ws.bufferedAmount : 0);
|
155 | }
|
156 |
|
157 | /**
|
158 | * The extensions selected by the server. This is currently only the empty string or a list of
|
159 | * extensions as negotiated by the connection
|
160 | */
|
161 | get extensions(): string {
|
162 | return this._ws ? this._ws.extensions : '';
|
163 | }
|
164 |
|
165 | /**
|
166 | * A string indicating the name of the sub-protocol the server selected;
|
167 | * this will be one of the strings specified in the protocols parameter when creating the
|
168 | * WebSocket object
|
169 | */
|
170 | get protocol(): string {
|
171 | return this._ws ? this._ws.protocol : '';
|
172 | }
|
173 |
|
174 | /**
|
175 | * The current state of the connection; this is one of the Ready state constants
|
176 | */
|
177 | get readyState(): number {
|
178 | if (this._ws) {
|
179 | return this._ws.readyState;
|
180 | }
|
181 | return this._options.startClosed
|
182 | ? ReconnectingWebSocket.CLOSED
|
183 | : ReconnectingWebSocket.CONNECTING;
|
184 | }
|
185 |
|
186 | /**
|
187 | * The URL as resolved by the constructor
|
188 | */
|
189 | get url(): string {
|
190 | return this._ws ? this._ws.url : '';
|
191 | }
|
192 |
|
193 | /**
|
194 | * An event listener to be called when the WebSocket connection's readyState changes to CLOSED
|
195 | */
|
196 | public onclose: ((event: Events.CloseEvent) => void) | null = null;
|
197 |
|
198 | /**
|
199 | * An event listener to be called when an error occurs
|
200 | */
|
201 | public onerror: ((event: Events.ErrorEvent) => void) | null = null;
|
202 |
|
203 | /**
|
204 | * An event listener to be called when a message is received from the server
|
205 | */
|
206 | public onmessage: ((event: MessageEvent) => void) | null = null;
|
207 |
|
208 | /**
|
209 | * An event listener to be called when the WebSocket connection's readyState changes to OPEN;
|
210 | * this indicates that the connection is ready to send and receive data
|
211 | */
|
212 | public onopen: ((event: Event) => void) | null = null;
|
213 |
|
214 | /**
|
215 | * Closes the WebSocket connection or connection attempt, if any. If the connection is already
|
216 | * CLOSED, this method does nothing
|
217 | */
|
218 | public close(code: number = 1000, reason?: string) {
|
219 | this._closeCalled = true;
|
220 | this._shouldReconnect = false;
|
221 | this._clearTimeouts();
|
222 | if (!this._ws) {
|
223 | this._debug('close enqueued: no ws instance');
|
224 | return;
|
225 | }
|
226 | if (this._ws.readyState === this.CLOSED) {
|
227 | this._debug('close: already closed');
|
228 | return;
|
229 | }
|
230 | this._ws.close(code, reason);
|
231 | }
|
232 |
|
233 | /**
|
234 | * Closes the WebSocket connection or connection attempt and connects again.
|
235 | * Resets retry counter;
|
236 | */
|
237 | public reconnect(code?: number, reason?: string) {
|
238 | this._shouldReconnect = true;
|
239 | this._closeCalled = false;
|
240 | this._retryCount = -1;
|
241 | if (!this._ws || this._ws.readyState === this.CLOSED) {
|
242 | this._connect();
|
243 | } else {
|
244 | this._disconnect(code, reason);
|
245 | this._connect();
|
246 | }
|
247 | }
|
248 |
|
249 | /**
|
250 | * Enqueue specified data to be transmitted to the server over the WebSocket connection
|
251 | */
|
252 | public send(data: Message) {
|
253 | if (this._ws && this._ws.readyState === this.OPEN) {
|
254 | this._debug('send', data);
|
255 | this._ws.send(data);
|
256 | } else {
|
257 | const {maxEnqueuedMessages = DEFAULT.maxEnqueuedMessages} = this._options;
|
258 | if (this._messageQueue.length < maxEnqueuedMessages) {
|
259 | this._debug('enqueue', data);
|
260 | this._messageQueue.push(data);
|
261 | }
|
262 | }
|
263 | }
|
264 |
|
265 | /**
|
266 | * Register an event handler of a specific event type
|
267 | */
|
268 | public addEventListener<T extends keyof Events.WebSocketEventListenerMap>(
|
269 | type: T,
|
270 | listener: Events.WebSocketEventListenerMap[T],
|
271 | ): void {
|
272 | if (this._listeners[type]) {
|
273 | // @ts-ignore
|
274 | this._listeners[type].push(listener);
|
275 | }
|
276 | }
|
277 |
|
278 | public dispatchEvent(event: Event) {
|
279 | const listeners = this._listeners[event.type as keyof Events.WebSocketEventListenerMap];
|
280 | if (listeners) {
|
281 | for (const listener of listeners) {
|
282 | this._callEventListener(event, listener);
|
283 | }
|
284 | }
|
285 | return true;
|
286 | }
|
287 |
|
288 | /**
|
289 | * Removes an event listener
|
290 | */
|
291 | public removeEventListener<T extends keyof Events.WebSocketEventListenerMap>(
|
292 | type: T,
|
293 | listener: Events.WebSocketEventListenerMap[T],
|
294 | ): void {
|
295 | if (this._listeners[type]) {
|
296 | // @ts-ignore
|
297 | this._listeners[type] = this._listeners[type].filter(l => l !== listener);
|
298 | }
|
299 | }
|
300 |
|
301 | private _debug(...args: any[]) {
|
302 | if (this._options.debug) {
|
303 | // not using spread because compiled version uses Symbols
|
304 | // tslint:disable-next-line
|
305 | console.log.apply(console, ['RWS>', ...args]);
|
306 | }
|
307 | }
|
308 |
|
309 | private _getNextDelay() {
|
310 | const {
|
311 | reconnectionDelayGrowFactor = DEFAULT.reconnectionDelayGrowFactor,
|
312 | minReconnectionDelay = DEFAULT.minReconnectionDelay,
|
313 | maxReconnectionDelay = DEFAULT.maxReconnectionDelay,
|
314 | } = this._options;
|
315 | let delay = 0;
|
316 | if (this._retryCount > 0) {
|
317 | delay =
|
318 | minReconnectionDelay * Math.pow(reconnectionDelayGrowFactor, this._retryCount - 1);
|
319 | if (delay > maxReconnectionDelay) {
|
320 | delay = maxReconnectionDelay;
|
321 | }
|
322 | }
|
323 | this._debug('next delay', delay);
|
324 | return delay;
|
325 | }
|
326 |
|
327 | private _wait(): Promise<void> {
|
328 | return new Promise(resolve => {
|
329 | setTimeout(resolve, this._getNextDelay());
|
330 | });
|
331 | }
|
332 |
|
333 | private _getNextUrl(urlProvider: UrlProvider): Promise<string> {
|
334 | if (typeof urlProvider === 'string') {
|
335 | return Promise.resolve(urlProvider);
|
336 | }
|
337 | if (typeof urlProvider === 'function') {
|
338 | const url = urlProvider();
|
339 | if (typeof url === 'string') {
|
340 | return Promise.resolve(url);
|
341 | }
|
342 | if (!!url.then) {
|
343 | return url;
|
344 | }
|
345 | }
|
346 | throw Error('Invalid URL');
|
347 | }
|
348 |
|
349 | private _connect() {
|
350 | if (this._connectLock || !this._shouldReconnect) {
|
351 | return;
|
352 | }
|
353 | this._connectLock = true;
|
354 |
|
355 | const {
|
356 | maxRetries = DEFAULT.maxRetries,
|
357 | connectionTimeout = DEFAULT.connectionTimeout,
|
358 | WebSocket = getGlobalWebSocket(),
|
359 | } = this._options;
|
360 |
|
361 | if (this._retryCount >= maxRetries) {
|
362 | this._debug('max retries reached', this._retryCount, '>=', maxRetries);
|
363 | return;
|
364 | }
|
365 |
|
366 | this._retryCount++;
|
367 |
|
368 | this._debug('connect', this._retryCount);
|
369 | this._removeListeners();
|
370 | if (!isWebSocket(WebSocket)) {
|
371 | throw Error('No valid WebSocket class provided');
|
372 | }
|
373 | this._wait()
|
374 | .then(() => this._getNextUrl(this._url))
|
375 | .then(url => {
|
376 |
|
377 | if (this._closeCalled) {
|
378 | return;
|
379 | }
|
380 | this._debug('connect', {url, protocols: this._protocols});
|
381 | this._ws = this._protocols
|
382 | ? new WebSocket(url, this._protocols)
|
383 | : new WebSocket(url);
|
384 | this._ws!.binaryType = this._binaryType;
|
385 | this._connectLock = false;
|
386 | this._addListeners();
|
387 |
|
388 | this._connectTimeout = setTimeout(() => this._handleTimeout(), connectionTimeout);
|
389 | });
|
390 | }
|
391 |
|
392 | private _handleTimeout() {
|
393 | this._debug('timeout event');
|
394 | this._handleError(new Events.ErrorEvent(Error('TIMEOUT'), this));
|
395 | }
|
396 |
|
397 | private _disconnect(code: number = 1000, reason?: string) {
|
398 | this._clearTimeouts();
|
399 | if (!this._ws) {
|
400 | return;
|
401 | }
|
402 | this._removeListeners();
|
403 | try {
|
404 | this._ws.close(code, reason);
|
405 | this._handleClose(new Events.CloseEvent(code, reason, this));
|
406 | } catch (error) {
|
407 | // ignore
|
408 | }
|
409 | }
|
410 |
|
411 | private _acceptOpen() {
|
412 | this._debug('accept open');
|
413 | this._retryCount = 0;
|
414 | }
|
415 |
|
416 | private _callEventListener<T extends keyof Events.WebSocketEventListenerMap>(
|
417 | event: Events.WebSocketEventMap[T],
|
418 | listener: Events.WebSocketEventListenerMap[T],
|
419 | ) {
|
420 | if ('handleEvent' in listener) {
|
421 | // @ts-ignore
|
422 | listener.handleEvent(event);
|
423 | } else {
|
424 | // @ts-ignore
|
425 | listener(event);
|
426 | }
|
427 | }
|
428 |
|
429 | private _handleOpen = (event: Event) => {
|
430 | this._debug('open event');
|
431 | const {minUptime = DEFAULT.minUptime} = this._options;
|
432 |
|
433 | clearTimeout(this._connectTimeout);
|
434 | this._uptimeTimeout = setTimeout(() => this._acceptOpen(), minUptime);
|
435 |
|
436 | this._ws!.binaryType = this._binaryType;
|
437 |
|
438 |
|
439 | this._messageQueue.forEach(message => this._ws!.send(message));
|
440 | this._messageQueue = [];
|
441 |
|
442 | if (this.onopen) {
|
443 | this.onopen(event);
|
444 | }
|
445 | this._listeners.open.forEach(listener => this._callEventListener(event, listener));
|
446 | };
|
447 |
|
448 | private _handleMessage = (event: MessageEvent) => {
|
449 | this._debug('message event');
|
450 |
|
451 | if (this.onmessage) {
|
452 | this.onmessage(event);
|
453 | }
|
454 | this._listeners.message.forEach(listener => this._callEventListener(event, listener));
|
455 | };
|
456 |
|
457 | private _handleError = (event: Events.ErrorEvent) => {
|
458 | this._debug('error event', event.message);
|
459 | this._disconnect(undefined, event.message === 'TIMEOUT' ? 'timeout' : undefined);
|
460 |
|
461 | if (this.onerror) {
|
462 | this.onerror(event);
|
463 | }
|
464 | this._debug('exec error listeners');
|
465 | this._listeners.error.forEach(listener => this._callEventListener(event, listener));
|
466 |
|
467 | this._connect();
|
468 | };
|
469 |
|
470 | private _handleClose = (event: Events.CloseEvent) => {
|
471 | this._debug('close event');
|
472 | this._clearTimeouts();
|
473 |
|
474 | if (this._shouldReconnect) {
|
475 | this._connect();
|
476 | }
|
477 |
|
478 | if (this.onclose) {
|
479 | this.onclose(event);
|
480 | }
|
481 | this._listeners.close.forEach(listener => this._callEventListener(event, listener));
|
482 | };
|
483 |
|
484 | private _removeListeners() {
|
485 | if (!this._ws) {
|
486 | return;
|
487 | }
|
488 | this._debug('removeListeners');
|
489 | this._ws.removeEventListener('open', this._handleOpen);
|
490 | this._ws.removeEventListener('close', this._handleClose);
|
491 | this._ws.removeEventListener('message', this._handleMessage);
|
492 |
|
493 | this._ws.removeEventListener('error', this._handleError);
|
494 | }
|
495 |
|
496 | private _addListeners() {
|
497 | if (!this._ws) {
|
498 | return;
|
499 | }
|
500 | this._debug('addListeners');
|
501 | this._ws.addEventListener('open', this._handleOpen);
|
502 | this._ws.addEventListener('close', this._handleClose);
|
503 | this._ws.addEventListener('message', this._handleMessage);
|
504 |
|
505 | this._ws.addEventListener('error', this._handleError);
|
506 | }
|
507 |
|
508 | private _clearTimeouts() {
|
509 | clearTimeout(this._connectTimeout);
|
510 | clearTimeout(this._uptimeTimeout);
|
511 | }
|
512 | }
|