Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions apps/docs/content/docs/guides/tree-shaking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 });
Expand All @@ -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/<method>` (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/<family>/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/<family>`) | 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/<family>/<method>`) | 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/<method>`) 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.
112 changes: 95 additions & 17 deletions packages/hyperliquid/.dev/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------------------

Expand Down Expand Up @@ -139,8 +140,10 @@ async function bundleSources(root: RootManifest): Promise<number> {
"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",
Expand All @@ -150,7 +153,7 @@ async function bundleSources(root: RootManifest): Promise<number> {
const aggregators = new Map<string, string>();
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"
Expand Down Expand Up @@ -257,6 +260,7 @@ async function bundleSources(root: RootManifest): Promise<number> {
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<string>();
for (const output of outputs)
for (const file of Object.keys(output.metafile.outputs)) {
const absolute = resolve(ROOT_DIR, file);
Expand All @@ -274,27 +278,97 @@ async function bundleSources(root: RootManifest): Promise<number> {
// 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<void> {
const source = ts.createSourceFile(chunk, await Bun.file(chunk).text(), ts.ScriptTarget.ESNext);
const locals = new Set<string>();
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.
*
* A bundler that emits unlinkable ESM fails silently until a consumer imports the package — the `Bun.build` output
* 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<void> {
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<string>();
while (pending.length) {
Expand Down Expand Up @@ -437,26 +511,30 @@ async function verifyConsumer(root: RootManifest): Promise<void> {
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 });
Expand Down
2 changes: 2 additions & 0 deletions packages/hyperliquid/.dev/build/consumer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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)) {
Expand Down
16 changes: 15 additions & 1 deletion packages/hyperliquid/.dev/build/consumer.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,7 +17,17 @@ void ({} as ActionMetadata);
declare const config: ExchangeConfig;
const transport = new HttpTransport();
const mids: Record<string, string> = 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<string, string> = 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);
Expand Down
23 changes: 21 additions & 2 deletions packages/hyperliquid/.dev/export_sync_check.ts
Original file line number Diff line number Diff line change
@@ -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/<name>.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/<name>.ts` file has its `./api/<family>/<name>` export, and every public
* `actions/<name>.ts` builder its `./actions/<name>` 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.
*
Expand Down Expand Up @@ -412,6 +414,23 @@ async function main(): Promise<void> {
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
Expand Down
Loading
Loading