@nktkas/hyperliquid
Version:
Unofficial Hyperliquid API SDK for all major JS runtimes, written in TypeScript.
297 lines (296 loc) • 11.6 kB
JavaScript
"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 });
});
}