@nktkas/hyperliquid
Version:
Hyperliquid API SDK for all major JS runtimes, written in TypeScript.
252 lines (251 loc) • 9.81 kB
JavaScript
/**
* WebSocket request dispatcher: sends `post`, `subscribe`, and `unsubscribe`
* messages and matches responses by id.
*
* @module
*/ import { ReconnectingWebSocket } from "@nktkas/rews";
import * as abort from "../_abort.js";
import { TransportError } from "../_base.js";
import { Promise_ } from "../_polyfills.js";
import { isSubset, requestToId, specificity } from "./_id.js";
// =============================================================================
// Errors
// =============================================================================
/**
* Error thrown when a WebSocket request fails.
*
* @example
* ```ts
* import { WebSocketRequestError, WebSocketTransport } from "@nktkas/hyperliquid";
*
* const transport = new WebSocketTransport();
* try {
* // Throws on a server rejection, a timeout, an abort, or a lost connection.
* await transport.request("info", { type: "allMids" });
* } catch (error) {
* if (error instanceof WebSocketRequestError) {
* console.error(error.message, error.request);
* }
* }
* ```
*/ export class WebSocketRequestError extends TransportError {
/** The original request payload that triggered the error, if available. */ request;
/**
* Creates a WebSocket request error.
*
* The failed request payload goes into `options.request`.
*/ constructor(message, options){
super(message, options);
this.name = "WebSocketRequestError";
this.request = options?.request;
}
}
// =============================================================================
// Dispatcher
// =============================================================================
/**
* Owns the WebSocket request queue and matches server responses back to
* in-flight requests.
*/ export class WebSocketDispatcher {
/** Timeout for requests in ms. Set to `null` to disable. */ timeout;
_socket;
_lastId = 0;
_queue = [];
constructor(socket, hlEvents, timeout){
this.timeout = timeout;
this._socket = socket;
// --- Hyperliquid event handlers ------------------------------------------
hlEvents.addEventListener("subscriptionResponse", (event)=>this._handleSubscriptionResponse(event.detail));
hlEvents.addEventListener("post", (event)=>this._handlePostResponse(event.detail));
hlEvents.addEventListener("error", (event)=>this._handleErrorEvent(event.detail));
// --- Socket lifecycle ----------------------------------------------------
// A rejected unsent request is guaranteed to never reach the server.
const handleDisconnect = ()=>{
this._queue.forEach(({ sent, payload, reject })=>reject(new WebSocketRequestError(sent ? "WebSocket connection closed" : "WebSocket connection closed before the request was sent", {
request: payload
})));
this._queue = [];
};
socket.addEventListener("close", handleDisconnect);
socket.addEventListener("error", handleDisconnect);
socket.addEventListener("open", ()=>{
// Everything still queued at this point was held back while disconnected.
for (const entry of this._queue){
entry.sent = true;
this._socket.send(entry.frame);
}
});
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Sends a request and resolves with the matched server response.
*
* @param signal Cancels the request from the caller's side.
*/ async request(method, payload, signal) {
// One controller per request: the timeout timer, the user signal, and the
// socket termination relay into it, and `finally` detaches everything, so
// no listener or timer outlives the request.
const controller = new AbortController();
const timeoutMs = this.timeout; // for correct error message after user changes
const timeout = abort.scheduleTimeout(controller, timeoutMs);
const detachRelay = abort.relay([
signal,
this._socket.terminationSignal
], controller);
let entry;
try {
if (controller.signal.aborted) throw controller.signal.reason;
// --- Build request envelope --------------------------------------------
const request = method === "post" ? {
method,
id: ++this._lastId,
request: payload
} : {
method,
subscription: payload
};
const id = "id" in request ? request.id : requestToId(request);
// --- Send or queue -----------------------------------------------------
const frame = JSON.stringify(request);
const sent = this._socket.readyState === ReconnectingWebSocket.OPEN;
if (sent) this._socket.send(frame);
const { promise, resolve, reject } = Promise_.withResolvers();
const pending = entry = {
id,
payload,
frame,
sent,
resolve,
reject
};
this._queue.push(pending);
controller.signal.addEventListener("abort", ()=>{
// Dequeue synchronously: an `open` flush between the abort and the
// `finally` microtask must not send a frame the caller saw rejected.
const index = this._queue.indexOf(pending);
if (index !== -1) this._queue.splice(index, 1);
reject(controller.signal.reason);
}, {
once: true
});
return await promise;
} catch (error) {
if (error instanceof TransportError) throw error;
if (error === timeout.reason) {
throw new WebSocketRequestError(`Request timed out after ${timeoutMs} ms`, {
cause: error,
request: payload
});
}
if (this._socket.terminationSignal.aborted && error === this._socket.terminationSignal.reason) {
throw new WebSocketRequestError("WebSocket connection permanently terminated", {
cause: error,
request: payload
});
}
if (controller.signal.aborted && error === controller.signal.reason) {
throw new WebSocketRequestError("Request aborted", {
cause: error,
request: payload
});
}
throw new WebSocketRequestError(`Unknown error while making a WebSocket request: ${error}`, {
cause: error,
request: payload
});
} finally{
if (entry) {
const index = this._queue.indexOf(entry);
if (index !== -1) this._queue.splice(index, 1);
}
timeout.cancel();
detachRelay();
}
}
// ===========================================================================
// Event handlers
// ===========================================================================
_handleSubscriptionResponse(detail) {
this._findByEcho(detail)?.resolve(detail);
}
_handlePostResponse(detail) {
const pending = this._queue.find((x)=>x.id === detail.id);
if (!pending) return;
if (detail.response.type === "error") {
pending.reject(new WebSocketRequestError(detail.response.payload, {
request: pending.payload
}));
} else {
const data = detail.response.type === "info" ? detail.response.payload.data : detail.response.payload;
pending.resolve(data);
}
}
_handleErrorEvent(detail) {
// Reject by the trailing id, e.g. `too many pending post requests id=1234`.
const idMatch = detail.match(/id=(\d+)$/);
if (idMatch) {
this._reject(this._queue.find((x)=>x.id === parseInt(idMatch[1], 10)), detail);
return;
}
// The remaining heuristics match by the request echoed in the message body;
// an embedded `{…}` body is valid JSON by the server contract.
const requestMatch = detail.match(/{.*}/)?.[0];
if (!requestMatch) return;
const parsedRequest = JSON.parse(requestMatch);
// A `post` envelope echo carries a numeric id.
if (typeof parsedRequest.id === "number") {
this._reject(this._queue.find((x)=>x.id === parsedRequest.id), detail);
return;
}
// A `subscribe` / `unsubscribe` envelope echo carries the subscription.
if (typeof parsedRequest.subscription === "object" && parsedRequest.subscription !== null) {
this._reject(this._findByEcho(parsedRequest), detail);
return;
}
// These prefixes echo only the subscription payload, without an envelope.
if (detail.startsWith("Already subscribed") || detail.startsWith("Invalid subscription")) {
this._reject(this._findByEcho({
method: "subscribe",
subscription: parsedRequest
}), detail);
return;
}
if (detail.startsWith("Already unsubscribed")) {
this._reject(this._findByEcho({
method: "unsubscribe",
subscription: parsedRequest
}), detail);
}
}
/** Rejects `pending`, when found, with the server text as the message. */ _reject(pending, detail) {
pending?.reject(new WebSocketRequestError(detail, {
request: pending.payload
}));
}
/**
* Finds the pending request matching an echoed body.
*
* The server normalizes the echo — fields can be added to the payload and
* unknown ones dropped — so the queue is searched by subset. Among several
* subset matches the most specific pending wins: when one in-flight payload
* is a subset of another, the looser one must not swallow the echo meant
* for the stricter one.
*/ _findByEcho(echo) {
let best;
let bestSpecificity = -1;
for (const pending of this._queue){
if (typeof pending.id !== "string") continue;
const payload = JSON.parse(pending.id);
if (!isSubset(payload, echo)) continue;
const score = specificity(payload);
if (score > bestSpecificity) {
best = pending;
bestSpecificity = score;
}
}
return best;
}
}
//# sourceMappingURL=_dispatcher.js.map