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

nktkas / hyperliquid / 17229216172

26 Aug 2025 05:48AM UTC coverage: 90.75% (-0.06%) from 90.806%
17229216172

push

github

nktkas
fix(transports): avoid wrapping `TransportError` into `HttpRequestError` / `WebSocketRequestError`

For example:

Before:
- `WebSocketRequestError: Unknown error while making a WebSocket request: ReconnectingWebSocketError: Error when reconnecting WebSocket: USER_INITIATED_CLOSE`

After:
- `ReconnectingWebSocketError: Error when reconnecting WebSocket: USER_INITIATED_CLOSE`

(`ReconnectingWebSocketError` instance of `TransportError`)

368 of 506 branches covered (72.73%)

Branch coverage included in aggregate %.

2 of 4 new or added lines in 2 files covered. (50.0%)

93 existing lines in 4 files now uncovered.

2958 of 3159 relevant lines covered (93.64%)

439.38 hits per line

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

95.45
/src/transports/websocket/_websocket_async_request.ts
1
import type { ReconnectingWebSocket } from "./_reconnecting_websocket.ts";
2
import type { HyperliquidEventMap, HyperliquidEventTarget } from "./_hyperliquid_event_target.ts";
3
import { WebSocketRequestError } from "./websocket_transport.ts";
119✔
4

5
interface PostRequest {
6
    method: "post";
7
    id: number;
8
    request: unknown;
9
}
10

11
interface SubscribeUnsubscribeRequest {
12
    method: "subscribe" | "unsubscribe";
13
    subscription: unknown;
14
}
15

16
interface PingRequest {
17
    method: "ping";
18
}
19

20
/**
21
 * Manages WebSocket requests to the Hyperliquid API.
22
 * Handles request creation, sending, and mapping responses to their corresponding requests.
23
 */
