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
38 changes: 38 additions & 0 deletions apps/docs/content/docs/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -770,3 +770,41 @@ local subscribers in arrival order. Each callback receives its own mutable asset
so changes made by one subscriber do not affect another. Unsubscribing or aborting suppresses queued deliveries;
listeners added later do not receive previously queued frames. A corrupt frame or throwing callback does not end
other listeners or stop subsequent updates. Separate transports keep separate decode queues.

## Canonical actions and explicit execution

Build an action once when its fields are stable, then choose when to sign and submit it:

```ts
import { ExchangeClient } from "@bloxwap/hyperliquid/api/exchange/client";
import { HttpTransport } from "@bloxwap/hyperliquid/transport/http";
import { buildOrder } from "@bloxwap/hyperliquid/actions/order";
import { privateKeyToAccount } from "viem/accounts";

const exchange = new ExchangeClient({
transport: new HttpTransport(),
wallet: privateKeyToAccount(process.env.HL_PRIVATE_KEY as `0x${string}`),
});
const action = buildOrder({
orders: [{ a: 0, b: true, p: "30000", s: "0.01", r: false, t: { limit: { tif: "Gtc" } } }],
});
const signed = await exchange.sign(action);
const result = await exchange.submit(signed);
// Or sign and submit in one coordinated call:
await exchange.execute(action);
```

Builders validate, normalize, fill defaults, and copy/freeze nested fields. They allocate no nonce and perform no
wallet or transport calls. The input remains owned by the caller. Reuse a built action while its fields remain valid;
resolve coin symbols to asset IDs before building. Time-dependent constraints such as scheduled cancellation are
checked when building, so rebuild those actions before reuse.
An explicit `buildNoop({ nonce })` retains that nonce rather than allocating a fresh one on reuse.

Signing consumes one nonce and produces an immutable signed request. Submission preserves the operation's response
type and does not sign again. Submit promptly: expiration, the protocol timestamp range, and the signer's 100-highest
nonce window still apply. Signed ownership and network checks are in-process; serialize for storage only if you intend
to use the legacy `submitPrepared` wire-payload API. Reconstructed canonical actions must be rebuilt through a builder.

