import { emitter } from '@amatiasq/emitter'; const DEFAULT_RECONNECTION_DELAY = 100; const MAX_RECONNECT_ATTEMPTS = 14; type Message = string | ArrayBuffer | Blob | ArrayBufferView; export interface SocketReconnectionEvent extends Event { disconnectedTime: Date; } export interface ResilientSocketOptions { reconnectionDelay?: number; maxReconnectAttempts?: number; } export class ResilientSocket { RECONNECTION_DELAY; MAX_RECONNECT_ATTEMPTS; private ws: WebSocket | null; private reconnectionDelay = DEFAULT_RECONNECTION_DELAY; private reconnectAttempts = 0; private disconnectedAt = new Date(); private isReconnecting = false; private isFirstConnection = true; private messageQueue: Message[] = []; private readonly emitOpen = emitter(); readonly onOpen = this.emitOpen.subscribe; private readonly emitError = emitter(); readonly onError = this.emitError.subscribe; private readonly emitClose = emitter(); readonly onClose = this.emitClose.subscribe; private readonly emitMessage = emitter(); readonly onMessage = this.emitMessage.subscribe; private readonly emitReconnect = emitter(); readonly onReconnect = this.emitReconnect.subscribe; get isConnected() { return !this.isFirstConnection && !this.isReconnecting; } get websocket() { return this.ws; } constructor( public readonly uri: string, { reconnectionDelay = DEFAULT_RECONNECTION_DELAY, maxReconnectAttempts = MAX_RECONNECT_ATTEMPTS, }: ResilientSocketOptions = {}, ) { this.connectionOpen = this.connectionOpen.bind(this); this.processMessage = this.processMessage.bind(this); this.connectionLost = this.connectionLost.bind(this); // Before `init()`, not after: a socket that dies before its first `open` // schedules a reconnection straight from the constructor's values, and // `resetReconnectionCounters` (which is what applies the option) only runs // once something has opened. this.RECONNECTION_DELAY = reconnectionDelay; this.MAX_RECONNECT_ATTEMPTS = maxReconnectAttempts; this.reconnectionDelay = reconnectionDelay; this.ws = this.init(); } send(data: Message) { if (this.isConnected) this.ws!.send(data); else this.messageQueue.push(data); } private init() { const socket = new WebSocket(this.uri); socket.addEventListener('open', this.connectionOpen); socket.addEventListener('message', this.processMessage); socket.addEventListener('error', this.connectionLost); socket.addEventListener('close', this.connectionLost); return socket; } private unbind() { const socket = this.ws; if (!socket) return; socket.removeEventListener('open', this.connectionOpen); socket.removeEventListener('message', this.processMessage); socket.removeEventListener('error', this.connectionLost); socket.removeEventListener('close', this.connectionLost); this.ws = null; return socket; } private processMessage(event: MessageEvent) { this.emitMessage(event); } private connectionOpen(event: Event) { this.resetReconnectionCounters(); if (this.isFirstConnection) { this.isFirstConnection = false; this.emitOpen(event); } else { this.emitReconnect( Object.assign(event, { disconnectedTime: this.disconnectedAt }), ); } this.processMessageQueue(); } private connectionLost(event: Event) { // Guard on "is this socket still ours", not on `isReconnecting`. A dying // socket fires `error` *and* `close`, and both land here — that is what the // guard is for. Keying it on `isReconnecting` also swallowed the second // failure of a reconnect cycle, so a failed retry never scheduled another // one: the backoff and `MAX_RECONNECT_ATTEMPTS` were dead code, and one // dropped connection left the socket permanently closed. if (!this.ws) { return; } this.unbind(); if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) { this.reconnectionFailed(event); return; } if (!this.isReconnecting) { this.isReconnecting = true; this.disconnectedAt = new Date(); } this.scheduleReconnection(); } private resetReconnectionCounters() { this.reconnectionDelay = this.RECONNECTION_DELAY; this.reconnectAttempts = 0; this.isReconnecting = false; } private processMessageQueue() { // `of`, not `in`: `for...in` walks an array's *indices*, so this used to // flush the strings "0", "1", "2"… instead of the queued messages. Every // message sent before the socket opened was silently replaced by its // position in the queue. for (const message of this.messageQueue) { this.ws!.send(message); } this.messageQueue.length = 0; } private reconnectionFailed(event: Event) { this.emitError(event); console.error( `Websocket aborted after ${this.reconnectAttempts} attempts`, ); } private scheduleReconnection() { const message = `Socket closed. Waiting ${this.reconnectionDelay / 1000}s`; const reconnecting = 'Reconnecting...'; const singleLine = this.reconnectAttempts < 1000; console.debug(`${message} ${singleLine ? reconnecting : ''}`); setTimeout(() => { if (!singleLine) { console.debug(reconnecting); } this.reconnectionDelay *= 2; this.reconnectAttempts++; this.ws = this.init(); }, this.reconnectionDelay); } close() { const socket = this.unbind(); if (socket) { socket.onclose = this.emitClose; socket.close(); } } }