UNPKG

expo

Version:
319 lines (265 loc) • 9.21 kB
import type { DevToolsPluginClientOptions } from './devtools.types'; export interface Options { /** * Reconnect interval in milliseconds. * @default 1500 */ retriesInterval?: number; /** * The maximum number of retries. * @default 200 */ maxRetries?: number; /** * The timeout in milliseconds for the WebSocket connecting. */ connectTimeout?: number; /** * The error handler. * @default throwing an error */ onError?: (error: Error) => void; /** * The callback to be called when the WebSocket is reconnected. * @default no-op */ onReconnect?: (reason: string) => void; /** * The [`binaryType`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/binaryType). */ binaryType?: DevToolsPluginClientOptions['websocketBinaryType']; } interface InternalEventListeners { message?: Set<(event: WebSocketMessageEvent) => void>; open?: Set<() => void>; error?: Set<(event: WebSocketErrorEvent) => void>; close?: Set<(event: WebSocketCloseEvent) => void>; [eventName: string]: undefined | Set<(event: any) => void>; } export class WebSocketWithReconnect implements WebSocket { private readonly retriesInterval: number; private readonly maxRetries: number; private readonly connectTimeout: number; private readonly onError: (error: Error) => void; private readonly onReconnect: (reason: string) => void; private ws: WebSocket | null = null; private retries = 0; private connectTimeoutHandle: ReturnType<typeof setTimeout> | null = null; private isClosed = false; private sendQueue: (string | ArrayBufferView | Blob | ArrayBufferLike)[] = []; private lastCloseEvent: { code?: number; reason?: string; message?: string } | null = null; private eventListeners: InternalEventListeners; private readonly wsBinaryType?: Options['binaryType']; constructor( public readonly url: string, options?: Options ) { this.retriesInterval = options?.retriesInterval ?? 1500; this.maxRetries = options?.maxRetries ?? 200; this.connectTimeout = options?.connectTimeout ?? 5000; this.onError = options?.onError ?? ((error) => { throw error; }); this.onReconnect = options?.onReconnect ?? (() => {}); this.wsBinaryType = options?.binaryType; this.eventListeners = Object.create(null); this.connect(); } public close(code?: number, reason?: string) { this.clearConnectTimeoutIfNeeded(); this.emitEvent( 'close', (this.lastCloseEvent ?? { code: code ?? 1000, reason: reason ?? 'Explicit closing', message: 'Explicit closing', }) as WebSocketCloseEvent ); this.lastCloseEvent = null; this.isClosed = true; this.eventListeners = Object.create(null); this.sendQueue = []; if (this.ws != null) { const ws = this.ws; this.ws = null; this.wsClose(ws); } } public addEventListener(event: 'message', listener: (event: WebSocketMessageEvent) => void): void; public addEventListener(event: 'open', listener: () => void): void; public addEventListener(event: 'error', listener: (event: WebSocketErrorEvent) => void): void; public addEventListener(event: 'close', listener: (event: WebSocketCloseEvent) => void): void; public addEventListener(event: string, listener: (event: any) => void) { const listeners = this.eventListeners[event] || (this.eventListeners[event] = new Set()); listeners.add(listener); } public removeEventListener(event: string, listener: (event: any) => void) { this.eventListeners[event]?.delete(listener); } //#region Internals private connect() { if (this.ws != null) { return; } this.connectTimeoutHandle = setTimeout(this.handleConnectTimeout, this.connectTimeout); this.ws = new WebSocket(this.url.toString()); if (this.wsBinaryType != null) { this.ws.binaryType = this.wsBinaryType; } this.ws.addEventListener('message', this.handleMessage); this.ws.addEventListener('open', this.handleOpen); // @ts-ignore TypeScript expects (e: Event) => any, but we want (e: WebSocketErrorEvent) => any this.ws.addEventListener('error', this.handleError); this.ws.addEventListener('close', this.handleClose); } public send(data: string | ArrayBufferView | Blob | ArrayBufferLike): void { if (this.isClosed) { this.onError(new Error('Unable to send data: WebSocket is closed')); return; } if (this.retries >= this.maxRetries) { this.onError( new Error(`Unable to send data: Exceeded max retries - retries[${this.retries}]`) ); return; } const ws = this.ws; if (ws != null && ws.readyState === WebSocket.OPEN) { ws.send(data); } else { this.sendQueue.push(data); } } private emitEvent(event: 'message', payload: WebSocketMessageEvent): void; private emitEvent(event: 'open', payload?: void): void; private emitEvent(event: 'error', payload: WebSocketErrorEvent): void; private emitEvent(event: 'close', payload: WebSocketCloseEvent): void; private emitEvent(event: string, payload: any) { const listeners = this.eventListeners[event]; if (listeners) { for (const listener of listeners) { listener(payload); } } } private handleOpen = () => { this.clearConnectTimeoutIfNeeded(); this.lastCloseEvent = null; this.emitEvent('open'); const sendQueue = this.sendQueue; this.sendQueue = []; for (const data of sendQueue) { this.send(data); } }; private handleMessage = (event: WebSocketMessageEvent) => { this.emitEvent('message', event); }; private handleError = (event: WebSocketErrorEvent) => { this.clearConnectTimeoutIfNeeded(); this.emitEvent('error', event); this.reconnectIfNeeded(`WebSocket error - ${event.message}`); }; private handleClose = (event: WebSocketCloseEvent) => { this.clearConnectTimeoutIfNeeded(); this.lastCloseEvent = { code: event.code, reason: event.reason, message: event.message, }; this.reconnectIfNeeded(`WebSocket closed - code[${event.code}] reason[${event.reason}]`); }; private handleConnectTimeout = () => { this.reconnectIfNeeded('Timeout from connecting to the WebSocket'); }; private clearConnectTimeoutIfNeeded() { if (this.connectTimeoutHandle != null) { clearTimeout(this.connectTimeoutHandle); this.connectTimeoutHandle = null; } } private reconnectIfNeeded(reason: string) { if (this.ws != null) { this.wsClose(this.ws); this.ws = null; } if (this.isClosed) { return; } if (this.retries >= this.maxRetries) { this.onError(new Error('Exceeded max retries')); this.close(); return; } setTimeout(() => { this.retries += 1; this.connect(); this.onReconnect(reason); }, this.retriesInterval); } private wsClose(ws: WebSocket) { try { ws.removeEventListener('message', this.handleMessage); ws.removeEventListener('open', this.handleOpen); ws.removeEventListener('close', this.handleClose); // WebSocket throws errors if we don't handle the error event. // Specifically when closing a ws in CONNECTING readyState, // WebSocket will have `WebSocket was closed before the connection was established` error. // We won't like to have the exception, so set a noop error handler. ws.onerror = () => {}; ws.close(); } catch {} } public get readyState() { // Only return closed if the WebSocket is explicitly closed or exceeds max retries. if (this.isClosed) { return WebSocket.CLOSED; } const readyState = this.ws?.readyState; if (readyState === WebSocket.CLOSED) { return WebSocket.CONNECTING; } return readyState ?? WebSocket.CONNECTING; } //#endregion //#region WebSocket API proxy public readonly CONNECTING = 0; public readonly OPEN = 1; public readonly CLOSING = 2; public readonly CLOSED = 3; public get binaryType() { return this.ws?.binaryType ?? 'blob'; } public get bufferedAmount() { return this.ws?.bufferedAmount ?? 0; } public get extensions() { return this.ws?.extensions ?? ''; } public get protocol() { return this.ws?.protocol ?? ''; } public ping(): void { return this.ws?.ping(); } public dispatchEvent(event: Event) { return this.ws?.dispatchEvent(event) ?? false; } //#endregion //#regions Unsupported legacy properties public set onclose(_value: ((e: WebSocketCloseEvent) => any) | null) { throw new Error('Unsupported legacy property, use addEventListener instead'); } public set onerror(_value: ((e: Event) => any) | null) { throw new Error('Unsupported legacy property, use addEventListener instead'); } public set onmessage(_value: ((e: WebSocketMessageEvent) => any) | null) { throw new Error('Unsupported legacy property, use addEventListener instead'); } public set onopen(_value: (() => any) | null) { throw new Error('Unsupported legacy property, use addEventListener instead'); } //#endregion }