UNPKG

15.6 kBPlain TextView Raw
1/*!
2 * Reconnecting WebSocket
3 * by Pedro Ladaria <pedro.ladaria@gmail.com>
4 * https://github.com/pladaria/reconnecting-websocket
5 * License MIT
6 */
7
8import * as Events from './events';
9
10const getGlobalWebSocket = (): WebSocket | undefined => {
11 if (typeof WebSocket !== 'undefined') {
12 // @ts-ignore
13 return WebSocket;
14 }
15};
16
17/**
18 * Returns true if given argument looks like a WebSocket class
19 */
20const isWebSocket = (w: any) => typeof w !== 'undefined' && !!w && w.CLOSING === 2;
21
22export type Event = Events.Event;
23export type ErrorEvent = Events.ErrorEvent;
24export type CloseEvent = Events.CloseEvent;
25
26export 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
39const 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
51export type UrlProvider = string | (() => string) | (() => Promise<string>);
52
53export type Message = string | ArrayBuffer | Blob | ArrayBufferView;
54
55export 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
62export 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; // not byte size
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 // close could be called before creating the ws
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 // send enqueued messages (messages sent before websocket open event)
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 // @ts-ignore
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 // @ts-ignore
505 this._ws.addEventListener('error', this._handleError);
506 }
507
508 private _clearTimeouts() {
509 clearTimeout(this._connectTimeout);
510 clearTimeout(this._uptimeTimeout);
511 }
512}