From 8cfdfd4c3e8f52db1462edf858fd91ab164ec47a Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Tue, 28 Jul 2026 06:13:35 -0700 Subject: [PATCH] fix(subscription): allMids() never resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release 0.1.6. `client.allMids()` — with no arguments, or with `dex: ""` — never resolved. It rejected with `WebSocketRequestError: Request timed out` after the configured timeout, or hung forever when `timeout` was null. Only a non-empty `dex` worked, so the main-dex mid-price feed, which is what almost every caller wants, was unusable. The chain: - `allMids.ts` builds its payload as `{ type: "allMids", dex: params.dex || undefined }`, so `dex` is an own key holding `undefined`. - `normalize()` walks `Object.keys()` and faithfully recreates that key, so the subscription's normalized form carries `dex: undefined`. - `JSON.stringify` drops it on the way out, so the server receives `{"type":"allMids"}` and echoes back exactly that. - `isSubset` requires every key of the pending request to be present in the response (`key in sup`). It looks for a `dex` the server was never told about, finds nothing, and the subscription is never matched to its own confirmation. Fixed in `normalize()` rather than in `allMids`: a key whose value is `undefined` cannot survive serialization, so keeping it leaves the in-memory identity describing a request that was never sent. Dropping it makes the id, the echo and the wire frame agree, and immunizes any future payload built with the same `x || undefined` shape. `allMids` is the only method using it today. No test caught this because the only `allMids` test is `mode: "api"` and needs the live network, so it is skipped in the offline suite CI runs. The regression test added here covers the root cause and runs offline; it was verified to fail against the unfixed normalize(). Found while benchmarking against upstream @nktkas/hyperliquid, whose allMids works correctly — the divergence is ours. Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/transport/websocket/_id.ts | 7 +++++++ tests/transport/websocket/_id.test.ts | 11 +++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8dd908de..c431f7f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bloxwap/hyperliquid", - "version": "0.1.5", + "version": "0.1.6", "description": "Blazing fast TypeScript Hyperliquid SDK.", "license": "MIT", "type": "module", diff --git a/src/transport/websocket/_id.ts b/src/transport/websocket/_id.ts index a7abf63c..9db6fc5c 100644 --- a/src/transport/websocket/_id.ts +++ b/src/transport/websocket/_id.ts @@ -85,6 +85,13 @@ export function normalize(value: unknown): unknown { const result: Record = {}; for (const key of Object.keys(value).sort()) { const item = normalize((value as Record)[key]); + // Drop `undefined`-valued keys, matching what `JSON.stringify` does to this object on its + // way to the wire. Keeping them would leave the id and the echo carrying a field the server + // never receives and so can never echo back — and `isSubset` requires every key of the + // pending request to be present in the response, so such a subscription could never be + // matched to its own confirmation. A payload built as `{ dex: params.dex || undefined }` + // is the shape that hits this. + if (item === undefined) continue; // `__proto__` is the one key with a setter on `Object.prototype`: plain assignment would // invoke it and silently drop an own `__proto__` key a `JSON.parse`d payload can carry, // collapsing two logically distinct payloads onto the same id. Define it as a data diff --git a/tests/transport/websocket/_id.test.ts b/tests/transport/websocket/_id.test.ts index 1abd5ea5..fa306890 100644 --- a/tests/transport/websocket/_id.test.ts +++ b/tests/transport/websocket/_id.test.ts @@ -25,6 +25,17 @@ describe("requestToId", () => { assert(requestToId([1, 2]) !== requestToId([2, 1])); }); + test("drops undefined-valued keys, matching what reaches the wire", () => { + // `JSON.stringify` omits an `undefined` value, so a payload built as + // `{ dex: params.dex || undefined }` goes out as `{"type":"allMids"}`. Keeping the key in the + // normalized form left the pending request demanding a field the server can never echo back, + // and `isSubset` then refused to match the subscription to its own confirmation — `allMids()` + // never resolved. The normalized form has to describe what was actually sent. + assertEquals(requestToId({ type: "allMids", dex: undefined }), requestToId({ type: "allMids" })); + assertEquals(requestToId({ type: "allMids", dex: undefined }), '{"type":"allMids"}'); + assert(isSubset(normalize({ type: "allMids", dex: undefined }), { type: "allMids" })); + }); + test("keeps an own __proto__ key from a parsed payload", () => { const payload = JSON.parse('{"type":"l2Book","__proto__":{"coin":"BTC"}}') as Record; assertEquals(requestToId(payload), '{"__proto__":{"coin":"BTC"},"type":"l2Book"}');