• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nktkas / hyperliquid / 15120940791

19 May 2025 06:54PM UTC coverage: 98.982% (+0.001%) from 98.981%
15120940791

push

github

nktkas
test: relaxation of schemaCoverage

371 of 384 branches covered (96.61%)

Branch coverage included in aggregate %.

2837 of 2857 relevant lines covered (99.3%)

226.08 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

100.0
/src/transports/websocket/_reconnecting_websocket.ts
1
// deno-lint-ignore-file no-explicit-any
2
import { delay } from "@std/async/delay";
112✔
3
import { type MaybePromise, TransportError } from "../../base.ts";
112✔
4

5
/** Configuration options for the `ReconnectingWebSocket`. */
6
export interface ReconnectingWebSocketOptions {
7
    /**
8
     * Maximum number of reconnection attempts.
9
     * @defaultValue `3`
10
     */
11
    maxRetries?: number;
12

13
    /**
14
     * Maximum time in ms to wait for a connection to open.
15
     * Set to `null` to disable.
16
     * @defaultValue `10_000`
17
     */
18
    connectionTimeout?: number | null;
19

20
    /**
21
     * Delay between reconnection attempts in ms.
22
     * May be a number or a function that returns a number.
23
     * @param attempt - The current attempt number.
24
     * @defaultValue `(attempt) => Math.min(~~(1 << attempt) * 150, 10_000)` - Exponential backoff (max 10s)
25
     */
26
    connectionDelay?: number | ((attempt: number, signal: AbortSignal) => MaybePromise<number>);
27

28
    /**
29
     * Custom logic to determine if reconnection is required.
30
     * @param event - The close event that occurred during the connection.
31
     * @returns A boolean indicating if reconnection should be attempted.
32
     * @defaultValue `() => true` - Always reconnect
33
     */
34
    shouldReconnect?: (event: CloseEvent, signal: AbortSignal) => MaybePromise<boolean>;
35

36
    /**
37
     * Message buffering strategy between reconnection attempts.
38
     * @defaultValue `new FIFOMessageBuffer()`
39
     */
40
    messageBuffer?: MessageBufferStrategy;
41
}
42

