expo
Version:
319 lines (265 loc) • 9.21 kB
text/typescript
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
}