@nktkas/hyperliquid
Version:
Hyperliquid API SDK for all major JS runtimes, written in TypeScript.
240 lines (239 loc) • 10.9 kB
JavaScript
/**
* Subscription lifecycle manager: tracks listeners per subscription payload,
* resubscribes on reconnect, and enforces per-connection subscription limits.
*
* @module
*/ import { ReconnectingWebSocket } from "@nktkas/rews";
import * as abort from "../_abort.js";
import { WebSocketRequestError } from "./_dispatcher.js";
import { requestToId } from "./_id.js";
/**
* Maximum number of subscriptions; the server rejects the excess without
* echoing the request, so the guard must run client-side.
*
* @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits
*/ const MAX_SUBSCRIPTIONS = 1000;
/**
* Maximum number of unique users across subscriptions; the server rejects the
* excess without echoing the request, so the guard must run client-side.
*
* @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits
*/ const MAX_UNIQUE_USERS = 15;
/** Tracks listeners per subscription payload, resubscribes on reconnect, enforces server-side limits. */ export class WebSocketSubscriptionManager {
/** Enable automatic re-subscription to Hyperliquid subscription after reconnection. */ resubscribe;
_socket;
_dispatcher;
_hlEvents;
_subscriptions = new Map();
constructor(socket, dispatcher, hlEvents, resubscribe){
this._socket = socket;
this._dispatcher = dispatcher;
this._hlEvents = hlEvents;
this.resubscribe = resubscribe;
socket.addEventListener("open", ()=>this._handleOpen());
socket.addEventListener("close", ()=>this._handleClose());
socket.addEventListener("error", ()=>this._handleClose());
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Subscribes to a Hyperliquid event channel.
*
* @param options.signal Stops waiting for the confirmation and detaches the listener.
* @param options.onError Callback invoked at most once, when an already confirmed subscription fails:
* - the server rejects a re-subscription after a reconnect;
* - the connection is permanently terminated;
* - the connection goes down while re-subscription is disabled.
*
* Failures before the confirmation reject the `subscribe()` promise instead.
* After the callback fires, the subscription is removed and no further events or errors follow.
*
* @throws {WebSocketRequestError} When the subscription request fails or limits are exceeded.
*/ async subscribe(channel, payload, listener, options) {
const { signal, onError } = options ?? {};
if (signal?.aborted) {
throw new WebSocketRequestError("Subscription was aborted", {
cause: signal.reason,
request: payload
});
}
const id = requestToId(payload);
// --- Subscription state --------------------------------------------------
let subscription = this._subscriptions.get(id);
if (!subscription) {
if (this._subscriptions.size >= MAX_SUBSCRIPTIONS) {
throw new WebSocketRequestError(`Cannot subscribe to more than ${MAX_SUBSCRIPTIONS} channels.`, {
request: payload
});
}
if (this._exceedsUserLimit(payload)) {
throw new WebSocketRequestError(`Cannot track more than ${MAX_UNIQUE_USERS} total users.`, {
request: payload
});
}
const promise = this._dispatcher.request("subscribe", payload).finally(()=>created.promiseFinished = true);
const created = {
channel,
listeners: new Map(),
promise,
promiseFinished: false
};
this._subscriptions.set(id, created);
subscription = created;
// Each subscriber awaits this promise through its own abort.race below,
// and an aborting subscriber stops awaiting early. If every subscriber
// aborts before the request settles, a later rejection would have no
// handler left and crash the process as an unhandled rejection; this
// no-op handler absorbs exactly that case.
promise.catch(()=>{});
}
// --- Listener registration -----------------------------------------------
let registration = subscription.listeners.get(listener);
const createdRegistration = registration === undefined;
if (!registration) {
const unsubscribe = async ()=>{
this._hlEvents.removeEventListener(channel, listener);
const current = this._subscriptions.get(id);
current?.listeners.delete(listener);
if (current?.listeners.size === 0) {
this._subscriptions.delete(id);
if (this._socket.readyState === ReconnectingWebSocket.OPEN) {
await this._dispatcher.request("unsubscribe", payload);
}
}
};
this._hlEvents.addEventListener(channel, listener);
registration = {
unsubscribe,
onError,
confirmed: false
};
subscription.listeners.set(listener, registration);
}
// --- Server confirmation -------------------------------------------------
try {
await abort.race(subscription.promise, signal);
registration.confirmed = true;
} catch (error) {
// Roll back only what this call registered: the subscription entry itself
// may legitimately survive (a re-subscription rejected by a disconnect is
// retried on the next open), but a listener whose subscribe() failed must
// never receive events — its caller holds no handle to remove it.
if (createdRegistration) {
this._hlEvents.removeEventListener(channel, listener);
const current = this._subscriptions.get(id);
if (current === subscription) {
subscription.listeners.delete(listener);
if (subscription.listeners.size === 0) {
this._subscriptions.delete(id);
// On abort the shared request stays in flight and may still confirm:
// free the server-side slot nobody is listening to.
if (signal && error === signal.reason && this._socket.readyState === ReconnectingWebSocket.OPEN) {
this._dispatcher.request("unsubscribe", payload).catch(()=>{});
}
}
}
}
if (signal && error === signal.reason) {
throw new WebSocketRequestError("Subscription was aborted", {
cause: error,
request: payload
});
}
throw error;
}
return {
unsubscribe: registration.unsubscribe
};
}
// ===========================================================================
// Event handlers
// ===========================================================================
/** Resubscribes to every completed subscription when the socket re-opens. */ _handleOpen() {
// Snapshot before iterating: _failSubscription mutates `_subscriptions`.
for (const [id, subscription] of [
...this._subscriptions.entries()
]){
if (!subscription.promiseFinished) continue;
// A subscription that survived a disconnect cannot be served when
// re-subscription was disabled while the socket was down.
if (!this.resubscribe) {
this._failSubscription(id, subscription, new WebSocketRequestError("WebSocket connection closed", {
request: JSON.parse(id)
}));
continue;
}
const promise = this._dispatcher.request("subscribe", JSON.parse(id));
subscription.promise = promise;
subscription.promiseFinished = false;
promise.catch((error)=>{
// A rejection on a live socket means the server refused the
// re-subscription or it timed out — either way the channel cannot be
// trusted anymore. A rejection on a dead socket is the disconnect
// clearing the queue; the next open retries.
if (this._socket.readyState === ReconnectingWebSocket.OPEN) {
// The dispatcher rejects only with WebSocketRequestError.
this._failSubscription(id, subscription, error);
}
}).finally(()=>subscription.promiseFinished = true);
}
}
/**
* Fails every subscription when the connection stops serving them: the socket is
* terminated, or it closes while re-subscription is disabled.
*/ _handleClose() {
const terminal = this._socket.terminationSignal.aborted;
if (this.resubscribe && !terminal) return;
// Snapshot before teardown: each call mutates `_subscriptions` during iteration.
for (const [id, subscription] of [
...this._subscriptions.entries()
]){
const error = terminal ? new WebSocketRequestError("WebSocket connection permanently terminated", {
cause: this._socket.terminationSignal.reason,
request: JSON.parse(id)
}) : new WebSocketRequestError("WebSocket connection closed", {
request: JSON.parse(id)
});
this._failSubscription(id, subscription, error);
}
}
// ===========================================================================
// Teardown
// ===========================================================================
/**
* Removes the subscription with all its listeners, then notifies each
* confirmed listener's `onError` once. Sends nothing to the server: every
* caller deals with a subscription the server no longer serves — refused,
* or cut off by a close.
*/ _failSubscription(id, subscription, error) {
if (this._subscriptions.get(id) !== subscription) return;
this._subscriptions.delete(id);
for (const [listener, registration] of subscription.listeners){
this._hlEvents.removeEventListener(subscription.channel, listener);
// An unconfirmed listener observes the failure through its still-pending
// subscribe() call instead.
if (!registration.confirmed) continue;
try {
registration.onError?.(error);
} catch {
// A throwing onError must not affect other listeners.
}
}
}
// ===========================================================================
// Subscription limit checks
// ===========================================================================
/** True when subscribing `payload` would track one user above the limit. */ _exceedsUserLimit(payload) {
const userOf = (p)=>typeof p === "object" && p !== null && "user" in p && typeof p.user === "string" ? p.user.toLowerCase() : undefined;
const user = userOf(payload);
if (user === undefined) return false;
const users = new Set();
for (const id of this._subscriptions.keys()){
const tracked = userOf(JSON.parse(id));
if (tracked !== undefined) users.add(tracked);
}
return !users.has(user) && users.size >= MAX_UNIQUE_USERS;
}
}
//# sourceMappingURL=_subscriptionManager.js.map