feat(coding-agent): bundle Node runtime (#8474)

This commit is contained in:
Armin Ronacher
2026-08-22 21:19:38 +02:00
committed by GitHub
parent c49906ec77
commit 7d4c0e05dd
15 changed files with 397 additions and 92 deletions
+1 -1
View File
@@ -5511,7 +5511,7 @@
"yaml": "2.9.0"
},
"bin": {
"pi": "dist/cli.js"
"pi": "dist/bundle/cli.js"
},
"devDependencies": {
"@types/cross-spawn": "6.0.6",
+1
View File
@@ -8,6 +8,7 @@
### Changed
- Changed the Node.js CLI and RPC entrypoints to load a bundled runtime, reducing startup filesystem reads while keeping the public library and legacy module paths on the modular runtime for normal dependency identity.
- Changed session sharing to render clickable terminal links and Radius shares to display only the artifact's canonical URL.
### Fixed
+1 -1
View File
@@ -530,7 +530,7 @@
"@mariozechner/clipboard": "0.3.9"
},
"bin": {
"pi": "dist/cli.js"
"pi": "dist/bundle/cli.js"
},
"engines": {
"node": ">=22.19.0"
+1 -1
View File
@@ -35,7 +35,7 @@
"@mariozechner/clipboard": "0.3.9"
},
"bin": {
"pi": "dist/cli.js"
"pi": "dist/bundle/cli.js"
},
"engines": {
"node": ">=22.19.0"
+5 -4
View File
@@ -7,7 +7,7 @@
"configDir": ".pi"
},
"bin": {
"pi": "dist/cli.js"
"pi": "dist/bundle/cli.js"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -17,7 +17,7 @@
"import": "./dist/index.js"
},
"./rpc-entry": {
"import": "./dist/rpc-entry.js"
"import": "./dist/bundle/rpc-entry.js"
},
"./client": {
"types": "./dist/client/index.d.ts",
@@ -34,8 +34,9 @@
],
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets",
"build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets",
"build": "npm run build:unbundled && node ../../scripts/build-coding-agent-bundle.mjs",
"build:unbundled": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets",
"build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./src/bun/cli.ts ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets",
"copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
"copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/",
"test": "vitest --run",
+20 -12
View File
@@ -362,9 +362,26 @@ export function getUpdateInstruction(packageName: string): string {
/**
* Get the base directory for resolving package assets (themes, package.json, README.md, CHANGELOG.md).
* - For Bun binary: returns the directory containing the executable
* - For Node.js (dist/): returns __dirname (the dist/ directory)
* - For tsx (src/): returns parent directory (the package root)
* - For Node.js and tsx: returns the package root containing package.json
* - Ignores Bun binary metadata copied into dist/ when the package root is available
*/
export function findNodePackageDir(startDir: string): string {
let dir = startDir;
while (dir !== dirname(dir)) {
if (existsSync(join(dir, "package.json"))) {
const parent = dirname(dir);
// build:binary places Bun's metadata inside dist/. Node still needs the
// package root so its dist-relative asset paths do not become dist/dist/.
if (basename(dir) === "dist" && existsSync(join(parent, "package.json"))) {
return parent;
}
return dir;
}
dir = dirname(dir);
}
return startDir;
}
export function getPackageDir(): string {
// Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly)
const envDir = process.env.PI_PACKAGE_DIR;
@@ -376,16 +393,7 @@ export function getPackageDir(): string {
// Bun binary: process.execPath points to the compiled executable
return dirname(process.execPath);
}
// Node.js: walk up from __dirname until we find package.json
let dir = __dirname;
while (dir !== dirname(dir)) {
if (existsSync(join(dir, "package.json"))) {
return dir;
}
dir = dirname(dir);
}
// Fallback (shouldn't happen)
return __dirname;
return findNodePackageDir(__dirname);
}
/**
@@ -78,6 +78,8 @@ const require = createRequire(import.meta.url);
const isNodeSeaBinary =
("sea" in process.features && process.features.sea === true) ||
process.getBuiltinModule("node:sea")?.isSea() === true;
declare const PI_BUNDLED_NODE: boolean;
const isBundledNode = typeof PI_BUNDLED_NODE !== "undefined" && PI_BUNDLED_NODE;
const isTypeScriptSourceRuntime = !isBunBinary && path.extname(fileURLToPath(import.meta.url)) === ".ts";
/**
@@ -451,9 +453,10 @@ async function loadExtensionModule(extensionPath: string, cacheToken?: Extension
const jiti = createJiti(import.meta.url, {
moduleCache: false,
// Compiled binaries use modules embedded in the executable. Source TypeScript
// reuses host modules and root tsconfig paths. Built Node uses dist aliases.
...(isBunBinary || isNodeSeaBinary
// Compiled binaries and the bundled Node distribution use embedded modules.
// Source TypeScript reuses host modules and root tsconfig paths. Unbundled
// Node builds use dist aliases.
...(isBunBinary || isNodeSeaBinary || isBundledNode
? { virtualModules: VIRTUAL_MODULES, tryNative: false }
: isTypeScriptSourceRuntime
? { virtualModules: VIRTUAL_MODULES, tsconfigPaths: true }
+14
View File
@@ -4,6 +4,7 @@ import { delimiter, join } from "path";
import { afterEach, describe, expect, test } from "vitest";
import {
detectInstallMethod,
findNodePackageDir,
getSelfUpdateCommand,
getSelfUpdateUnavailableInstruction,
getUpdateInstruction,
@@ -145,6 +146,19 @@ function createFakeBunScript(bunBin: string): string {
return `#!/bin/sh\nif [ "$1" = "pm" ] && [ "$2" = "bin" ] && [ "$3" = "-g" ]; then\n\tprintf '%s\\n' '${escapedBunBin}'\n\texit 0\nfi\nexit 1\n`;
}
describe("findNodePackageDir", () => {
test("skips binary metadata copied into dist", () => {
tempDir = mkdtempSync(join(tmpdir(), "pi-package-dir-"));
const distDir = join(tempDir, "dist");
const bundleDir = join(distDir, "bundle");
mkdirSync(bundleDir, { recursive: true });
writeFileSync(join(tempDir, "package.json"), "{}");
writeFileSync(join(distDir, "package.json"), "{}");
expect(findNodePackageDir(bundleDir)).toBe(tempDir);
});
});
describe("detectInstallMethod", () => {
test("detects pnpm from Windows .pnpm install paths", () => {
setExecPath(
@@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";
interface CodingAgentPackageJson {
bin: { pi: string };
main: string;
exports: {
".": { import: string; types: string };
"./client": { import: string; types: string };
"./rpc-entry": { import: string };
};
}
const packageJson = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as CodingAgentPackageJson;
describe("package distribution entrypoints", () => {
test("uses the bundle for executables and modular output for libraries", () => {
expect(packageJson.bin.pi).toBe("dist/bundle/cli.js");
expect(packageJson.main).toBe("./dist/index.js");
expect(packageJson.exports["."].import).toBe("./dist/index.js");
expect(packageJson.exports["./client"].import).toBe("./dist/client/index.js");
expect(packageJson.exports["./rpc-entry"].import).toBe("./dist/bundle/rpc-entry.js");
});
});
+2 -9
View File
@@ -1,6 +1,6 @@
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { getNativeModuleCandidates } from "./native-module-path.ts";
const cjsRequire = createRequire(import.meta.url);
@@ -33,14 +33,7 @@ function loadNativeModifiersHelper(): NativeModifiersHelper | undefined {
return undefined;
}
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.join(moduleDir, "..", nativePath),
path.join(moduleDir, nativePath),
path.join(path.dirname(process.execPath), nativePath),
];
for (const modulePath of candidates) {
for (const modulePath of getNativeModuleCandidates(nativePath)) {
try {
const helper = cjsRequire(modulePath) as unknown;
if (isNativeModifiersHelper(helper)) {
+31
View File
@@ -0,0 +1,31 @@
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const moduleRequire = createRequire(import.meta.url);
const TUI_PACKAGE_NAME = "@earendil-works/pi-tui";
export interface NativeModuleCandidateOptions {
moduleUrl?: string;
execPath?: string;
resolvePackage?: (specifier: string) => string;
}
export function getNativeModuleCandidates(nativePath: string, options: NativeModuleCandidateOptions = {}): string[] {
const moduleDir = dirname(fileURLToPath(options.moduleUrl ?? import.meta.url));
const candidates: string[] = [];
try {
const packageEntry = (options.resolvePackage ?? moduleRequire.resolve)(TUI_PACKAGE_NAME);
candidates.push(join(dirname(packageEntry), "..", nativePath));
} catch {
// Standalone binaries do not have an installed TUI package.
}
candidates.push(
join(moduleDir, "..", nativePath),
join(moduleDir, nativePath),
join(dirname(options.execPath ?? process.execPath), nativePath),
);
return Array.from(new Set(candidates));
}
+4 -10
View File
@@ -1,9 +1,9 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { setKittyProtocolActive } from "./keys.ts";
import { isNativeModifierPressed } from "./native-modifiers.ts";
import { getNativeModuleCandidates } from "./native-module-path.ts";
import { StdinBuffer } from "./stdin-buffer.ts";
const cjsRequire = createRequire(import.meta.url);
@@ -370,16 +370,10 @@ export class ProcessTerminal implements Terminal {
if (arch !== "x64" && arch !== "arm64") return;
// Dynamic require so non-Windows and bundled/browser paths never load the
// native helper. In the npm package native/ is next to dist/; in compiled
// binary archives native/ is copied next to the executable.
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
// native helper. Installed packages resolve it from pi-tui; standalone
// binaries resolve the copy next to the executable.
const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node");
const candidates = [
path.join(moduleDir, "..", nativePath),
path.join(moduleDir, nativePath),
path.join(path.dirname(process.execPath), nativePath),
];
for (const modulePath of candidates) {
for (const modulePath of getNativeModuleCandidates(nativePath)) {
try {
const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean };
helper.enableVirtualTerminalInput?.();
@@ -0,0 +1,45 @@
import assert from "node:assert";
import { dirname, join, resolve } from "node:path";
import { describe, it } from "node:test";
import { pathToFileURL } from "node:url";
import { getNativeModuleCandidates } from "../src/native-module-path.ts";
describe("getNativeModuleCandidates", () => {
it("resolves native helpers from the installed TUI package when the module is bundled elsewhere", () => {
const packageRoot = resolve("virtual", "node_modules", "@earendil-works", "pi-tui");
const bundledModule = resolve("virtual", "pi-coding-agent", "dist", "bundle", "chunks", "chunk.js");
const nativePath = join("native", "win32", "prebuilds", "win32-arm64", "win32-console-mode.node");
const candidates = getNativeModuleCandidates(nativePath, {
moduleUrl: pathToFileURL(bundledModule).href,
execPath: resolve("virtual", "node", "node.exe"),
resolvePackage: (specifier) => {
assert.equal(specifier, "@earendil-works/pi-tui");
return join(packageRoot, "dist", "index.js");
},
});
assert.equal(candidates[0], join(packageRoot, nativePath));
assert.ok(candidates.includes(join(dirname(bundledModule), "..", nativePath)));
});
it("keeps standalone binary fallbacks when the TUI package is unavailable", () => {
const bundledModule = resolve("virtual", "pi", "bundle", "chunks", "chunk.js");
const execPath = resolve("virtual", "pi", "pi.exe");
const nativePath = join("native", "darwin", "prebuilds", "darwin-arm64", "darwin-modifiers.node");
const candidates = getNativeModuleCandidates(nativePath, {
moduleUrl: pathToFileURL(bundledModule).href,
execPath,
resolvePackage: () => {
throw new Error("not installed");
},
});
assert.deepEqual(candidates, [
join(dirname(bundledModule), "..", nativePath),
join(dirname(bundledModule), nativePath),
join(dirname(execPath), nativePath),
]);
});
});
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env node
import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { isBuiltin } from "node:module";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
const codingAgentDir = join(repoRoot, "packages", "coding-agent");
const aiDistDir = join(repoRoot, "packages", "ai", "dist");
const codingAgentDistDir = join(codingAgentDir, "dist");
const bundleDir = join(codingAgentDistDir, "bundle");
const banner = {
js: 'import { createRequire as __piCreateRequire } from "node:module"; const require = __piCreateRequire(import.meta.url);',
};
const allowedExternalPackages = new Set([
"@silvia-odwyer/photon-node",
// Optional native accelerators. Their callers fall back to JavaScript when absent.
"bufferutil",
"utf-8-validate",
// Optional debug output coloring.
"supports-color",
]);
function commonBuildOptions() {
return {
absWorkingDir: repoRoot,
banner,
bundle: true,
define: { PI_BUNDLED_NODE: "true" },
external: ["@silvia-odwyer/photon-node"],
format: "esm",
legalComments: "none",
logLevel: "warning",
metafile: true,
minifySyntax: true,
minifyWhitespace: true,
platform: "node",
sourcemap: false,
target: "node22.19",
// Do not apply the monorepo's source-oriented path aliases while bundling
// compiled output. Release builds must resolve the same package entries as
// an installed npm package.
tsconfigRaw: { compilerOptions: {} },
};
}
function validateExternalImports(metafiles) {
const unexpected = new Set();
for (const metafile of metafiles) {
for (const input of Object.values(metafile.inputs)) {
for (const imported of input.imports) {
if (!imported.external || isBuiltin(imported.path) || allowedExternalPackages.has(imported.path)) {
continue;
}
unexpected.add(imported.path);
}
}
}
if (unexpected.size > 0) {
throw new Error(`Bundle left unexpected external imports: ${Array.from(unexpected).sort().join(", ")}`);
}
}
function findContainingOutput(metafile, inputSuffix) {
const normalizedSuffix = inputSuffix.replaceAll("\\", "/");
for (const [outputPath, output] of Object.entries(metafile.outputs)) {
if (Object.keys(output.inputs).some((inputPath) => inputPath.replaceAll("\\", "/").endsWith(normalizedSuffix))) {
return resolve(repoRoot, outputPath);
}
}
throw new Error(`Could not locate bundled output containing ${inputSuffix}`);
}
function outputBytes(metafiles) {
return metafiles.reduce(
(total, metafile) => total + Object.values(metafile.outputs).reduce((subtotal, output) => subtotal + output.bytes, 0),
0,
);
}
for (const entry of [
join(codingAgentDistDir, "cli.js"),
join(codingAgentDistDir, "index.js"),
join(codingAgentDistDir, "rpc-entry.js"),
join(codingAgentDistDir, "client", "index.js"),
join(codingAgentDistDir, "utils", "image-resize-worker.js"),
join(aiDistDir, "api", "bedrock-converse-stream.js"),
join(aiDistDir, "auth", "oauth", "anthropic.js"),
]) {
if (!existsSync(entry)) {
throw new Error(`Bundle input is missing: ${relative(repoRoot, entry)}. Build the workspace packages first.`);
}
}
rmSync(bundleDir, { force: true, recursive: true });
mkdirSync(bundleDir, { recursive: true });
const mainResult = await build({
...commonBuildOptions(),
entryNames: "[name]",
entryPoints: {
cli: join(codingAgentDistDir, "cli.js"),
client: join(codingAgentDistDir, "client", "index.js"),
index: join(codingAgentDistDir, "index.js"),
"rpc-entry": join(codingAgentDistDir, "rpc-entry.js"),
},
outdir: bundleDir,
chunkNames: "chunks/[name]-[hash]",
splitting: true,
});
const bedrockLoaderOutput = findContainingOutput(mainResult.metafile, "packages/ai/dist/api/bedrock-converse-stream.lazy.js");
const oauthLoaderOutput = findContainingOutput(mainResult.metafile, "packages/ai/dist/auth/oauth/load.js");
const imageResizeOutput = findContainingOutput(mainResult.metafile, "packages/coding-agent/dist/utils/image-resize.js");
if (dirname(bedrockLoaderOutput) !== dirname(oauthLoaderOutput)) {
throw new Error("Bedrock and OAuth lazy loaders were emitted into different directories");
}
// These implementations are reached through variable-specifier imports or a
// worker URL, so the main bundle cannot follow them. Emit one self-contained
// file per implementation beside the code that resolves it.
const lazyResult = await build({
...commonBuildOptions(),
entryNames: "[name]",
entryPoints: {
anthropic: join(aiDistDir, "auth", "oauth", "anthropic.js"),
"bedrock-converse-stream": join(aiDistDir, "api", "bedrock-converse-stream.js"),
"github-copilot": join(aiDistDir, "auth", "oauth", "github-copilot.js"),
"image-resize-worker": join(codingAgentDistDir, "utils", "image-resize-worker.js"),
"kimi-coding": join(aiDistDir, "auth", "oauth", "kimi-coding.js"),
"openai-codex": join(aiDistDir, "auth", "oauth", "openai-codex.js"),
openrouter: join(aiDistDir, "auth", "oauth", "openrouter.js"),
radius: join(aiDistDir, "auth", "oauth", "radius.js"),
xai: join(aiDistDir, "auth", "oauth", "xai.js"),
},
outdir: dirname(bedrockLoaderOutput),
splitting: false,
});
const imageResizeWorkerOutput = resolve(dirname(bedrockLoaderOutput), "image-resize-worker.js");
if (dirname(imageResizeOutput) !== dirname(imageResizeWorkerOutput)) {
throw new Error("Image resize implementation and worker were emitted into different directories");
}
validateExternalImports([mainResult.metafile, lazyResult.metafile]);
chmodSync(join(bundleDir, "cli.js"), 0o755);
chmodSync(join(bundleDir, "rpc-entry.js"), 0o755);
const files = new Set([...Object.keys(mainResult.metafile.outputs), ...Object.keys(lazyResult.metafile.outputs)]).size;
const mib = outputBytes([mainResult.metafile, lazyResult.metafile]) / (1024 * 1024);
console.log(`Built ${relative(repoRoot, bundleDir)} (${files} files, ${mib.toFixed(1)} MiB)`);
+86 -51
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { spawn } from "node:child_process";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
@@ -9,6 +9,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const packageDir = join(repoRoot, "packages", "coding-agent");
const distCliPath = join(packageDir, "dist", "cli.js");
const bundledDistCliPath = join(packageDir, "dist", "bundle", "cli.js");
const srcCliPath = join(packageDir, "src", "cli.ts");
const defaultNodeProfileDir = join(repoRoot, "profiles-node");
const defaultBunProfileDir = join(repoRoot, "profiles-bun");
@@ -35,8 +36,9 @@ Options:
--runtime <name> node, bun, or auto (default: auto)
--agent-dir <dir> Use a specific PI_CODING_AGENT_DIR for the benchmark run
--isolated-agent-dir Use a fresh temporary agent dir instead of the normal one
--bundle Build and profile the bundled Node entrypoint instead of dist/cli.js
--no-offline Do not force PI_OFFLINE=1 / PI_SKIP_VERSION_CHECK=1
--skip-build Reuse the current dist/cli.js without rebuilding first (Node only)
--skip-build Reuse the selected build output without rebuilding first (Node only)
--cpu-profile Write CPU profiles for benchmark runs
--help Show this help
@@ -73,6 +75,7 @@ function parseMode(value) {
function parseArgs(argv) {
const options = {
mode: "tui",
bundle: false,
runs: 1,
warmup: 0,
profileDir: undefined,
@@ -103,6 +106,11 @@ function parseArgs(argv) {
continue;
}
if (arg === "--bundle") {
options.bundle = true;
continue;
}
if (arg === "--skip-build") {
options.build = false;
continue;
@@ -221,7 +229,7 @@ function parseStartupTimings(stderr) {
let inBlock = false;
for (const line of lines) {
if (line.includes("--- Startup Timings ---")) {
if (/^--- Startup Timings(?:: [^-]+)? ---$/.test(line.trim())) {
inBlock = true;
continue;
}
@@ -278,59 +286,72 @@ async function waitForExit(child, errorPrefix) {
});
}
async function runBuild() {
process.stdout.write("Building packages/tui, packages/telemetry, packages/ai, packages/agent, and packages/coding-agent...\n");
async function runBuild(bundle) {
process.stdout.write(
`Building dependencies and the ${bundle ? "bundled" : "unbundled"} coding-agent Node entrypoint...\n`,
);
const startedAt = performance.now();
const child = spawn(
"npm",
[
"run",
"build",
"--workspace",
"packages/tui",
"--workspace",
"packages/telemetry",
"--workspace",
"packages/ai",
"--workspace",
"packages/agent",
"--workspace",
"packages/coding-agent",
],
const commands = [
{
label: "Dependency build",
args: [
"run",
"build",
"--workspace",
"packages/tui",
"--workspace",
"packages/telemetry",
"--workspace",
"packages/ai",
"--workspace",
"packages/agent",
"--workspace",
"packages/protocol",
"--workspace",
"packages/client",
],
},
{
label: "Coding-agent build",
args: ["run", bundle ? "build" : "build:unbundled", "--workspace", "packages/coding-agent"],
},
];
for (const command of commands) {
const child = spawn("npm", command.args, {
cwd: repoRoot,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
shell: process.platform === "win32",
},
);
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
const exitCode = await waitForExit(child, "Build");
if (exitCode !== 0) {
if (stdout.trim()) {
process.stdout.write(`${stdout}${stdout.endsWith("\n") ? "" : "\n"}`);
const exitCode = await waitForExit(child, command.label);
if (exitCode !== 0) {
if (stdout.trim()) {
process.stdout.write(`${stdout}${stdout.endsWith("\n") ? "" : "\n"}`);
}
if (stderr.trim()) {
process.stderr.write(`${stderr}${stderr.endsWith("\n") ? "" : "\n"}`);
}
throw new Error(`${command.label} failed with exit code ${exitCode}`);
}
if (stderr.trim()) {
process.stderr.write(`${stderr}${stderr.endsWith("\n") ? "" : "\n"}`);
}
throw new Error(`Build failed with exit code ${exitCode}`);
}
process.stdout.write(`Build completed in ${formatMs(performance.now() - startedAt)}\n`);
}
function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) {
function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile, nodeEntryPath) {
const benchmarkArgs = ["--no-session"];
if (mode === "rpc") {
benchmarkArgs.push("--mode", "rpc");
@@ -352,7 +373,7 @@ function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) {
if (cpuProfile) {
args.push("--cpu-prof", `--cpu-prof-dir=${profileDir}`, `--cpu-prof-name=${profileName}`);
}
args.push(distCliPath, ...benchmarkArgs);
args.push(nodeEntryPath, ...benchmarkArgs);
return {
executable: process.execPath,
args,
@@ -360,7 +381,7 @@ function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) {
}
function createBenchmarkEnv(options, isolatedAgentDir) {
const env = { ...process.env };
const env = { ...process.env, PI_TIMING: "1" };
if (options.agentDir) {
env[agentDirEnvName] = options.agentDir;
} else if (isolatedAgentDir) {
@@ -386,11 +407,12 @@ async function runTuiBenchmarkRun({ runtime, runIndex, measuredIndex, options, p
mkdirSync(isolatedAgentDir, { recursive: true });
}
const command = getRuntimeCommand(runtime, "tui", profileDir, profileName, options.cpuProfile);
const nodeEntryPath = options.bundle ? bundledDistCliPath : distCliPath;
const command = getRuntimeCommand(runtime, "tui", profileDir, profileName, options.cpuProfile, nodeEntryPath);
const child = spawn(command.executable, command.args, {
cwd: packageDir,
env: createBenchmarkEnv(options, isolatedAgentDir),
stdio: ["inherit", "ignore", "pipe"],
stdio: ["inherit", "inherit", "pipe"],
shell: process.platform === "win32" && runtime === "bun",
});
@@ -445,7 +467,8 @@ async function runRpcBenchmarkRun({ runtime, runIndex, measuredIndex, options, p
mkdirSync(isolatedAgentDir, { recursive: true });
}
const command = getRuntimeCommand(runtime, "rpc", profileDir, profileName, options.cpuProfile);
const nodeEntryPath = options.bundle ? bundledDistCliPath : distCliPath;
const command = getRuntimeCommand(runtime, "rpc", profileDir, profileName, options.cpuProfile, nodeEntryPath);
const child = spawn(command.executable, command.args, {
cwd: packageDir,
env: createBenchmarkEnv(options, isolatedAgentDir),
@@ -547,11 +570,14 @@ async function main() {
}
const runtime = resolveRuntime(options.runtime);
if (options.bundle && runtime !== "node") {
throw new Error("--bundle only supports the Node runtime");
}
options.label = resolveLabel(options.mode, options.label);
const profileDir = resolveProfileDir(runtime, options.profileDir);
if (runtime === "node" && options.build) {
await runBuild();
await runBuild(options.bundle);
}
if (runtime === "bun") {
process.stdout.write(
@@ -559,7 +585,16 @@ async function main() {
);
}
const entryPath = runtime === "bun" ? srcCliPath : distCliPath;
const entryPath = runtime === "bun" ? srcCliPath : options.bundle ? bundledDistCliPath : distCliPath;
if (
runtime === "node" &&
!options.bundle &&
!options.build &&
existsSync(distCliPath) &&
readFileSync(distCliPath, "utf8").includes('import "./bundle/cli.js";')
) {
throw new Error("dist/cli.js is a bundled facade; rerun without --skip-build for an unbundled profile");
}
if (!existsSync(entryPath)) {
throw new Error(`CLI entrypoint not found: ${entryPath}`);
}
@@ -597,7 +632,7 @@ async function main() {
const maxElapsedRun = measuredRuns.reduce((slowest, run) => (run.elapsedMs > slowest.elapsedMs ? run : slowest));
if (measuredRuns.length === 1) {
process.stdout.write("\nResult\n");
process.stdout.write(` runtime: ${runtime}\n`);
process.stdout.write(` runtime: ${runtime}${options.bundle ? " (bundle)" : ""}\n`);
process.stdout.write(` mode: ${options.mode}\n`);
process.stdout.write(` elapsed: ${formatMs(measuredRuns[0].elapsedMs)}\n`);
for (const [label, summary] of timingSummaries.entries()) {
@@ -615,7 +650,7 @@ async function main() {
}
process.stdout.write("\nSummary\n");
process.stdout.write(` runtime: ${runtime}\n`);
process.stdout.write(` runtime: ${runtime}${options.bundle ? " (bundle)" : ""}\n`);
process.stdout.write(` mode: ${options.mode}\n`);
process.stdout.write(` elapsed min: ${formatMs(elapsedSummary.min)}\n`);
process.stdout.write(` elapsed median: ${formatMs(elapsedSummary.median)}\n`);