216 lines
7.0 KiB
JavaScript
216 lines
7.0 KiB
JavaScript
export class WebSocketWithReconnect {
|
|
url;
|
|
retriesInterval;
|
|
maxRetries;
|
|
connectTimeout;
|
|
onError;
|
|
onReconnect;
|
|
ws = null;
|
|
retries = 0;
|
|
connectTimeoutHandle = null;
|
|
isClosed = false;
|
|
sendQueue = [];
|
|
lastCloseEvent = null;
|
|
eventListeners;
|
|
wsBinaryType;
|
|
constructor(url, options) {
|
|
this.url = url;
|
|
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();
|
|
}
|
|
close(code, reason) {
|
|
this.clearConnectTimeoutIfNeeded();
|
|
this.emitEvent('close', (this.lastCloseEvent ?? {
|
|
code: code ?? 1000,
|
|
reason: reason ?? 'Explicit closing',
|
|
message: 'Explicit closing',
|
|
}));
|
|
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);
|
|
}
|
|
}
|
|
addEventListener(event, listener) {
|
|
const listeners = this.eventListeners[event] || (this.eventListeners[event] = new Set());
|
|
listeners.add(listener);
|
|
}
|
|
removeEventListener(event, listener) {
|
|
this.eventListeners[event]?.delete(listener);
|
|
}
|
|
//#region Internals
|
|
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);
|
|
}
|
|
send(data) {
|
|
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);
|
|
}
|
|
}
|
|
emitEvent(event, payload) {
|
|
const listeners = this.eventListeners[event];
|
|
if (listeners) {
|
|
for (const listener of listeners) {
|
|
listener(payload);
|
|
}
|
|
}
|
|
}
|
|
handleOpen = () => {
|
|
this.clearConnectTimeoutIfNeeded();
|
|
this.lastCloseEvent = null;
|
|
this.emitEvent('open');
|
|
const sendQueue = this.sendQueue;
|
|
this.sendQueue = [];
|
|
for (const data of sendQueue) {
|
|
this.send(data);
|
|
}
|
|
};
|
|
handleMessage = (event) => {
|
|
this.emitEvent('message', event);
|
|
};
|
|
handleError = (event) => {
|
|
this.clearConnectTimeoutIfNeeded();
|
|
this.emitEvent('error', event);
|
|
this.reconnectIfNeeded(`WebSocket error - ${event.message}`);
|
|
};
|
|
handleClose = (event) => {
|
|
this.clearConnectTimeoutIfNeeded();
|
|
this.lastCloseEvent = {
|
|
code: event.code,
|
|
reason: event.reason,
|
|
message: event.message,
|
|
};
|
|
this.reconnectIfNeeded(`WebSocket closed - code[${event.code}] reason[${event.reason}]`);
|
|
};
|
|
handleConnectTimeout = () => {
|
|
this.reconnectIfNeeded('Timeout from connecting to the WebSocket');
|
|
};
|
|
clearConnectTimeoutIfNeeded() {
|
|
if (this.connectTimeoutHandle != null) {
|
|
clearTimeout(this.connectTimeoutHandle);
|
|
this.connectTimeoutHandle = null;
|
|
}
|
|
}
|
|
reconnectIfNeeded(reason) {
|
|
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);
|
|
}
|
|
wsClose(ws) {
|
|
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 { }
|
|
}
|
|
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
|
|
CONNECTING = 0;
|
|
OPEN = 1;
|
|
CLOSING = 2;
|
|
CLOSED = 3;
|
|
get binaryType() {
|
|
return this.ws?.binaryType ?? 'blob';
|
|
}
|
|
get bufferedAmount() {
|
|
return this.ws?.bufferedAmount ?? 0;
|
|
}
|
|
get extensions() {
|
|
return this.ws?.extensions ?? '';
|
|
}
|
|
get protocol() {
|
|
return this.ws?.protocol ?? '';
|
|
}
|
|
ping() {
|
|
// @ts-ignore react-native WebSocket has the ping method
|
|
return this.ws?.ping();
|
|
}
|
|
dispatchEvent(event) {
|
|
return this.ws?.dispatchEvent(event) ?? false;
|
|
}
|
|
//#endregion
|
|
//#regions Unsupported legacy properties
|
|
set onclose(_value) {
|
|
throw new Error('Unsupported legacy property, use addEventListener instead');
|
|
}
|
|
set onerror(_value) {
|
|
throw new Error('Unsupported legacy property, use addEventListener instead');
|
|
}
|
|
set onmessage(_value) {
|
|
throw new Error('Unsupported legacy property, use addEventListener instead');
|
|
}
|
|
set onopen(_value) {
|
|
throw new Error('Unsupported legacy property, use addEventListener instead');
|
|
}
|
|
}
|
|
//# sourceMappingURL=WebSocketWithReconnect.js.map
|