Skip to content

v0.1.4

Choose a tag to compare

@joeblau joeblau released this 28 Jul 02:59
· 11 commits to main since this release
7b5d0bb

What's changed

Performance work on the websocket receive path, plus two behaviours restored from 0.1.3.

Faster

Measured on CI, base vs head, three paired rounds on the same runner — 9 faster, 0 regressed, 37 unchanged:

  • l2book_dispatch_50_coins −40.0%
  • eip712_agent_digest_wasm −26.7%, eip712_multisig_digest_wasm −24.5%
  • ws_request_round_trip −23.3%
  • subscribe_200_coins −18.8%
  • prepare_request −17.6%
  • reconnect_resubscribe_burst −15.3%
  • l1_action_hash_order_100_wasm −10.8%

What changed under those numbers:

  • The socket no longer re-boxes every inbound frame. ReconnectingWebSocket was allocating a MessageEvent and paying a full EventTarget.dispatchEvent per frame purely to hand it to the SDK's own consumer, which then ran its own dispatch. That consumer now takes a direct frame hook; the public message event is still dispatched whenever anything outside the package listens. 1.42×, 305 ns/frame.
  • fastAssetCtxs decodes natively. Where the runtime exposes node:zlib, a per-frame DecompressionStream + writer + reader + four promises collapses into one inflateRawSync, delivered in the dispatch tick rather than through a promise queue. 7.6× on delta frames, 1.8× on full snapshots. DecompressionStream remains the browser / React Native fallback, and both paths are differentially tested to agree — including multi-chunk payloads and every malformed input.
  • Cheaper listener dispatch. A lone listener is stored unboxed and promoted to a copy-on-write array on the second registration, so multi-listener dispatch no longer copies into a snapshot buffer and clears it. 2.6× at 8 listeners, 1.18× at 1.
  • One less serialization per subscribe. The dispatcher reuses the subscription id it already built by concatenation as the wire frame.

Large frames move least: JSON.parse dominates them and the SDK cannot touch that.

Fixed

  • fastAssetCtxs could deliver the wrong frame. The payload was read from the recycled event shell inside a queued continuation, so once frames arrived faster than decompression completed, the continuation read a later frame's data — delta updates silently dropped and others delivered twice, corrupting downstream price state. The payload is now captured synchronously.
  • A listener could receive a frame after unsubscribing. Dispatch skipped the liveness check for a listener unsubscribed by an earlier listener mid-dispatch. 0.1.3 matched EventTarget here; the behaviour is restored.
  • createL1ActionHash validates vaultAddress again. It is publicly exported and was taking the address with no runtime check — a 0x${string} type constrains neither charset nor length at runtime, and nothing at all for JavaScript callers. A malformed address silently hashed to different bytes than the caller described, and a 32-byte address hashed identically to its 20-byte truncation. Both now throw, as they did at 0.1.3.

Behavioural change

HyperliquidEventTarget is no longer a subclass of EventTarget. Dispatch is now a hand-rolled listener map, which is where most of the per-frame win above comes from. addEventListener / removeEventListener keep their semantics — including duplicate-registration dedup, once, AbortSignal, and not delivering to a listener removed mid-dispatch — but instanceof EventTarget is now false and dispatchEvent is not available. Reaching for either was never part of the documented surface; if you depended on it, that access needs replacing. Flagged here because it landed in a patch release.

Also

New perf scenarios cover the fastAssetCtxs decode path and the user-account channels, neither of which was measured before.