The standalone `signAction`, `submitAction`, and `executeAction` functions in
`@bloxwap/hyperliquid/actions/execution` accept the same exchange config. Existing client methods and
`prepareRequest` / `submitPrepared` continue to work.
21 changes: 16 additions & 5 deletions apps/docs/content/docs/guides/tree-shaking.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ Each method accepts the same config as its client as the first argument:
Info methods use [`InfoClient`](../clients.md#info-endpoint) config:

```ts
import { HttpTransport } from "@bloxwap/hyperliquid";
import { allMids } from "@bloxwap/hyperliquid/api/info";
import { HttpTransport } from "@bloxwap/hyperliquid/transport/http";
import { allMids } from "@bloxwap/hyperliquid/api/info/allMids";

const transport = new HttpTransport();
const result = await allMids({ transport });
Expand All @@ -42,8 +42,8 @@ const result = await allMids({ transport });
Exchange methods use [`ExchangeClient`](../clients.md#exchange-endpoint) config:

```ts
import { HttpTransport } from "@bloxwap/hyperliquid";
import { order } from "@bloxwap/hyperliquid/api/exchange";
import { HttpTransport } from "@bloxwap/hyperliquid/transport/http";
import { order } from "@bloxwap/hyperliquid/api/exchange/order";
import { privateKeyToAccount } from "viem/accounts";

const transport = new HttpTransport();
Expand Down Expand Up @@ -80,9 +80,20 @@ const subscription = await allMids({ transport }, (data) => {
Explorer methods use [`ExplorerClient`](../clients.md#explorer-endpoint) config:

```ts
import { HttpTransport } from "@bloxwap/hyperliquid";
import { HttpTransport } from "@bloxwap/hyperliquid/transport/http";
import { blockDetails } from "@bloxwap/hyperliquid/api/explorer";

const transport = new HttpTransport();
const block = await blockDetails({ transport }, { height: 123 });
```

## One-operation entry points

Every public method is available at `@bloxwap/hyperliquid/api/<family>/<method>`, including its parameter and response
types. The four families are `info`, `exchange`, `explorer`, and `subscription`. Existing API barrels and client paths
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.

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.
251 changes: 234 additions & 17 deletions packages/hyperliquid/.dev/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
* @module
*/

import { copyFile, readdir, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { copyFile, cp, mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises";
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";

Expand Down Expand Up @@ -129,23 +130,157 @@ async function emitDeclarations(): Promise<void> {
* @throws If the bundler reports any error.
*/
async function bundleSources(root: RootManifest): Promise<number> {
const result = await esbuild({
entryPoints: Object.values(root.exports).map((target) => join(ROOT_DIR, target)),
// Per-operation entry points would force the client bundles into hundreds of tiny chunks.
// Bundle pure operation/schema code separately, but externalize every identity/state boundary
// to one shared core so mixed entry points keep instanceof, nonce, and action ownership intact.
const core = [
"_base.ts",
"api/_errors.ts",
"signing/mod.ts",
"signing/_canonicalize.ts",
"transport/_base.ts",
"api/exchange/_methods/_base/_shell.ts",
"api/exchange/_methods/_base/_nonce.ts",
"api/exchange/_methods/_base/execute.ts",
"actions/_canonical.ts",
"actions/execution.ts",
"api/subscription/_methods/fastAssetCtxs.ts",
];
const coreFiles = new Map<string, { group: string; exports: string }>();
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)
? "runtime"
: name === "signing/mod.ts"
? "signing"
: ["actions/_canonical.ts", "signing/_canonicalize.ts"].includes(name)
? "canonical"
: name === "api/subscription/_methods/fastAssetCtxs.ts"
? "subscriptions"
: "exchange";
const names = Object.keys(await import(file));
const aliases = names.map((value) => ({ value, alias: `m${index}_${value}` }));
const exports = aliases.map(({ value, alias }) => `${alias} as ${value}`).join(", ");
coreFiles.set(file, { group, exports });
aggregators.set(
group,
(aggregators.get(group) ?? "") +
`export { ${aliases.map(({ value, alias }) => `${value} as ${alias}`).join(", ")} } from ${JSON.stringify(file)};\n`,
);
}
const primary: string[] = [];
const narrow: string[] = [];
for (const target of new Set(Object.values(root.exports))) {
const file = join(ROOT_DIR, target);
const shared = coreFiles.get(file);
if (shared) {
const output = join(DIST_DIR, target.slice("./src/".length).replace(/\.ts$/, ".js"));
// Declarations already created the parent directories.
const specifier = pathRelative(dirname(output), join(DIST_DIR, "_core", `${shared.group}.js`));
await writeFile(
output,
`export { ${shared.exports} } from ${JSON.stringify(specifier.startsWith(".") ? specifier : `./${specifier}`)};\n`,
);
} else if (
/\/api\/[^/]+\/_methods\/[^/]+\.ts$/.test(file) ||
/\/actions\/(?!mod\.ts|orderBatcher\.ts)[^/]+\.ts$/.test(file)
) {
narrow.push(file);
} else primary.push(file);
}
const common = {
outbase: join(ROOT_DIR, "src"),
outdir: DIST_DIR,
tsconfig: BUILD_TSCONFIG,
bundle: true,
splitting: true,
format: "esm",
platform: "neutral",
format: "esm" as const,
platform: "neutral" as const,
target: "es2024",
packages: "external",
packages: "external" as const,
entryNames: "[dir]/[name]",
chunkNames: "_chunks/[name]-[hash]",
metafile: true,
logLevel: "warning",
});
return Object.keys(result.metafile.outputs).length;
// Keep readable identifiers while avoiding comment/whitespace parsing at startup.
minifyWhitespace: true,
metafile: true as const,
logLevel: "warning" as const,
};
const externalCore: import("esbuild").Plugin = {
name: "shared-sdk-core",
setup(builder): void {
builder.onResolve({ filter: /^\./ }, (args) => {
const resolved = resolve(args.resolveDir, args.path);
return coreFiles.has(resolved)
? { path: resolved, namespace: "sdk-core-proxy", sideEffects: false }
: undefined;
});
// Internal proxies give esbuild explicit names for external core exports, so
// overlapping wildcard exports remain unambiguous and proxies add no runtime files.
builder.onLoad({ filter: /.*/, namespace: "sdk-core-proxy" }, (args) => {
const shared = coreFiles.get(args.path)!;
return {
contents: `export { ${shared.exports} } from ${JSON.stringify(join(DIST_DIR, "_core", `${shared.group}.js`))};`,
loader: "js",
};
});
builder.onResolve({ filter: /.*/, namespace: "sdk-core-proxy" }, (args) => ({
path: args.path,
external: true,
sideEffects: false,
}));
},
};
const aggregateCore: import("esbuild").Plugin = {
name: "aggregate-sdk-core",
setup(builder): void {
builder.onResolve({ filter: /^sdk-core:/ }, (args) => ({
path: args.path.slice("sdk-core:".length),
namespace: "sdk-core",
}));
builder.onLoad({ filter: /.*/, namespace: "sdk-core" }, (args) => ({
contents: aggregators.get(args.path)!,
loader: "js",
resolveDir: ROOT_DIR,
}));
},
};
const outputs = [
await esbuild({
...common,
entryPoints: [
...primary.map((file) => ({ in: file, out: pathRelative(join(ROOT_DIR, "src"), file).replace(/\.ts$/, "") })),
...[...aggregators.keys()].map((group) => ({ in: `sdk-core:${group}`, out: `_core/${group}` })),
],
plugins: [aggregateCore],
outdir: DIST_DIR,
splitting: true,
chunkNames: "_chunks/[name]-[hash]",
}),
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;
for (const output of outputs)
for (const file of Object.keys(output.metafile.outputs)) {
const absolute = resolve(ROOT_DIR, file);
const code = await Bun.file(absolute).text();
const portable = code.replace(/(?:\bfrom\s*|\bimport\s*)"([^"\n]+)"/g, (match, specifier: string) => {
if (!specifier.startsWith(`${DIST_DIR}/_core/`)) return match;
const relative = pathRelative(dirname(absolute), specifier);
return match.replace(
JSON.stringify(specifier),
JSON.stringify(relative.startsWith(".") ? relative : `./${relative}`),
);
});
// The package promises sideEffects:false. esbuild's splitting emits bare chunk
// imports for evaluation ordering, even when that entry uses none of their bindings.
// 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;
});
if (isolated.includes(DIST_DIR)) throw new Error(`Non-portable SDK path in ${file}`);
if (isolated !== code) await writeFile(absolute, isolated);
count++;
}
return count;
}

/**
Expand All @@ -159,6 +294,24 @@ async function bundleSources(root: RootManifest): Promise<number> {
* @throws If 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"]) {
const pending = [join(DIST_DIR, entry)];
const seen = new Set<string>();
while (pending.length) {
const file = pending.pop()!;
if (seen.has(file)) continue;
seen.add(file);
const code = await Bun.file(file).text();
if (
/\b(?:function (?:signL1Action|createL1ActionHash)|class (?:ExchangeClient|SubscriptionClient))\b/.test(code)
) {
throw new Error(`Read-only ${entry} evaluates exchange/subscription/signing code through ${file}`);
}
for (const match of code.matchAll(/\b(?:from\s*|import\s*)"(\.{1,2}\/[^"\n]+)"/g)) {
pending.push(resolve(dirname(file), match[1]));
}
}
}
const expected: Record<string, string[]> = {};
for (const target of Object.values(root.exports)) {
const source = (await import(join(ROOT_DIR, target))) as Record<string, unknown>;
Expand Down Expand Up @@ -235,9 +388,24 @@ async function writeDistManifest(root: RootManifest): Promise<void> {
if (root[key] !== undefined) manifest[key] = root[key];
}
// ESM-only package: consumers resolve through `exports` alone, so no `main`/`module`/`types` fallbacks are emitted.
manifest.exports = Object.fromEntries(
Object.entries(root.exports).map(([subpath, target]) => [subpath, toEmittedConditions(target)]),
);
// Hundreds of repetitive conditions enlarge package.json and slow Node's package-scope
// lookup on every cold import. Source exports stay explicit and checked; published operation
// families use the same file layout through compact patterns. Private underscore paths stay closed.
const exports: Record<string, { types: string; default: string } | null> = {};
for (const [subpath, target] of Object.entries(root.exports)) {
if (target.includes("/_methods/") || subpath.startsWith("./actions/")) continue;
exports[subpath] = toEmittedConditions(target);
}
for (const family of ["info", "exchange", "explorer", "subscription"]) {
exports[`./api/${family}/*`] = {
types: `./api/${family}/_methods/*.d.ts`,
default: `./api/${family}/_methods/*.js`,
};
exports[`./api/${family}/_*`] = null;
}
exports["./actions/*"] = { types: "./actions/*.d.ts", default: "./actions/*.js" };
exports["./actions/_*"] = null;
manifest.exports = exports;

await writeFile(join(DIST_DIR, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`);
}
Expand All @@ -247,6 +415,54 @@ async function copyDocs(): Promise<void> {
await Promise.all(COPIED_FILES.map((name) => copyFile(join(ROOT_DIR, name), join(DIST_DIR, name))));
}

/** Check public runtime resolution, shared state, and type inference after relocating the package. */
async function verifyConsumer(root: RootManifest): Promise<void> {
const consumer = await mkdtemp(join(tmpdir(), "hl-published-consumer-"));
try {
const modules = join(consumer, "node_modules");
await mkdir(join(modules, "@bloxwap"), { recursive: true });
await cp(DIST_DIR, join(modules, root.name), { recursive: true });
for (const name of Object.keys(root.dependencies as Record<string, string>)) {
const target = join(modules, name);
await mkdir(dirname(target), { recursive: true });
await symlink(join(ROOT_DIR, "node_modules", name), target, "dir");
}
await writeFile(join(consumer, "package.json"), '{"type":"module"}\n');
const entries: Record<string, string[]> = {};
for (const [subpath, target] of Object.entries(root.exports)) {
const specifier = subpath === "." ? root.name : `${root.name}${subpath.slice(1)}`;
entries[specifier] = Object.keys(await import(join(ROOT_DIR, target))).sort();
}
await writeFile(join(consumer, "entries.json"), JSON.stringify(entries));
for (const name of ["consumer.ts", "consumer.mjs"]) {
await copyFile(join(ROOT_DIR, ".dev/build", name), join(consumer, name));
}
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",
],
];
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})`);
}
} finally {
await rm(consumer, { recursive: true, force: true });
}
}

// --- Entry point -------------------------------------------------------------

/**
Expand All @@ -265,6 +481,7 @@ export async function build(): Promise<void> {
const rewritten = await rewriteDeclarationExtensions(DIST_DIR);
await writeDistManifest(root);
await copyDocs();
await verifyConsumer(root);

console.log(
`Built ${root.name}@${root.version} into dist/ (${bundled} JavaScript files, ${rewritten} declaration files rewritten).`,
Expand Down
Loading
Loading