24
export class WebSocketAsyncRequest {
119✔
25
    protected lastId: number = 0;
352✔
26
    protected queue: {
352✔
27
        id: number | string;
28
        // deno-lint-ignore no-explicit-any
29
        resolve: (value?: any) => void;
30
        // deno-lint-ignore no-explicit-any
31
        reject: (reason?: any) => void;
32
    }[] = [];
176✔
33
    lastRequestTime: number = 0;
295✔
34

35
    /**
36
     * Creates a new WebSocket async request handler.
37
     * @param socket - WebSocket connection instance for sending requests to the Hyperliquid WebSocket API
38
     * @param hlEvents - Used to recognize Hyperliquid responses and match them with sent requests
39
     */
40
    constructor(protected socket: ReconnectingWebSocket, hlEvents: HyperliquidEventTarget) {
295✔
41
        // Monitor responses and match the pending request
42
        hlEvents.addEventListener("subscriptionResponse", (event) => {
176✔
43
            const detail = (event as HyperliquidEventMap["subscriptionResponse"]).detail;
216✔
44

45
            // Use a stringified request as an id
46
            const id = WebSocketAsyncRequest.requestToId(detail);
216✔
47
            this.queue.findLast((item) => item.id === id)?.resolve(detail);
216✔
48
        });
176✔
49
        hlEvents.addEventListener("post", (event) => {
176✔
50
            const detail = (event as HyperliquidEventMap["post"]).detail;
188✔
51

52
            const data = detail.response.type === "info" ? detail.response.payload.data : detail.response.payload;
188✔
53
            this.queue.findLast((item) => item.id === detail.id)?.resolve(data);
188✔
54
        });
176✔
55
        hlEvents.addEventListener("pong", () => {
176✔
56
            this.queue.findLast((item) => item.id === "ping")?.resolve();
180✔
57
        });
176✔
58
        hlEvents.addEventListener("error", (event) => {
176✔
59
            const detail = (event as HyperliquidEventMap["error"]).detail;
183✔
60

61
            try {
183✔
62
                // Error event doesn't have an id, use original request to match
63
                const request = detail.match(/{.*}/)?.[0];
183✔
64
                if (!request) return;
×
65

66
                const parsedRequest = JSON.parse(request) as Record<string, unknown>;
183✔
67

68
                // For `post` requests
69
                if ("id" in parsedRequest && typeof parsedRequest.id === "number") {
183✔
70
                    this.queue.findLast((item) => item.id === parsedRequest.id)
185✔
71
                        ?.reject(new WebSocketRequestError(`Server error: ${detail}`, { cause: detail }));
555✔
72
                    return;
185✔
73
                }
185✔
74

75
                // For `subscribe` and `unsubscribe` requests
76
                if (
183✔
77
                    "subscription" in parsedRequest &&
183✔
78
                    typeof parsedRequest.subscription === "object" && parsedRequest.subscription !== null
183✔
79
                ) {
183✔
80
                    const id = WebSocketAsyncRequest.requestToId(parsedRequest);
190✔
81
                    this.queue.findLast((item) => item.id === id)
190✔
82
                        ?.reject(new WebSocketRequestError(`Server error: ${detail}`, { cause: detail }));
570✔
83
                    return;
190✔
84
                }
190✔
85

86
                // For `Already subscribed` and `Invalid subscription` requests
87
                if (detail.startsWith("Already subscribed") || detail.startsWith("Invalid subscription")) {
183✔
88
                    const id = WebSocketAsyncRequest.requestToId({
191✔
89
                        method: "subscribe",
191✔
90
                        subscription: parsedRequest,
191✔
91
                    });
191✔
92
                    this.queue.findLast((item) => item.id === id)
191✔
93
                        ?.reject(new WebSocketRequestError(`Server error: ${detail}`, { cause: detail }));
573✔
94
                    return;
191✔
95
                }
191✔
96
                // For `Already unsubscribed` requests
97
                if (detail.startsWith("Already unsubscribed")) {
190✔
98
                    const id = WebSocketAsyncRequest.requestToId({
190✔
99
                        method: "unsubscribe",
190✔
100
                        subscription: parsedRequest,
190✔
101
                    });
190✔
102
                    this.queue.findLast((item) => item.id === id)
190✔
103
                        ?.reject(new WebSocketRequestError(`Server error: ${detail}`, { cause: detail }));
570✔
104
                    return;
190✔
105
                }
190!
106

107
                // For unknown requests
108
                const id = WebSocketAsyncRequest.requestToId(parsedRequest);
×
109
                this.queue.findLast((item) => item.id === id)
×
110
                    ?.reject(new WebSocketRequestError(`Server error: ${detail}`, { cause: detail }));
366✔
111
            } catch {
183✔
112
                // Ignore JSON parsing errors
113
            }
184✔
114
        });
176✔
115

116
        // Throws all pending requests if the connection is dropped
117
        socket.addEventListener("close", () => {
176✔
118
            this.queue.forEach(({ reject }) => {
227✔
119
                reject(new WebSocketRequestError("WebSocket connection closed."));
230✔
120
            });
227✔
121
            this.queue = [];
227✔
122
        });
176✔
123
    }
176✔
124

125
    /**
126
     * Sends a request to the Hyperliquid API.
127
     * @returns A promise that resolves with the parsed JSON response body.
128
     */
129
    async request(method: "ping", signal?: AbortSignal): Promise<void>;
130
    async request<T>(method: "post" | "subscribe" | "unsubscribe", payload: unknown, signal?: AbortSignal): Promise<T>;
131
    async request<T>(
119✔
132
        method: "post" | "subscribe" | "unsubscribe" | "ping",
119✔
133
        payload_or_signal?: unknown | AbortSignal,
119✔
134
        maybeSignal?: AbortSignal,
119✔
135
    ): Promise<T> {
119✔
136
        const payload = payload_or_signal instanceof AbortSignal ? undefined : payload_or_signal;
188✔
137
        const signal = payload_or_signal instanceof AbortSignal ? payload_or_signal : maybeSignal;
188✔
138

139
        // Reject the request if the signal is aborted
140
        if (signal?.aborted) return Promise.reject(signal.reason);
188✔
141
        // or if the WebSocket connection is permanently closed
UNCOV
142
        if (this.socket.reconnectAbortController.signal.aborted) {
×
UNCOV
143
            return Promise.reject(this.socket.reconnectAbortController.signal.reason);
×
UNCOV
144
        }
✔
145

146
        // Create a request
147
        let id: string | number;
255✔
148
        let request: SubscribeUnsubscribeRequest | PostRequest | PingRequest;
255✔
149
        if (method === "post") {
188✔
150
            id = ++this.lastId;
205✔
151
            request = { method, id, request: payload };
1,025✔
152
        } else if (method === "ping") {
188✔
153
            id = "ping";
291✔
154
            request = { method };
873✔
155
        } else {
287✔
156
            request = { method, subscription: payload };
1,332✔
157
            id = WebSocketAsyncRequest.requestToId(request);
333✔
158
        }
333✔
159

160
        // Send the request
161
        this.socket.send(JSON.stringify(request), signal);
255✔
162
        this.lastRequestTime = Date.now();
255✔
163

164
        // Wait for a response
165
        const { promise, resolve, reject } = Promise.withResolvers<T>();
255✔
166
        this.queue.push({ id, resolve, reject });
1,275✔
167

168
        const onAbort = () => reject(signal?.reason);
255✔
169
        signal?.addEventListener("abort", onAbort, { once: true });
564✔
170

171
        return await promise.finally(() => {
188✔
172
            const index = this.queue.findLastIndex((item) => item.id === id);
255✔
173
            if (index !== -1) this.queue.splice(index, 1);
255✔
174

175
            signal?.removeEventListener("abort", onAbort);
255✔
176
        });
188✔
177
    }
188✔
178

179
    /** Normalizes an object and then converts it to a string. */
180
    static requestToId(value: unknown): string {
119✔
181
        const lowerHex = deepLowerHex(value);
251✔
182
        const sorted = deepSortKeys(lowerHex);
251✔
183
        return JSON.stringify(sorted); // Also removes undefined
251✔
184
    }
251✔
185
}
119✔
186

