UNPKG

@nktkas/hyperliquid

Version:

Unofficial Hyperliquid API SDK for all major JS runtimes, written in TypeScript.

297 lines (296 loc) 11.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ReconnectingWebSocket = exports.ReconnectingWebSocketError = void 0; // deno-lint-ignore-file no-explicit-any const base_js_1 = require("../base.js"); /** Simple FIFO (First In, First Out) buffer implementation. */ class FIFOMessageBuffer { queue = []; push(data, signal) { this.queue.push({ data, signal }); } *[Symbol.iterator]() { while (this.queue.length > 0) { const { data, signal } = this.queue.shift(); if (signal?.aborted) continue; yield data; } } } /** Error thrown when reconnection problems occur. */ class ReconnectingWebSocketError extends base_js_1.TransportError { code; constructor(code, cause) { super(`Error when reconnecting WebSocket: ${code}`); this.code = code; this.name = "ReconnectingWebSocketError"; this.cause = cause; } } exports.ReconnectingWebSocketError = ReconnectingWebSocketError; /** * A WebSocket that automatically reconnects when disconnected. * Fully compatible with standard WebSocket API. */ class ReconnectingWebSocket { _socket; _protocols; _listeners = []; _attempt = 0; reconnectOptions; reconnectAbortController = new AbortController(); constructor(url, protocols, options) { this.reconnectOptions = { maxRetries: options?.maxRetries ?? 3, connectionTimeout: options?.connectionTimeout === undefined ? 10_000 : options.connectionTimeout, connectionDelay: options?.connectionDelay ?? ((n) => Math.min(~~(1 << n) * 150, 10_000)), shouldReconnect: options?.shouldReconnect ?? (() => true), messageBuffer: options?.messageBuffer ?? new FIFOMessageBuffer(), }; this._socket = this._createSocket(url, protocols); this._protocols = protocols; this._setupEventListeners(); } _createSocket(url, protocols) { const socket = new WebSocket(url, protocols); if (this.reconnectOptions.connectionTimeout === null) return socket; const timeoutId = setTimeout(() => { socket.removeEventListener("open", openHandler); socket.removeEventListener("close", closeHandler); socket.close(3008, "Timeout"); // https://www.iana.org/assignments/websocket/websocket.xml#close-code-number }, this.reconnectOptions.connectionTimeout); const openHandler = () => { socket.removeEventListener("close", closeHandler); clearTimeout(timeoutId); }; const closeHandler = () => { socket.removeEventListener("open", openHandler); clearTimeout(timeoutId); }; socket.addEventListener("open", openHandler, { once: true }); socket.addEventListener("close", closeHandler, { once: true }); return socket; } /** Initializes the internal event listeners for the socket. */ _setupEventListeners() { this._socket.addEventListener("open", this._open, { once: true }); this._socket.addEventListener("close", this._close, { once: true }); } _open = () => { // Reset the attempt counter this._attempt = 0; // Send all buffered messages for (const message of this.reconnectOptions.messageBuffer) { this._socket.send(message); } }; _close = async (event) => { try { // If the event was triggered but the socket is not closing, ignore it if (this._socket.readyState !== ReconnectingWebSocket.CLOSING && this._socket.readyState !== ReconnectingWebSocket.CLOSED) return; // If the instance is terminated, do not attempt to reconnect if (this.reconnectAbortController.signal.aborted) return; // Check if reconnection should be attempted if (++this._attempt > this.reconnectOptions.maxRetries) { this._cleanup("RECONNECTION_LIMIT_REACHED"); return; } const userDecision = await this.reconnectOptions.shouldReconnect(event, this.reconnectAbortController.signal); if (this.reconnectAbortController.signal.aborted) return; if (!userDecision) { this._cleanup("RECONNECTION_STOPPED_BY_USER"); return; } // Delay before reconnecting const reconnectDelay = typeof this.reconnectOptions.connectionDelay === "number" ? this.reconnectOptions.connectionDelay : await this.reconnectOptions.connectionDelay(this._attempt, this.reconnectAbortController.signal); if (this.reconnectAbortController.signal.aborted) return; await delay(reconnectDelay, this.reconnectAbortController.signal); // Create a new WebSocket instance const { onclose, onerror, onmessage, onopen } = this._socket; this._socket = this._createSocket(this._socket.url, this._protocols); // Reconnect all listeners this._setupEventListeners(); this._listeners.forEach(({ type, listenerProxy, options }) => { this._socket.addEventListener(type, listenerProxy, options); }); this._socket.onclose = onclose; this._socket.onerror = onerror; this._socket.onmessage = onmessage; this._socket.onopen = onopen; } catch (error) { this._cleanup("UNKNOWN_ERROR", error); } }; /** Clean up internal resources. */ _cleanup(code, cause) { this.reconnectAbortController.abort(new ReconnectingWebSocketError(code, cause)); this._listeners = []; this._socket.close(); } // WebSocket property implementations get url() { return this._socket.url; } get readyState() { return this._socket.readyState; } get bufferedAmount() { return this._socket.bufferedAmount; } get extensions() { return this._socket.extensions; } get protocol() { return this._socket.protocol; } get binaryType() { return this._socket.binaryType; } set binaryType(value) { this._socket.binaryType = value; } CONNECTING = 0; OPEN = 1; CLOSING = 2; CLOSED = 3; static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; get onclose() { return this._socket.onclose; } set onclose(value) { this._socket.onclose = value; } get onerror() { return this._socket.onerror; } set onerror(value) { this._socket.onerror = value; } get onmessage() { return this._socket.onmessage; } set onmessage(value) { this._socket.onmessage = value; } get onopen() { return this._socket.onopen; } set onopen(value) { this._socket.onopen = value; } /** * @param permanently - If `true`, the connection will be permanently closed. Default is `true`. */ close(code, reason, permanently = true) { this._socket.close(code, reason); if (permanently) this._cleanup("USER_INITIATED_CLOSE"); } /** * @param signal - `AbortSignal` to cancel sending a message if it was in the buffer. * @note If the connection is not open, the data will be buffered and sent when the connection is established. */ send(data, signal) { if (signal?.aborted) return; if (this._socket.readyState !== ReconnectingWebSocket.OPEN && !this.reconnectAbortController.signal.aborted) { this.reconnectOptions.messageBuffer.push(data, signal); } else { this._socket.send(data); } } addEventListener(type, listener, options) { // Wrap the listener to handle reconnection let listenerProxy; if (this.reconnectAbortController.signal.aborted) { // If the instance is terminated, use the original listener listenerProxy = listener; } else { // Check if the listener is already registered const index = this._listeners.findIndex((e) => listenersMatch(e, { type, listener, options })); if (index !== -1) { // Use the existing listener proxy listenerProxy = this._listeners[index].listenerProxy; } else { // Wrap the original listener to follow the once option when reconnecting listenerProxy = (event) => { try { if (typeof listener === "function") { listener.call(this, event); } else { listener.handleEvent(event); } } finally { // If the listener is marked as once, remove it after the first invocation if (typeof options === "object" && options.once === true) { const index = this._listeners.findIndex((e) => listenersMatch(e, { type, listener, options })); if (index !== -1) { this._listeners.splice(index, 1); } } } }; this._listeners.push({ type, listener, options, listenerProxy }); } } // Add the wrapped (or original) listener this._socket.addEventListener(type, listenerProxy, options); } removeEventListener(type, listener, options) { // Remove a wrapped listener, not an original listener const index = this._listeners.findIndex((e) => listenersMatch(e, { type, listener, options })); if (index !== -1) { const { listenerProxy } = this._listeners[index]; this._socket.removeEventListener(type, listenerProxy, options); this._listeners.splice(index, 1); } else { // If the wrapped listener is not found, remove the original listener this._socket.removeEventListener(type, listener, options); } } dispatchEvent(event) { return this._socket.dispatchEvent(event); } } exports.ReconnectingWebSocket = ReconnectingWebSocket; function listenersMatch(a, b) { // EventTarget only compares capture in options, even if one is an object and the other is boolean const aCapture = Boolean(typeof a.options === "object" ? a.options.capture : a.options); const bCapture = Boolean(typeof b.options === "object" ? b.options.capture : b.options); return a.type === b.type && a.listener === b.listener && aCapture === bCapture; } function delay(ms, signal) { if (signal?.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { const onAbort = () => { clearTimeout(timer); reject(signal?.reason); }; const onTimeout = () => { signal?.removeEventListener("abort", onAbort); resolve(); }; const timer = setTimeout(onTimeout, ms); signal?.addEventListener("abort", onAbort, { once: true }); }); }