From f2ef39c9e489c1ec874e6bb1cd89f4ed92f678fe Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Wed, 7 Oct 2026 08:42:39 +0800 Subject: [PATCH] feat(sdk): verify and measure per-operation entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `./api//` and `./actions/` entry points shipped with the package; this closes the remaining acceptance criteria of #161 around them. - Import graph: add budgets for a single Exchange (`order`) and Subscription (`allMids`) operation, forbid family-crossing modules in each operation's closure (no signing or Exchange/Subscription code behind Info `allMids`), and assert every representative operation loads fewer modules than its client and barrel. - Export sync: require a `./actions/` entry for every public builder, the same contract `_methods` files already had. - Build: externalize `transport/runtime.ts` and the bounded dispatch lanes to the shared core so narrow bundles never inline stateful modules; guard the bare-chunk-import stripping by failing the build when a deferred chunk has a top-level side effect beyond chunk-local writes; walk the published closures of single Subscription and Explorer operations too. - Published consumer: type-check under `nodenext`, `node16` and `bundler` resolution, with operation-specific inference and negatives for Info, Explorer and Subscription operations, and check `systemRuntime` identity. - `.dev/perf/entry_points.ts`: client vs barrel vs operation for Info, Exchange and Subscription — files and bytes evaluated, minified/gzip bundle size, and cold import medians on Node and Bun. - Docs: a "Choosing an import style" section, per-op paths in every example. Closes #161 Co-Authored-By: Claude Opus 5.5 --- apps/docs/content/docs/guides/tree-shaking.md | 29 ++- packages/hyperliquid/.dev/build/build.ts | 112 +++++++++-- packages/hyperliquid/.dev/build/consumer.mjs | 2 + packages/hyperliquid/.dev/build/consumer.ts | 16 +- .../hyperliquid/.dev/export_sync_check.ts | 23 ++- .../hyperliquid/.dev/import_graph_check.ts | 77 +++++++- .../hyperliquid/.dev/perf/entry_points.ts | 180 ++++++++++++++++++ 7 files changed, 408 insertions(+), 31 deletions(-) create mode 100644 packages/hyperliquid/.dev/perf/entry_points.ts diff --git a/apps/docs/content/docs/guides/tree-shaking.md b/apps/docs/content/docs/guides/tree-shaking.md index ebab2a9f..827f4466 100644 --- a/apps/docs/content/docs/guides/tree-shaking.md +++ b/apps/docs/content/docs/guides/tree-shaking.md @@ -68,8 +68,8 @@ await order( Subscription methods use [`SubscriptionClient`](../clients.md#websocket-subscriptions) config: ```ts -import { WebSocketTransport } from "@bloxwap/hyperliquid"; -import { allMids } from "@bloxwap/hyperliquid/api/subscription"; +import { WebSocketTransport } from "@bloxwap/hyperliquid/transport/websocket"; +import { allMids } from "@bloxwap/hyperliquid/api/subscription/allMids"; const transport = new WebSocketTransport(); const subscription = await allMids({ transport }, (data) => { @@ -81,7 +81,7 @@ Explorer methods use [`ExplorerClient`](../clients.md#explorer-endpoint) config: ```ts import { HttpTransport } from "@bloxwap/hyperliquid/transport/http"; -import { blockDetails } from "@bloxwap/hyperliquid/api/explorer"; +import { blockDetails } from "@bloxwap/hyperliquid/api/explorer/blockDetails"; const transport = new HttpTransport(); const block = await blockDetails({ transport }, { height: 123 }); @@ -94,6 +94,27 @@ types. The four families are `info`, `exchange`, `explorer`, and `subscription`. remain available. Per-operation imports reduce runtime module evaluation even when Node or Bun runs without a bundler; API barrel imports rely on bundling/tree-shaking to remove sibling operations. +```ts +import type { L2BookParameters, L2BookResponse } from "@bloxwap/hyperliquid/api/info/l2Book"; +import { l2Book } from "@bloxwap/hyperliquid/api/info/l2Book"; +``` + Canonical builders are available at `@bloxwap/hyperliquid/actions/` (for example `buildOrder` from `actions/order`). Import execution stages separately from `actions/execution` when you need only a few builders. -The `actions` barrel contains every builder. +The `actions` barrel contains every builder and the optional order batcher. + +Underscore-prefixed paths such as `api/info/_base` are private and are not exported; everything a caller needs is +reachable through the paths above. + +## Choosing an import style + +| Import | Use it when | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| Client (`api//client`, or the root barrel) | You call many methods of a family, want one object that holds the config, or prefer discoverability over size. Loads every method it wraps. | +| API barrel (`api/`) | You bundle for the browser and want several functions from one import line. A bundler removes unused siblings; unbundled Node and Bun load them all. | +| One operation (`api//`) | A script, server function or CLI runs without a bundler and calls a few methods. Only that method, its schema and shared core code load. | +| Builder (`actions/`) with `actions/execution` | You build, sign and submit exchange actions as separate steps, for example to sign remotely or batch. | + +The narrow paths matter most for cold starts. Importing only Info `allMids` loads no signing, Exchange, or Subscription +code, and an Exchange operation loads the signing core but none of its sibling actions. Checks in the SDK build keep +these closures within fixed module budgets. diff --git a/packages/hyperliquid/.dev/build/build.ts b/packages/hyperliquid/.dev/build/build.ts index 791cecc4..478bc38f 100644 --- a/packages/hyperliquid/.dev/build/build.ts +++ b/packages/hyperliquid/.dev/build/build.ts @@ -27,6 +27,7 @@ import { tmpdir } from "node:os"; import { dirname, join, relative as pathRelative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { build as esbuild } from "esbuild"; +import ts from "typescript"; // --- Layout ------------------------------------------------------------------ @@ -139,8 +140,10 @@ async function bundleSources(root: RootManifest): Promise { "signing/mod.ts", "signing/_canonicalize.ts", "transport/_base.ts", + "transport/runtime.ts", "api/exchange/_methods/_base/_shell.ts", "api/exchange/_methods/_base/_nonce.ts", + "api/exchange/_methods/_base/_dispatch.ts", "api/exchange/_methods/_base/execute.ts", "actions/_canonical.ts", "actions/execution.ts", @@ -150,7 +153,7 @@ async function bundleSources(root: RootManifest): Promise { const aggregators = new Map(); for (const [index, name] of core.entries()) { const file = join(ROOT_DIR, "src", name); - const group = ["_base.ts", "api/_errors.ts", "transport/_base.ts"].includes(name) + const group = ["_base.ts", "api/_errors.ts", "transport/_base.ts", "transport/runtime.ts"].includes(name) ? "runtime" : name === "signing/mod.ts" ? "signing" @@ -257,6 +260,7 @@ async function bundleSources(root: RootManifest): Promise { await esbuild({ ...common, entryPoints: narrow, outdir: DIST_DIR, splitting: false, plugins: [externalCore] }), ]; let count = core.filter((name) => Object.values(root.exports).includes(`./src/${name}`)).length; + const stripped = new Set(); for (const output of outputs) for (const file of Object.keys(output.metafile.outputs)) { const absolute = resolve(ROOT_DIR, file); @@ -274,15 +278,74 @@ async function bundleSources(root: RootManifest): Promise { // Bundled consumers drop these already; unbundled Node/Bun consumers need the same // behavior to prevent an Info-only import from evaluating exchange/signing chunks. const isolated = portable.replace(/\bimport\s*"(\.{1,2}\/[^"\n]+)"\s*;/g, (match, specifier: string) => { - return resolve(dirname(absolute), specifier).startsWith(join(DIST_DIR, "_chunks")) ? "" : match; + const chunk = resolve(dirname(absolute), specifier); + if (!chunk.startsWith(join(DIST_DIR, "_chunks"))) return match; + stripped.add(chunk); + return ""; }); if (isolated.includes(DIST_DIR)) throw new Error(`Non-portable SDK path in ${file}`); if (isolated !== code) await writeFile(absolute, isolated); count++; } + for (const chunk of stripped) await assertDeferrable(chunk); return count; } +/** + * Fails the build if a chunk whose bare import {@linkcode bundleSources} removed could do observable work when it + * evaluates. + * + * Removing `import "./_chunks/x.js"` defers that chunk until something imports one of its bindings, which is only + * sound while evaluating it touches nothing but its own top-level bindings. Declarations pass; so do writes to, and + * method calls on, a binding the chunk itself declares (`TABLE[0] = 25`, `seen.add(value)`), which is how esbuild + * lowers module-local setup. Anything else at the top level — a bare call, a write to an imported or global binding, + * a class static block — could register or patch state another module relies on, so it stops the build instead of + * silently changing evaluation order. Variable initializers are not inspected: the sources keep them to value + * construction, and a side effect hidden there would equally break the `sideEffects: false` promise for bundlers. + * + * @param chunk - Absolute path of an emitted chunk. + * @throws If a top-level statement is not a declaration or a chunk-local write. + */ +async function assertDeferrable(chunk: string): Promise { + const source = ts.createSourceFile(chunk, await Bun.file(chunk).text(), ts.ScriptTarget.ESNext); + const locals = new Set(); + for (const statement of source.statements) { + if (ts.isVariableStatement(statement)) { + for (const { name } of statement.declarationList.declarations) if (ts.isIdentifier(name)) locals.add(name.text); + } else if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name) { + locals.add(statement.name.text); + } + } + for (const statement of source.statements) { + if ( + ts.isImportDeclaration(statement) || + ts.isExportDeclaration(statement) || + ts.isFunctionDeclaration(statement) || + ts.isVariableStatement(statement) || + (ts.isClassDeclaration(statement) && !statement.members.some(ts.isClassStaticBlockDeclaration)) + ) { + continue; + } + if (ts.isExpressionStatement(statement)) { + const { expression } = statement; + let target: ts.Expression | undefined; + if (ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + target = expression.left; + } else if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression)) { + target = expression.expression; + } + let owner = target; + while (owner && (ts.isPropertyAccessExpression(owner) || ts.isElementAccessExpression(owner))) { + owner = owner.expression; + } + if (owner !== target && owner && ts.isIdentifier(owner) && locals.has(owner.text)) continue; + } + throw new Error( + `${pathRelative(DIST_DIR, chunk)} has a top-level side effect, so its bare import cannot be dropped: ${statement.getText(source).slice(0, 120)}`, + ); + } +} + /** * Loads every emitted entry point in Node and checks that it exports exactly what its TypeScript source exports. * @@ -290,11 +353,22 @@ async function bundleSources(root: RootManifest): Promise { * this script once produced passed every in-repo check and only broke under Node's linker. Running the check as part * of the build makes a broken bundle a failed build instead of a broken release. * + * First, it walks the emitted import closure of the read-only entry points (the Info client, single Info, Subscription + * and Explorer operations, the HTTP transport) and fails if any file in it defines signing or client code from another + * family — the published counterpart of the source-level budgets in `.dev/import_graph_check.ts`. + * * @param root - The parsed root manifest. - * @throws If Node cannot load an entry, or an entry's export names differ from its source's. + * @throws If a read-only closure reaches signing or client code, Node cannot load an entry, or an entry's export names + * differ from its source's. */ async function verifyBundle(root: RootManifest): Promise { - for (const entry of ["api/info/client.js", "api/info/_methods/allMids.js", "transport/http/mod.js"]) { + for (const entry of [ + "api/info/client.js", + "api/info/_methods/allMids.js", + "api/subscription/_methods/allMids.js", + "api/explorer/_methods/explorerBlock.js", + "transport/http/mod.js", + ]) { const pending = [join(DIST_DIR, entry)]; const seen = new Set(); while (pending.length) { @@ -437,26 +511,30 @@ async function verifyConsumer(root: RootManifest): Promise { for (const name of ["consumer.ts", "consumer.mjs"]) { await copyFile(join(ROOT_DIR, ".dev/build", name), join(consumer, name)); } + const tsc = (...resolution: string[]): string[] => [ + process.execPath, + join(ROOT_DIR, "node_modules/typescript/bin/tsc"), + "consumer.ts", + "--noEmit", + "--strict", + "--skipLibCheck", + "--target", + "es2024", + ...resolution, + ]; + // Every resolution mode that honours `exports`: Node's two ESM modes, and the bundler mode that + // Vite, esbuild, webpack and Bun projects use. const commands = [ ["node", "consumer.mjs"], [process.execPath, "consumer.mjs"], - [ - process.execPath, - join(ROOT_DIR, "node_modules/typescript/bin/tsc"), - "consumer.ts", - "--noEmit", - "--strict", - "--skipLibCheck", - "--module", - "nodenext", - "--target", - "es2024", - ], + tsc("--module", "nodenext"), + tsc("--module", "node16"), + tsc("--module", "esnext", "--moduleResolution", "bundler"), ]; for (const command of commands) { const child = Bun.spawn(command, { cwd: consumer, stdio: ["inherit", "inherit", "inherit"] }); const code = await child.exited; - if (code !== 0) throw new Error(`Published consumer failed: ${command[0]} (exit ${code})`); + if (code !== 0) throw new Error(`Published consumer failed: ${command.join(" ")} (exit ${code})`); } } finally { await rm(consumer, { recursive: true, force: true }); diff --git a/packages/hyperliquid/.dev/build/consumer.mjs b/packages/hyperliquid/.dev/build/consumer.mjs index a75c7352..d34c54e6 100644 --- a/packages/hyperliquid/.dev/build/consumer.mjs +++ b/packages/hyperliquid/.dev/build/consumer.mjs @@ -4,6 +4,7 @@ import * as root from "@bloxwap/hyperliquid"; import { ExchangeClient } from "@bloxwap/hyperliquid/api/exchange/client"; import { ApiRequestError } from "@bloxwap/hyperliquid/api/exchange"; import { HttpTransport } from "@bloxwap/hyperliquid/transport/http"; +import { systemRuntime } from "@bloxwap/hyperliquid/transport/runtime"; import { allMids } from "@bloxwap/hyperliquid/api/info/allMids"; import { buildOrder } from "@bloxwap/hyperliquid/actions/order"; import { order } from "@bloxwap/hyperliquid/api/exchange/order"; @@ -14,6 +15,7 @@ import { fastAssetCtxs as individualFastAssetCtxs } from "@bloxwap/hyperliquid/a assert.equal(root.ExchangeClient, ExchangeClient); assert.equal(root.HttpTransport, HttpTransport); assert.equal(root.ApiRequestError, ApiRequestError); +assert.equal(root.systemRuntime, systemRuntime); assert.equal(barrelFastAssetCtxs, individualFastAssetCtxs); const expectedEntries = JSON.parse(readFileSync(new URL("./entries.json", import.meta.url), "utf8")); for (const [specifier, expected] of Object.entries(expectedEntries)) { diff --git a/packages/hyperliquid/.dev/build/consumer.ts b/packages/hyperliquid/.dev/build/consumer.ts index 81149763..87d2eedc 100644 --- a/packages/hyperliquid/.dev/build/consumer.ts +++ b/packages/hyperliquid/.dev/build/consumer.ts @@ -1,6 +1,10 @@ /** Type-check against the relocated published package, rather than source aliases. */ import { ExchangeClient, HttpTransport } from "@bloxwap/hyperliquid"; import { allMids } from "@bloxwap/hyperliquid/api/info/allMids"; +import { l2Book, type L2BookResponse } from "@bloxwap/hyperliquid/api/info/l2Book"; +import { blockDetails, type BlockDetailsResponse } from "@bloxwap/hyperliquid/api/explorer/blockDetails"; +import { allMids as allMidsChannel } from "@bloxwap/hyperliquid/api/subscription/allMids"; +import { WebSocketTransport } from "@bloxwap/hyperliquid/transport/websocket"; import { buildOrder } from "@bloxwap/hyperliquid/actions/order"; import { executeAction, signAction, submitAction } from "@bloxwap/hyperliquid/actions/execution"; import { createOrderBatcher, type OrderOutcome } from "@bloxwap/hyperliquid/actions/orderBatcher"; @@ -13,7 +17,17 @@ void ({} as ActionMetadata); declare const config: ExchangeConfig; const transport = new HttpTransport(); const mids: Record = await allMids({ transport }); -void mids; +// @ts-expect-error Operation responses keep their specific type rather than widening. +const wrongMids: number = await allMids({ transport }); +// @ts-expect-error Operation parameters are checked against the operation's own schema. +await allMids({ transport }, { dex: 1 }); +const book: L2BookResponse = await l2Book({ transport }, { coin: "BTC" }); +const block: BlockDetailsResponse = await blockDetails({ transport }, { height: 1 }); +const subscription = await allMidsChannel({ transport: new WebSocketTransport() }, (event) => { + const channelMids: Record = event.mids; + void channelMids; +}); +void [mids, wrongMids, book, block, subscription]; const input = { a: 0, b: true, p: "1", s: "1", r: false, t: { limit: { tif: "Gtc" as const } } }; const action = buildOrder({ orders: [input] }); const client = new ExchangeClient(config); diff --git a/packages/hyperliquid/.dev/export_sync_check.ts b/packages/hyperliquid/.dev/export_sync_check.ts index 5de88fa9..67205e4a 100644 --- a/packages/hyperliquid/.dev/export_sync_check.ts +++ b/packages/hyperliquid/.dev/export_sync_check.ts @@ -1,11 +1,13 @@ /** * Export Sync Checker * - * Two gates over the public surface of the package: + * Three gates over the public surface of the package: * * 1. Method sync — every `_methods/.ts` file in an API module is re-exported from that module's `mod.ts` and * `client.ts`, and neither file re-exports a method that no longer exists. - * 2. Reachability — every module under `src/` is reachable by walking relative imports from the entry points declared + * 2. Entry points — every `_methods/.ts` file has its `./api//` export, and every public + * `actions/.ts` builder its `./actions/` export, so consumers never need private paths. + * 3. Reachability — every module under `src/` is reachable by walking relative imports from the entry points declared * in `package.json`'s `exports` map. An unreachable module is dead code that still ships in the repo; an * unresolvable relative specifier is a broken edge in that same graph. * @@ -412,6 +414,23 @@ async function main(): Promise { allErrors.push(...compareClientExports(methodsFromDir, clientExports, endpoint)); } + // Builders get the same one-file, one-entry-point contract as API operations, so a new action + // cannot ship reachable only through the `./actions` barrel. `mod.ts` is that barrel itself. + const manifest = await Bun.file(path.join(process.cwd(), "package.json")).json(); + for (const actionName of await getMethodsFromDir("src/actions")) { + if (actionName === "mod") continue; + const expected = `./src/actions/${actionName}.ts`; + if (manifest.exports?.[`./actions/${actionName}`] !== expected) { + allErrors.push({ + scope: "exports", + subject: actionName, + errorType: "missing action entry point", + details: `Expected ./actions/${actionName} to export ${expected}`, + filePath: "package.json", + }); + } + } + allErrors.push(...(await checkReachability())); // Success diff --git a/packages/hyperliquid/.dev/import_graph_check.ts b/packages/hyperliquid/.dev/import_graph_check.ts index cfe09462..fd645a63 100644 --- a/packages/hyperliquid/.dev/import_graph_check.ts +++ b/packages/hyperliquid/.dev/import_graph_check.ts @@ -22,7 +22,7 @@ */ import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import process from "node:process"; import ts from "typescript"; @@ -41,7 +41,7 @@ const ROOT_DIR: string = resolve(fileURLToPath(import.meta.url), "../.."); * trip it. `why` explains what the budget protects, and is printed on failure — a budget whose * rationale is not obvious gets raised by the next person who trips it. */ -const BUDGETS: readonly { entry: string; limit: number; why: string }[] = [ +const BUDGETS: readonly { entry: string; limit: number; why: string; forbid?: RegExp }[] = [ { entry: "src/utils/mod.ts", limit: 20, @@ -66,11 +66,25 @@ const BUDGETS: readonly { entry: string; limit: number; why: string }[] = [ entry: "src/api/info/_methods/allMids.ts", limit: 12, why: "A single Info operation must not load sibling operations or signing.", + forbid: /\/(?:signing|api\/(?:exchange|subscription)|transport\/websocket)\//, + }, + { + entry: "src/api/exchange/_methods/order.ts", + limit: 35, + why: "A single Exchange operation needs the signing and nonce core, but not sibling actions or the Info and Subscription graphs.", + forbid: /\/api\/(?:info|subscription|explorer)\//, + }, + { + entry: "src/api/subscription/_methods/allMids.ts", + limit: 8, + why: "A single Subscription operation must not load sibling channels, the WebSocket transport or signing.", + forbid: /\/(?:signing|api\/(?:exchange|info)|transport\/websocket)\//, }, { entry: "src/api/explorer/_methods/explorerBlock.ts", limit: 12, why: "A single Explorer operation must remain independent of the other API families.", + forbid: /\/(?:signing|api\/(?:exchange|info|subscription))\//, }, { entry: "src/actions/order.ts", @@ -79,6 +93,25 @@ const BUDGETS: readonly { entry: string; limit: number; why: string }[] = [ }, ]; +/** + * Operation entry points that must load strictly fewer modules than the client and barrel they + * belong to. Budgets bound absolute growth; this pins the reason the entry points exist, so a + * change that makes one operation as heavy as its whole family fails even inside its budget. + */ +const NARROWER_THAN: readonly { entry: string; wider: readonly string[] }[] = [ + { entry: "src/api/info/_methods/allMids.ts", wider: ["src/api/info/client.ts", "src/api/info/mod.ts"] }, + { entry: "src/api/exchange/_methods/order.ts", wider: ["src/api/exchange/client.ts", "src/api/exchange/mod.ts"] }, + { + entry: "src/api/subscription/_methods/allMids.ts", + wider: ["src/api/subscription/client.ts", "src/api/subscription/mod.ts"], + }, + { + entry: "src/api/explorer/_methods/explorerBlock.ts", + wider: ["src/api/explorer/client.ts", "src/api/explorer/mod.ts"], + }, + { entry: "src/actions/order.ts", wider: ["src/actions/mod.ts"] }, +]; + // ============================================================================= // GRAPH // ============================================================================= @@ -129,11 +162,24 @@ function closure(entry: string): Set { // MAIN // ============================================================================= +const sizes = new Map(); +/** Runtime closure size of `entry`, memoized across the budget and comparison passes. */ +function sizeOf(entry: string): number { + let size = sizes.get(entry); + if (size === undefined) sizes.set(entry, (size = closure(resolve(ROOT_DIR, entry)).size)); + return size; +} + let failed = false; -for (const { entry, limit, why } of BUDGETS) { - const size = closure(resolve(ROOT_DIR, entry)).size; - const status = size <= limit ? "ok" : "OVER"; - console.log(`${status.padEnd(5)} ${entry.padEnd(28)} ${String(size).padStart(4)} / ${limit}`); +for (const { entry, limit, why, forbid } of BUDGETS) { + const modules = closure(resolve(ROOT_DIR, entry)); + const size = modules.size; + sizes.set(entry, size); + const leaked = forbid + ? [...modules].map((file) => relative(ROOT_DIR, file)).filter((file) => forbid.test(`/${file}`)) + : []; + const status = size <= limit && leaked.length === 0 ? "ok" : "OVER"; + console.log(`${status.padEnd(5)} ${entry.padEnd(44)} ${String(size).padStart(4)} / ${limit}`); if (size > limit) { failed = true; console.error(`\n ${entry} now loads ${size} modules at runtime, over its budget of ${limit}.`); @@ -142,10 +188,27 @@ for (const { entry, limit, why } of BUDGETS) { " Import the specific modules you need rather than a `mod.ts` barrel, or raise the budget deliberately.\n", ); } + if (leaked.length > 0) { + failed = true; + console.error(`\n ${entry} reaches modules outside its family: ${leaked.join(", ")}.`); + console.error(` ${why}\n`); + } +} + +for (const { entry, wider } of NARROWER_THAN) { + for (const other of wider) { + const [narrow, broad] = [sizeOf(entry), sizeOf(other)]; + if (narrow < broad) continue; + failed = true; + console.error(`\n ${entry} loads ${narrow} modules, no fewer than ${other} (${broad}).`); + console.error(" A one-operation entry point that is as heavy as its client or barrel has no reason to exist.\n"); + } } if (failed) { console.error("Import graph budgets exceeded."); process.exit(1); } -console.log(`All ${BUDGETS.length} import graph budgets are within limits.`); +console.log( + `All ${BUDGETS.length} import graph budgets are within limits, and ${NARROWER_THAN.length} operation entry points are narrower than their clients and barrels.`, +); diff --git a/packages/hyperliquid/.dev/perf/entry_points.ts b/packages/hyperliquid/.dev/perf/entry_points.ts new file mode 100644 index 00000000..46ab1702 --- /dev/null +++ b/packages/hyperliquid/.dev/perf/entry_points.ts @@ -0,0 +1,180 @@ +/** + * One-operation entry points against their client and API barrel, on the published package. + * bun run build && bun .dev/perf/entry_points.ts [--samples 15] [--out /tmp/entry-points.json] + * + * For one representative operation per family (Info `allMids`, Exchange `order`, Subscription + * `allMids`), three consumers do the same job through the client, the API barrel and the + * operation's own entry point. Reported per consumer: + * - modules and evaluatedBytes: files Node evaluates for the consumer (its static import closure + * inside `dist/`, transport included) and their combined size; + * - minified and gzip bytes of an esbuild bundle of the consumer, dependencies external; + * - cold import median on Node and Bun: a fresh process that imports the entry point and its + * transport, timed from inside the process so runtime boot is excluded. + * + * Consumers alternate inside every round so machine drift hits each one equally. Numbers are + * wall-clock timings of the local machine, useful as ratios rather than absolutes. + * @module + */ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { gzipSync } from "node:zlib"; +import { build } from "esbuild"; + +const root = resolve(import.meta.dir, "../.."); +const dist = join(root, "dist"); +const samplesIndex = process.argv.indexOf("--samples"); +const samples = samplesIndex >= 0 ? Number(process.argv[samplesIndex + 1]) : 15; +const outIndex = process.argv.indexOf("--out"); + +/** One way to perform a family's representative operation. */ +interface Consumer { + family: string; + style: "client" | "barrel" | "operation"; + /** Subpath of the operation's module, after `@bloxwap/hyperliquid/`. */ + path: string; + binding: string; + /** Subpath of the transport the operation needs. */ + transport: string; + /** Statement that performs the operation with `transport` and `wallet` in scope. */ + call: string; +} + +const order = `{ orders: [{ a: 0, b: true, p: "1", s: "1", r: false, t: { limit: { tif: "Gtc" } } }], grouping: "na" }`; +const families = [ + { + family: "info", + transport: "transport/http", + client: ["api/info/client", "InfoClient", "await new InfoClient({ transport }).allMids();"], + operation: ["api/info/allMids", "allMids", "await allMids({ transport });"], + barrel: ["api/info", "allMids", "await allMids({ transport });"], + }, + { + family: "exchange", + transport: "transport/http", + client: [ + "api/exchange/client", + "ExchangeClient", + `await new ExchangeClient({ transport, wallet }).order(${order});`, + ], + operation: ["api/exchange/order", "order", `await order({ transport, wallet }, ${order});`], + barrel: ["api/exchange", "order", `await order({ transport, wallet }, ${order});`], + }, + { + family: "subscription", + transport: "transport/websocket", + client: [ + "api/subscription/client", + "SubscriptionClient", + "await new SubscriptionClient({ transport }).allMids(console.log);", + ], + operation: ["api/subscription/allMids", "allMids", "await allMids({ transport }, console.log);"], + barrel: ["api/subscription", "allMids", "await allMids({ transport }, console.log);"], + }, +] as const; +const consumers: Consumer[] = families.flatMap(({ family, transport, ...styles }) => + (["client", "barrel", "operation"] as const).map((style) => { + const [path, binding, call] = styles[style]; + return { family, style, path, binding, transport, call }; + }), +); + +/** Files Node evaluates for `entries` (their static import closure inside `dist/`) and their total size. */ +async function evaluated(...entries: string[]): Promise<{ modules: number; bytes: number }> { + const pending = [...entries]; + const seen = new Set(); + let bytes = 0; + while (pending.length > 0) { + const file = pending.pop()!; + if (seen.has(file)) continue; + seen.add(file); + const code = await Bun.file(file).text(); + bytes += Buffer.byteLength(code); + for (const match of code.matchAll(/\b(?:from\s*|import\s*)"(\.{1,2}\/[^"\n]+)"/g)) { + pending.push(resolve(dirname(file), match[1])); + } + } + return { modules: seen.size, bytes }; +} + +const manifest = (await Bun.file(join(dist, "package.json")).json()).exports as Record< + string, + { default: string } | null +>; + +/** `dist/` file an export subpath resolves to, following the published manifest's patterns. */ +function distFile(subpath: string): string { + const explicit = manifest[`./${subpath}`]; + if (explicit) return join(dist, explicit.default); + const [, family, name] = subpath.match(/^api\/([^/]+)\/([^/]+)$/)!; + return join(dist, "api", family, "_methods", `${name}.js`); +} + +const consumerDir = await mkdtemp(join(tmpdir(), "hl-entry-points-")); +try { + await mkdir(join(consumerDir, "node_modules", "@bloxwap"), { recursive: true }); + await symlink(dist, join(consumerDir, "node_modules", "@bloxwap", "hyperliquid"), "dir"); + await writeFile(join(consumerDir, "package.json"), '{"type":"module"}\n'); + + const rows = new Map>(); + for (const consumer of consumers) { + const transportClass = consumer.transport === "transport/http" ? "HttpTransport" : "WebSocketTransport"; + const code = `import { ${consumer.binding} } from "@bloxwap/hyperliquid/${consumer.path}"; + import { ${transportClass} } from "@bloxwap/hyperliquid/${consumer.transport}"; + const transport = new ${transportClass}(); + const wallet = globalThis.wallet; + ${consumer.call}`; + const bundle = await build({ + stdin: { contents: code, resolveDir: consumerDir }, + bundle: true, + write: false, + format: "esm", + platform: "neutral", + minify: true, + external: ["valibot", "@noble/hashes/*", "hash-wasm", "tiny-secp256k1"], + }); + const bytes = bundle.outputFiles[0].contents; + const { modules, bytes: evaluatedBytes } = await evaluated(distFile(consumer.path), distFile(consumer.transport)); + rows.set(consumer, { + family: consumer.family, + style: consumer.style, + entry: consumer.path, + modules, + evaluatedBytes, + minBytes: bytes.length, + gzipBytes: gzipSync(bytes).length, + }); + } + + for (const [runtime, label] of [ + ["node", "node"], + [process.execPath, "bun"], + ] as const) { + const times = new Map(consumers.map((consumer) => [consumer, [] as number[]])); + for (let round = 0; round < samples; round++) { + for (const consumer of consumers) { + const script = `const start = performance.now(); + await Promise.all([import("@bloxwap/hyperliquid/${consumer.path}"), import("@bloxwap/hyperliquid/${consumer.transport}")]); + console.log(performance.now() - start);`; + const child = Bun.spawn([runtime, "--input-type=module", "-e", script], { + cwd: consumerDir, + stderr: "inherit", + }); + const [output, exit] = await Promise.all([new Response(child.stdout).text(), child.exited]); + if (exit !== 0) throw new Error(`Importing ${consumer.path} failed under ${label}`); + times.get(consumer)!.push(Number(output)); + } + } + for (const [consumer, rounds] of times) { + const sorted = [...rounds].sort((a, b) => a - b); + rows.get(consumer)![`${label}Ms`] = Number(sorted[Math.floor(sorted.length / 2)].toFixed(2)); + } + } + + const results = [...rows.values()]; + console.table(results); + const report = { node: process.versions.node, bun: Bun.version, samples, results }; + if (outIndex >= 0) await writeFile(process.argv[outIndex + 1], `${JSON.stringify(report, null, 2)}\n`); +} finally { + await rm(consumerDir, { recursive: true, force: true }); +}