187
/** Deeply converts hexadecimal strings in an object/array to lowercase. */
188
function deepLowerHex(obj: unknown): unknown {
119✔
189
    if (typeof obj === "string") {
703✔
190
        return /^(0X[0-9a-fA-F]*|0x[0-9a-fA-F]*[A-F][0-9a-fA-F]*)$/.test(obj) ? obj.toLowerCase() : obj;
1,050✔
191
    }
1,050✔
192

193
    if (Array.isArray(obj)) {
703✔
194
        return obj.map((value) => deepLowerHex(value));
704✔
195
    }
704✔
196

197
    if (typeof obj === "object" && obj !== null) {
703✔
198
        const result: Record<string, unknown> = {};
926✔
199
        const entries = Object.entries(obj);
926✔
200

201
        for (const [key, value] of entries) {
926✔
202
            result[key] = deepLowerHex(value);
1,375✔
203
        }
1,375✔
204

205
        return result;
926✔
206
    }
926✔
207

208
    return obj;
716✔
209
}
703✔
210

211
/** Deeply sort the keys of an object. */
212
function deepSortKeys<T>(obj: T): T {
119✔
213
    if (typeof obj !== "object" || obj === null) {
703✔
214
        return obj;
1,632✔
215
    }
1,632✔
216

217
    if (Array.isArray(obj)) {
703✔
218
        return obj.map(deepSortKeys) as T;
704✔
219
    }
704✔
220

221
    const result: Record<string, unknown> = {};
926✔
222
    const keys = Object.keys(obj).sort();
926✔
223

224
    for (const key of keys) {
703✔
225
        result[key] = deepSortKeys((obj as Record<string, unknown>)[key]);
1,152✔
226
    }
1,152✔
227

228
    return result as T;
926✔
229
}
703✔
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

© 2026 Coveralls, Inc