43
/** Message buffer strategy interface. */
44
export interface MessageBufferStrategy {
45
    /** Array of buffered messages. */
46
    messages: (string | ArrayBufferLike | Blob | ArrayBufferView)[];
47
    /**
48
     * Add a message to the buffer.
49
     * @param data - The message to buffer.
50
     */
51
    push(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
52

53
    /**
54
     * Get and remove the next message from the buffer.
55
     * @returns The next message or `undefined` if no more messages are available.
56
     */
57
    shift(): (string | ArrayBufferLike | Blob | ArrayBufferView) | undefined;
58

59
    /** Clear all buffered messages. */
60
    clear(): void;
61
}
62

63
/** Simple FIFO (First In, First Out) buffer implementation. */
64
class FIFOMessageBuffer implements MessageBufferStrategy {
112✔
65
    messages: (string | ArrayBufferLike | Blob | ArrayBufferView)[] = [];
112✔
66
    constructor() {}
112✔
67
    push(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
112✔
68
        this.messages.push(data);
143✔
69
    }
143✔
70
    shift(): (string | ArrayBufferLike | Blob | ArrayBufferView) | undefined {
112✔
71
        return this.messages.shift();
201✔
72
    }
201✔
73
    clear(): void {
112✔
74
        this.messages = [];
233✔
75
    }
233✔
76
}
112✔
77

78
/** Error thrown when reconnection problems occur. */
79
export class ReconnectingWebSocketError extends TransportError {
112✔
80
    constructor(
112✔
81
        public code:
345✔
82
            | "RECONNECTION_LIMIT_REACHED"
83
            | "RECONNECTION_STOPPED_BY_USER"
84
            | "USER_INITIATED_CLOSE"
85
            | "UNKNOWN_ERROR",
112✔
86
        public originalError?: unknown,
345✔
87
    ) {
112✔
88
        super(`Error when reconnecting WebSocket: ${code}`);
233✔
89
        this.name = "ReconnectingWebSocketError";
233✔
90
    }
233✔
91
}
112✔
92

93
/**
94
 * A WebSocket that automatically reconnects when disconnected.
95
 * Fully compatible with standard WebSocket API.
96
 */
97
export class ReconnectingWebSocket implements WebSocket {
112✔
98
    private _socket: WebSocket;
112✔
99
    private _protocols?: string | string[];
219✔
100
    private _listeners: {
219✔
101
        type: string;
102
        listener: EventListenerOrEventListenerObject;
103
        options?: boolean | AddEventListenerOptions;
104
        listenerProxy: EventListenerOrEventListenerObject;
105
    }[] = [];
219✔
106
    private _attempt = 0;
219✔
107
    public reconnectOptions: Required<ReconnectingWebSocketOptions>;
219✔
108
    public readonly reconnectAbortController: AbortController = new AbortController();
219✔
109

110
    constructor(url: string | URL, protocols?: string | string[], options?: ReconnectingWebSocketOptions) {
219✔
111
        this.reconnectOptions = {
326✔
112
            maxRetries: options?.maxRetries ?? 3,
326✔
113
            connectionTimeout: options?.connectionTimeout === undefined ? 10_000 : options.connectionTimeout,
326✔
114
            connectionDelay: options?.connectionDelay ?? ((n) => Math.min(~~(1 << n) * 150, 10_000)),
326✔
115
            shouldReconnect: options?.shouldReconnect ?? (() => true),
326✔
116
            messageBuffer: options?.messageBuffer ?? new FIFOMessageBuffer(),
326✔
117
        };
326✔
118

119
        this._socket = this._createSocket(url, protocols);
326✔
120
        this._protocols = protocols;
326✔
121
        this._setupEventListeners();
326✔
122
    }
326✔
123

124
    private _createSocket(url: string | URL, protocols?: string | string[]): WebSocket {
219✔
125
        const socket = new WebSocket(url, protocols);
354✔
126
        if (this.reconnectOptions.connectionTimeout === null) return socket;
354✔
127

128
        const timeoutId = setTimeout(() => {
487✔
129
            socket.removeEventListener("open", openHandler);
491✔
130
            socket.removeEventListener("close", closeHandler);
491✔
131
            socket.close(3008, "Timeout"); // https://www.iana.org/assignments/websocket/websocket.xml#close-code-number
491✔
132
        }, this.reconnectOptions.connectionTimeout);
487✔
133

134
        const openHandler = () => {
487✔
135
            socket.removeEventListener("close", closeHandler);
545✔
136
            clearTimeout(timeoutId);
545✔
137
        };
487✔
138
        const closeHandler = () => {
487✔
139
            socket.removeEventListener("open", openHandler);
558✔
140
            clearTimeout(timeoutId);
558✔
141
        };
487✔
142

143
        socket.addEventListener("open", openHandler, { once: true });
1,461✔
144
        socket.addEventListener("close", closeHandler, { once: true });
1,461✔
145

146
        return socket;
487✔
147
    }
354✔
148

149
    /** Initializes the internal event listeners for the socket. */
150
    private _setupEventListeners() {
219✔
151
        this._socket.addEventListener("open", this._open, { once: true });
1,062✔
152
        this._socket.addEventListener("close", this._close, { once: true });
1,062✔
153
    }
354✔
154
    private _open = () => {
219✔
155
        // Reset the attempt counter
156
        this._attempt = 0;
278✔
157

158
        // Send all buffered messages
159
        let message: (string | ArrayBufferLike | Blob | ArrayBufferView) | undefined;
278✔
160
        while ((message = this.reconnectOptions.messageBuffer.shift()) !== undefined) {
278✔
161
            this._socket.send(message);
308✔
162
        }
308✔
163
    };
219✔
164
    private _close = async (event: CloseEvent) => {
219✔
165
        try {
354✔
166
            // If the event was triggered but the socket is not closing, ignore it
167
            if (
354✔
168
                this._socket.readyState !== ReconnectingWebSocket.CLOSING &&
354✔
169
                this._socket.readyState !== ReconnectingWebSocket.CLOSED
354✔
170
            ) return;
354✔
171

172
            // If the instance is terminated, do not attempt to reconnect
173
            if (this.reconnectAbortController.signal.aborted) return;
354✔
174

175
            // Check if reconnection should be attempted
176
            if (++this._attempt > this.reconnectOptions.maxRetries) {
433✔
177
                this._cleanup("RECONNECTION_LIMIT_REACHED");
442✔
178
                return;
442✔
179
            }
442✔
180

181
            const userDecision = await this.reconnectOptions.shouldReconnect(
466✔
182
                event,
466✔
183
                this.reconnectAbortController.signal,
466✔
184
            );
185
            if (this.reconnectAbortController.signal.aborted) return;
433✔
186
            if (!userDecision) {
433✔
187
                this._cleanup("RECONNECTION_STOPPED_BY_USER");
434✔
188
                return;
434✔
189
            }
434✔
190

191
            // Delay before reconnecting
192
            const reconnectDelay = typeof this.reconnectOptions.connectionDelay === "number"
354✔
193
                ? this.reconnectOptions.connectionDelay
354✔
194
                : await this.reconnectOptions.connectionDelay(this._attempt, this.reconnectAbortController.signal);
354✔
195
            if (this.reconnectAbortController.signal.aborted) return;
433✔
196
            await delay(reconnectDelay, { signal: this.reconnectAbortController.signal });
1,386✔
197

198
            // Create a new WebSocket instance
199
            const { onclose, onerror, onmessage, onopen } = this._socket;
461✔
200
            this._socket = this._createSocket(this._socket.url, this._protocols);
461✔
201

202
            // Reconnect all listeners
203
            this._setupEventListeners();
461✔
204

205
            this._listeners.forEach(({ type, listenerProxy, options }) => {
461✔
206
                this._socket.addEventListener(type, listenerProxy, options);
484✔
207
            });
461✔
208

209
            this._socket.onclose = onclose;
461✔
210
            this._socket.onerror = onerror;
461✔
211
            this._socket.onmessage = onmessage;
461✔
212
            this._socket.onopen = onopen;
461✔
213
        } catch (error) {
433✔
214
            this._cleanup("UNKNOWN_ERROR", error);
435✔
215
        }
435✔
216
    };
219✔
217

218
    /** Clean up internal resources. */
219
    private _cleanup(
219✔
220
        code:
219✔
221
            | "RECONNECTION_LIMIT_REACHED"
222
            | "RECONNECTION_STOPPED_BY_USER"
223
            | "USER_INITIATED_CLOSE"
224
            | "UNKNOWN_ERROR",
219✔
225
        error?: unknown,
219✔
226
    ) {
219✔
227
        this.reconnectAbortController.abort(new ReconnectingWebSocketError(code, error));
340✔
228
        this.reconnectOptions.messageBuffer.clear();
340✔
229
        this._listeners = [];
340✔
230
        this._socket.close();
340✔
231
    }
340✔
232

233
    // WebSocket property implementations
234
    get url(): string {
219✔
235
        return this._socket.url;
223✔
236
    }
223✔
237
    get readyState(): number {
219✔
238
        return this._socket.readyState;
373✔
239
    }
373✔
240
    get bufferedAmount(): number {
219✔
241
        return this._socket.bufferedAmount;
221✔
242
    }
221✔
243
    get extensions(): string {
219✔
244
        return this._socket.extensions;
221✔
245
    }
221✔
246
    get protocol(): string {
219✔
247
        return this._socket.protocol;
223✔
248
    }
223✔
249
    get binaryType(): BinaryType {
219✔
250
        return this._socket.binaryType;
223✔
251
    }
223✔
252
    set binaryType(value: BinaryType) {
219✔
253
        this._socket.binaryType = value;
220✔
254
    }
220✔
255

256
    readonly CONNECTING = 0;
219✔
257
    readonly OPEN = 1;
219✔
258
    readonly CLOSING = 2;
219✔
259
    readonly CLOSED = 3;
112✔
260

261
    static readonly CONNECTING = 0;
112✔
262
    static readonly OPEN = 1;
224✔
263
    static readonly CLOSING = 2;
224✔
264
    static readonly CLOSED = 3;
112✔
265

266
    get onclose(): ((this: WebSocket, ev: CloseEvent) => any) | null {
112✔
267
        return this._socket.onclose;
115✔
268
    }
115✔
269
    set onclose(value: ((this: WebSocket, ev: CloseEvent) => any) | null) {
112✔
270
        this._socket.onclose = value;
114✔
271
    }
114✔
272

273
    get onerror(): ((this: WebSocket, ev: Event) => any) | null {
112✔
274
        return this._socket.onerror;
115✔
275
    }
115✔
276
    set onerror(value: ((this: WebSocket, ev: Event) => any) | null) {
112✔
277
        this._socket.onerror = value;
114✔
278
    }
114✔
279

280
    get onmessage(): ((this: WebSocket, ev: MessageEvent<any>) => any) | null {
112✔
281
        return this._socket.onmessage;
115✔
282
    }
115✔
283
    set onmessage(value: ((this: WebSocket, ev: MessageEvent<any>) => any) | null) {
112✔
284
        this._socket.onmessage = value;
114✔
285
    }
114✔
286

287
    get onopen(): ((this: WebSocket, ev: Event) => any) | null {
112✔
288
        return this._socket.onopen;
115✔
289
    }
115✔
290
    set onopen(value: ((this: WebSocket, ev: Event) => any) | null) {
112✔
291
        this._socket.onopen = value;
114✔
292
    }
114✔
293

294
    /**
295
     * @param permanently - If `true`, the connection will be permanently closed. Default is `true`.
296
     */
297
    close(code?: number, reason?: string, permanently: boolean = true): void {
112✔
298
        this._socket.close(code, reason);
227✔
299
        if (permanently) this._cleanup("USER_INITIATED_CLOSE");
227✔
300
    }
227✔
301

302
    /**
303
     * @note If the connection is not open, the data will be buffered and sent when the connection is established.
304
     */
305
    send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
112✔
306
        if (this._socket.readyState !== ReconnectingWebSocket.OPEN && !this.reconnectAbortController.signal.aborted) {
222✔
307
            this.reconnectOptions.messageBuffer.push(data);
362✔
308
        } else {
331✔
309
            this._socket.send(data);
410✔
310
        }
410✔
311
    }
222✔
312

313
    addEventListener<K extends keyof WebSocketEventMap>(
314
        type: K,
315
        listener:
316
            | ((this: ReconnectingWebSocket, ev: WebSocketEventMap[K]) => any)
317
            | { handleEvent: (event: WebSocketEventMap[K]) => any },
318
        options?: boolean | AddEventListenerOptions,
319
    ): void;
320
    addEventListener(
112✔
321
        type: string,
112✔
322
        listener: EventListenerOrEventListenerObject,
112✔
323
        options?: boolean | AddEventListenerOptions,
112✔
324
    ): void {
112✔
325
        // Wrap the listener to handle reconnection
326
        let listenerProxy: EventListenerOrEventListenerObject;
455✔
327
        if (this.reconnectAbortController.signal.aborted) {
455✔
328
            // If the instance is terminated, use the original listener
329
            listenerProxy = listener;
457✔
330
        } else {
455✔
331
            // Check if the listener is already registered
332
            const index = this._listeners.findIndex((e) => listenersMatch(e, { type, listener, options }));
6,044✔
333
            if (index !== -1) {
796✔
334
                // Use the existing listener proxy
335
                listenerProxy = this._listeners[index].listenerProxy;
798✔
336
            } else {
796✔
337
                // Wrap the original listener to follow the once option when reconnecting
338
                listenerProxy = (event: Event) => {
1,135✔
339
                    try {
1,582✔
340
                        if (typeof listener === "function") {
1,582✔
341
                            listener.call(this, event);
2,028✔
342
                        } else {
1,582✔
343
                            listener.handleEvent(event);
1,583✔
344
                        }
1,583✔
345
                    } finally {
1,582✔
346
                        // If the listener is marked as once, remove it after the first invocation
347
                        if (typeof options === "object" && options.once === true) {
1,582✔
348
                            const index = this._listeners.findIndex((e) =>
1,679✔
349
                                listenersMatch(e, { type, listener, options })
8,866✔
350
                            );
351
                            if (index !== -1) {
1,679✔
352
                                this._listeners.splice(index, 1);
1,720✔
353
                            }
1,720✔
354
                        }
1,679✔
355
                    }
1,582✔
356
                };
1,135✔
357
                this._listeners.push({ type, listener, options, listenerProxy });
6,810✔
358
            }
1,135✔
359
        }
796✔
360

361
        // Add the wrapped (or original) listener
362
        this._socket.addEventListener(type, listenerProxy, options);
455✔
363
    }
455✔
364

365
    removeEventListener<K extends keyof WebSocketEventMap>(
366
        type: K,
367
        listener:
368
            | ((this: ReconnectingWebSocket, ev: WebSocketEventMap[K]) => any)
369
            | { handleEvent: (event: WebSocketEventMap[K]) => any },
370
        options?: boolean | EventListenerOptions,
371
    ): void;
372
    removeEventListener(
112✔
373
        type: string,
112✔
374
        listener: EventListenerOrEventListenerObject,
112✔
375
        options?: boolean | EventListenerOptions,
112✔
376
    ): void {
112✔
377
        // Remove a wrapped listener, not an original listener
378
        const index = this._listeners.findIndex((e) => listenersMatch(e, { type, listener, options }));
617✔
379
        if (index !== -1) {
118✔
380
            const { listenerProxy } = this._listeners[index];
127✔
381
            this._socket.removeEventListener(type, listenerProxy, options);
127✔
382
            this._listeners.splice(index, 1);
127✔
383
        } else {
123✔
384
            // If the wrapped listener is not found, remove the original listener
385
            this._socket.removeEventListener(type, listener, options);
125✔
386
        }
125✔
387
    }
118✔
388

389
    dispatchEvent(event: Event): boolean {
112✔
390
        return this._socket.dispatchEvent(event);
122✔
391
    }
122✔
392
}
112✔
393

394
/** Check if two event listeners are the same (just like EventTarget). */
395
function listenersMatch(
112✔
396
    a: { type: string; listener: EventListenerOrEventListenerObject; options?: boolean | AddEventListenerOptions },
112✔
397
    b: { type: string; listener: EventListenerOrEventListenerObject; options?: boolean | AddEventListenerOptions },
112✔
398
): boolean {
399
    // EventTarget only compares capture in options, even if one is an object and the other is boolean
400
    const aCapture = Boolean(typeof a.options === "object" ? a.options.capture : a.options);
966✔
401
    const bCapture = Boolean(typeof b.options === "object" ? b.options.capture : b.options);
966✔
402
    return a.type === b.type && a.listener === b.listener && aCapture === bCapture;
966✔
403
}
966✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc