/** * Build entry point — configure + ninja exec. * * bun scripts/build.ts --profile=debug * bun scripts/build.ts --profile=release * bun scripts/build.ts --asan=off test foo.test.ts # override + build + run * bun scripts/build.ts --target tinycc # build one dep * bun scripts/build.ts --configure-only # emit ninja, don't run * bun scripts/build.ts -- --target=browser x.ts # `--` → rest to runtime * * Arg routing (see parseArgs): build flags first, then the FIRST arg that * isn't a recognized build/ninja flag starts exec-args — it and everything * after go to the built binary. `--` forces the cutoff. When exec-args are * present, build output is suppressed unless the build fails. * * -j/-k/-l/-v → ninja * --configure-only, --quiet, --help → here * --= or -- → here (profile/target/config override) * --= → error (typo check) * anything else → runtime */ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { canTraceOrderFile, downloadArtifacts, inheritOrderFile, isCI, mustGenerateOrderFile, orderFileContext, orderFileEligible, packageAndUpload, printEnvironment, regenerateOrderFile, reportOrderFileBootstrap, reportOrderFileCannotTrace, reportOrderFileFailure, shouldGenerateOrderFile, spawnWithAnnotations, startGroup, uploadArtifacts, verifyOrderFileApplied, } from "./build/ci.ts"; import { formatConfig, formatConfigUnchanged, type PartialConfig } from "./build/config.ts"; import { configure, type ConfigureInput, type ConfigureResult } from "./build/configure.ts"; import { BuildError } from "./build/error.ts"; import { STREAM_FD } from "./build/stream.ts"; import { interactive, nameColor, status } from "./build/tty.ts"; // ─────────────────────────────────────────────────────────────────────────── // Main // ─────────────────────────────────────────────────────────────────────────── async function main(): Promise { // Windows: re-exec inside the VS dev shell if not already there. // The shell provides PATH (mt.exe, rc.exe, cl.exe), INCLUDE, LIB, // WindowsSdkDir — things clang-cl can mostly self-detect but nested // cmake projects can't. Cheap: VSINSTALLDIR check short-circuits on // subsequent runs in the same terminal. if (process.platform === "win32" && !process.env.VSINSTALLDIR) { const vsShell = join(import.meta.dirname, "vs-shell.ps1"); const result = spawnSync( "pwsh", ["-NoProfile", "-NoLogo", "-File", vsShell, process.argv0, import.meta.filename, ...process.argv.slice(2)], { stdio: "inherit" }, ); if (result.error) { throw new BuildError(`Failed to spawn pwsh`, { cause: result.error, hint: "Is PowerShell 7+ (pwsh) installed?", }); } process.exit(result.status ?? 1); } const args = parseArgs(process.argv.slice(2)); // Skip on --configure-only / --config-file (ninja regen): those paths // return before spawning ninja, so the NO_PROXY mutation can't reach any // child and would be pure wasted wall-clock (up to 2s behind a // silent-drop firewall). if (!args.configureOnly) { await maybeBypassProxyForCratesIo(); } // Resolve ConfigureInput: either from --config-file (ninja's generator rule // replaying a previous configure) or from --profile + overrides (normal use). // We pass the *unresolved* {profile, overrides} pair through — configure() // expands the profile itself and persists the unresolved form, so editing // profiles.ts propagates to existing build dirs on the next regen instead // of being frozen at first-configure time. const input: ConfigureInput = args.configFile ? loadConfigFile(args.configFile) : { profile: args.profile, overrides: args.overrides }; const ninjaArgv = (cfg: { buildDir: string }) => ["-C", cfg.buildDir, ...args.ninjaArgs, ...args.ninjaTargets]; // GNU-style include-path vars (CPATH, C_INCLUDE_PATH, CPLUS_INCLUDE_PATH, // OBJC_INCLUDE_PATH) apply to every clang invocation regardless of // --target. The CI build containers set them for the *host* gcc toolchain // (.buildkite/Dockerfile), which hijacks & co. away from the MSVC // STL when cross-compiling for Windows ("'bits/c++config.h' file not // found"). Scrub them for Windows cross builds — they are host-targeted by // definition. Native Windows builds (INCLUDE/LIB from the VS dev shell) and // every other target keep the environment as provisioned. const ninjaEnv = (cfg: { windows: boolean; host: { os: string } }, env: Record) => { const merged: NodeJS.ProcessEnv = { ...process.env, ...env }; if (cfg.windows && cfg.host.os !== "windows") { for (const name of ["CPATH", "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "OBJC_INCLUDE_PATH"]) { delete merged[name]; } } return merged; }; if (isCI) { // CI: machine/env dump + collapsible groups + annotation-on-failure. printEnvironment(); const result = (await startGroup("Configure", () => configure(input))) as ConfigureResult; if (args.configureOnly) return; // link-only: download cpp-only + rust-only artifacts before ninja. if (result.cfg.buildkite && result.cfg.mode === "link-only") { await startGroup("Download artifacts", () => downloadArtifacts(result.cfg)); } // The order file is a link input, so it must land before the linking ninja // pass. In rust-and-link mode it runs between cargo and the build-cpp // poll (whose sleep loop yields cleanly) so it doesn't stall cargo. const orderCtx = orderFileContext(); const runInherit = () => (orderFileEligible(result.cfg, orderCtx) && !shouldGenerateOrderFile(result.cfg, orderCtx) ? inheritOrderFile(result.cfg, orderCtx) : Promise.resolve(false) ).catch(e => { console.log(`~ symbol order: inherit failed (${(e as Error)?.message ?? e}); linking unordered`); return false; }); let inherited = false; const runNinja = (targets: string[] = args.ninjaTargets) => spawnWithAnnotations("ninja", ["-C", result.cfg.buildDir, ...args.ninjaArgs, ...targets], { label: "ninja", env: ninjaEnv(result.cfg, result.env), }); // rust-and-link: build libbun_rust.a first so cargo overlaps with the // sibling build-cpp job, THEN poll for build-cpp's outcome + download // its archive, THEN link. link-only skips straight to the full build // (its artifacts were downloaded above). if (result.cfg.buildkite && result.cfg.mode === "rust-and-link") { await startGroup("Build Rust", () => runNinja(["bun-rust"])); inherited = (await startGroup("Inherit symbol order file", runInherit)) as boolean; await startGroup("Wait for build-cpp & download artifacts", () => downloadArtifacts(result.cfg)); } else { inherited = (await startGroup("Inherit symbol order file", runInherit)) as boolean; } await startGroup("Build", () => runNinja()); // Trace and relink when we are a release, when a commit asked for it, or when // there was nothing to inherit. A failed trace is not fatal: the order file is // an optimization, and a flaky workload must not kill a release 40 minutes in. if (mustGenerateOrderFile(result.cfg, orderCtx, inherited)) { if (!inherited && !shouldGenerateOrderFile(result.cfg, orderCtx)) reportOrderFileBootstrap(result.cfg); let traced = true; await startGroup("Generate symbol order file", () => { try { regenerateOrderFile(result.cfg, orderCtx); } catch (error) { traced = false; reportOrderFileFailure(error as Error); } }); if (traced) { await startGroup("Relink against symbol order file", runNinja); // We traced this exact binary: nearly every symbol must resolve. Hard-fail. if (result.output.exe) verifyOrderFileApplied(result.cfg, orderCtx, result.output.exe); } } else if (orderFileEligible(result.cfg, orderCtx) && result.output.exe) { // Inherited: a stale file is a slower binary, not a broken one. if (!inherited && !canTraceOrderFile(result.cfg)) reportOrderFileCannotTrace(result.cfg); verifyOrderFileApplied(result.cfg, orderCtx, result.output.exe, { strict: false }); } // cpp-only/rust-only: upload build outputs for downstream link-only. // link-only/rust-and-link: package + upload zips for downstream test steps. if (result.cfg.buildkite) { if (result.cfg.mode === "cpp-only" || result.cfg.mode === "rust-only") { await startGroup("Upload artifacts", () => uploadArtifacts(result.cfg, result.output)); } if ( result.cfg.mode === "link-only" || result.cfg.mode === "rust-and-link" || result.cfg.mode === "archive-link" ) { await startGroup("Package and upload", () => packageAndUpload(result.cfg, result.output)); } } } else { // Local: configure, then spawn ninja. const result = await configure(input); // Quiet one-liner when configure was a no-op — the full banner only // prints when build.ninja changed. Timing matters: a regression here // would otherwise be invisible. Suppressed for ninja's generator- // rule replay (--config-file) since ninja's [N/M] already says // "reconfigure" and doubling it is noise. // Quiet mode: suppress build output unless the build fails. Enabled by // --quiet or automatically when positionals are present (you want to see // your test output, not a wall of [N/M] lines above it). const quiet = args.quiet || args.execArgs.length > 0; // Configure summary. Full block only when build.ninja changed (new // profile/flags/sources) — a no-op reconfigure, which happens every // run, gets a one-liner. CI always full. Suppressed entirely in quiet // mode and during ninja's generator-rule replay (ninja's [N/M] already // says "reconfigure"). if (!quiet && !args.configFile) { if (result.changed || result.cfg.ci) { const o = result.output; process.stderr.write(formatConfig(result.cfg, result.exe) + "\n\n"); process.stderr.write( `${o.deps.length} deps, ${o.codegen?.all.length ?? 0} codegen, ${o.objects.length} objects in ${result.elapsed}ms\n\n`, ); } else { process.stderr.write(formatConfigUnchanged(result.exe, result.elapsed) + "\n"); } } if (args.configureOnly) { // Hint only for manual --configure-only, not generator replay. if (!args.configFile) { process.stderr.write(`run: ninja -C ${result.cfg.buildDir}\n`); } return; } // FD 3 sideband — only when interactive. stream.ts (wrapping deps + // cargo) writes live output there, bypassing ninja's per-job buffering. // A human watching a terminal wants to see cmake configure spew and // cargo build progress in real time. A log file (CI) doesn't — // that live output is noise (hundreds of `-- Looking for header.h` // lines from cmake). When FD 3 isn't set up, stream.ts falls back to // stdout which ninja buffers per-job: deps stay quiet until they // finish or fail, failure logs stay compact. // // Ninja's subprocess spawn only touches FDs 0-2; higher fds inherit // through posix_spawn/CreateProcessA. Passing our stderr fd (2) at // index STREAM_FD dups it there for the whole ninja process tree. // // In quiet mode, capture to buffers instead — dumped only on failure. const stdio: (number | "inherit" | "pipe")[] = quiet ? ["inherit", "pipe", "pipe"] : ["inherit", "inherit", "inherit"]; if (!quiet && interactive) { stdio[STREAM_FD] = 2; } const ninja = spawnSync("ninja", ninjaArgv(result.cfg), { stdio, env: ninjaEnv(result.cfg, result.env), // cargo's compile output (now part of the ninja graph via emitRust) can // be tens of MB on a cold build; the default 1 MB maxBuffer ENOBUFSes. maxBuffer: 1024 * 1024 * 1024, }); if (ninja.error) { process.stderr.write(`Failed to exec ninja: ${ninja.error.message}\nIs ninja in your PATH?\n`); process.exit(127); } if (ninja.status !== 0) { if (quiet) { if (ninja.stdout) process.stderr.write(ninja.stdout); if (ninja.stderr) process.stderr.write(ninja.stderr); } process.exit(ninja.status ?? 1); } if (args.execArgs.length === 0) { // Closing line on success: when restat prunes most of the graph // (local WebKit no-op shows `[1/555] build WebKit` then silence), // it's not obvious ninja finished vs. stalled. This disambiguates. // Targets named when explicit so it's clear what was actually built. const what = args.ninjaTargets.length > 0 ? ` ${args.ninjaTargets.map(t => nameColor(t)).join(", ")}` : ""; status(`[build]${what} done`); process.exit(0); } // Exec the built binary. result.output.exe is the linked (unstripped) // binary — bun-debug for debug, bun-profile for release. That's the one // you want for dev iteration (has symbols + assertions in debug). const exe = result.output.exe; if (exe === undefined) { throw new BuildError("Cannot exec: build mode produced no executable", { hint: `mode=${result.cfg.mode} builds artifacts, not a runnable binary. Drop the positional args or use --profile=debug.`, }); } const child = spawnSync(exe, args.execArgs, { stdio: "inherit" }); if (child.error) { throw new BuildError(`Failed to exec ${exe}`, { cause: child.error }); } // Signal death: re-raise so our parent sees the same signal (shells // show "Segmentation fault" etc. based on this, not exit code). if (child.signal) { process.kill(process.pid, child.signal); return; } process.exit(child.status ?? 0); } } /** * When an HTTP proxy is configured, cargo's `-Zbuild-std` (release lolhtml) * must reach crates.io. Some CI/corporate proxies 403 CONNECT to package * registries while direct egress is open. If a proxy is set and crates.io * isn't already exempted, probe direct connectivity once: if it works, add * crates.io to NO_PROXY so cargo goes direct. If the probe fails (mandatory- * egress-proxy topology, firewall drops direct), leave NO_PROXY untouched so * cargo keeps using the proxy. Runs once per build; propagates to all ninja * children via process.env. */ async function maybeBypassProxyForCratesIo(): Promise { const proxySet = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.https_proxy || process.env.http_proxy; if (!proxySet) return; // Both case variants are honoured by different tools; merge them so we // don't clobber one when writing the unified value back to both. const bypass = new Set(); for (const v of [process.env.NO_PROXY, process.env.no_proxy]) { for (const entry of (v ?? "").split(",")) { const t = entry.trim(); if (t) bypass.add(t); } } if (bypass.has("crates.io")) return; const { connect } = await import("node:net"); const directReachable = await new Promise(resolve => { const sock = connect({ host: "index.crates.io", port: 443, timeout: 2000 }); const done = (ok: boolean) => { sock.destroy(); resolve(ok); }; sock.once("connect", () => done(true)); sock.once("timeout", () => done(false)); sock.once("error", () => done(false)); }); if (!directReachable) return; for (const h of ["crates.io", "static.crates.io", "index.crates.io"]) bypass.add(h); const merged = [...bypass].join(","); process.env.NO_PROXY = merged; process.env.no_proxy = merged; } /** * Load a ConfigureInput from JSON (for ninja's generator rule replay). * * Current format: `{ profile?: string, overrides?: PartialConfig }`. * Legacy format (pre profile-name persistence): a flat PartialConfig — if we * see neither `profile` nor `overrides` keys, wrap the whole object as * overrides so old build dirs still regen. */ function loadConfigFile(path: string): ConfigureInput { let raw: Record; try { raw = JSON.parse(readFileSync(path, "utf8")) as Record; } catch (cause) { throw new BuildError(`Failed to load config file: ${path}`, { cause }); } if ("profile" in raw || "overrides" in raw) { return raw as ConfigureInput; } // Legacy flat PartialConfig. return { overrides: raw as PartialConfig }; } // ─────────────────────────────────────────────────────────────────────────── // CLI arg parsing // ─────────────────────────────────────────────────────────────────────────── interface CliArgs { profile: string; /** PartialConfig overrides from --= flags. */ overrides: PartialConfig; /** Explicit ninja targets from --target=X. Empty = use defaults. */ ninjaTargets: string[]; /** Just configure, don't run ninja. */ configureOnly: boolean; /** Suppress build output unless it fails. Also auto-enabled when execArgs present. */ quiet: boolean; /** Extra ninja args (e.g. -j8, -v). */ ninjaArgs: string[]; /** * Args to exec the built binary with. First bare positional and everything * after. Empty = just build, don't exec. */ execArgs: string[]; /** * Load PartialConfig from JSON (ninja's generator rule replay). * Mutually exclusive with --profile/overrides. */ configFile: string | undefined; } /** * Parse argv. Format: * --profile= Profile (required, no default here — caller picks) * --= Override any PartialConfig boolean/string field * --target= Build a specific ninja target (repeatable) * --configure-only Emit build.ninja, don't run it * -j / -v / -k Passed through to ninja * Exec the built binary with these args * * First bare positional ends flag parsing — everything after goes to the * built binary verbatim, so `build.ts test -t foo` passes `-t foo` to bun, * not to this parser. * * Boolean overrides accept: on/off, true/false, yes/no, 1/0. */ function parseArgs(argv: string[]): CliArgs { let profile = "debug"; const overrides: PartialConfig = {}; const ninjaTargets: string[] = []; const ninjaArgs: string[] = []; const execArgs: string[] = []; let configureOnly = false; let quiet = false; let configFile: string | undefined; let inExec = false; // PartialConfig fields that are BOOLEANS. Used for value coercion. // Not exhaustive — add as needed. Unknown -- is rejected so you // notice typos. const boolFields = new Set([ "lto", "asan", "assertions", "logs", "baseline", "canary", "staticSqlite", "staticLibatomic", "tinycc", "valgrind", "fuzzilli", "socketFaultInjection", "unifiedSources", "archiveDeps", "timeTrace", "ci", "buildkite", ]); // PartialConfig fields that are STRINGS. const stringFields = new Set([ "os", "arch", "abi", "buildType", "mode", "webkit", "localDeps", "buildDir", "cacheDir", "nodejsVersion", "nodejsAbiVersion", "webkitVersion", "pgoGenerate", "pgoUse", "androidNdk", "macosSdk", "osxDeploymentTarget", "winsysroot", "linuxSysroot", "freebsdSysroot", ]); for (let i = 0; i < argv.length; i++) { const arg = argv[i]!; if (inExec) { execArgs.push(arg); continue; } // Ninja passthrough: -j, -v, -k, -l. Short flags only — // anything starting with `--` is OURS. if (/^-[jklv]/.test(arg)) { ninjaArgs.push(arg); continue; } // `--` ends flag parsing — everything after goes to the built binary, // even args that would otherwise look like build flags. Use when a // runtime flag collides with one of ours (e.g. bun-debug's --target). if (arg === "--") { inExec = true; continue; } if (arg === "--configure-only") { configureOnly = true; continue; } if (arg === "--quiet") { quiet = true; continue; } if (arg === "--help" || arg === "-h") { process.stderr.write(USAGE); process.exit(0); } // --= or -- . Space form consumes next argv. // Unknown `--` with no value (e.g. `--watch`) falls through to // exec args — those are bun-debug flags, not ours. const eq = arg.match(/^--([a-zA-Z][a-zA-Z0-9-]*)(?:=(.*))?$/); if (!eq) { // Not a --flag at all: first bare positional ends flag parsing. // Everything after goes to the built binary verbatim. execArgs.push(arg); inExec = true; continue; } const rawKey = eq[1]!; const key = rawKey.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); const isOurs = key === "profile" || key === "target" || key === "configFile" || boolFields.has(key) || stringFields.has(key); let value = eq[2]; if (value === undefined) { // No `=`. If this is one of our flags, consume next arg as value. // If not (e.g. --print, --watch), it's a bun-debug flag → exec args. if (!isOurs) { execArgs.push(arg); inExec = true; continue; } value = argv[++i]; if (value === undefined) { throw new BuildError(`--${rawKey} requires a value`); } } if (key === "target") { ninjaTargets.push(value); continue; } if (key === "configFile") { configFile = value; configureOnly = true; continue; } if (key === "profile") { profile = value; } else if (boolFields.has(key)) { (overrides as Record)[key] = parseBool(value); } else if (stringFields.has(key)) { (overrides as Record)[key] = value; } else { throw new BuildError(`Unknown config field: --${rawKey}`, { hint: `Known fields: profile, target, ${[...boolFields, ...stringFields].sort().join(", ")}`, }); } } return { profile, overrides, ninjaTargets, ninjaArgs, execArgs, configureOnly, quiet, configFile }; } function parseBool(v: string): boolean { const lower = v.toLowerCase(); if (["on", "true", "yes", "1"].includes(lower)) return true; if (["off", "false", "no", "0"].includes(lower)) return false; throw new BuildError(`Invalid boolean value: ${v}`, { hint: "Use on/off, true/false, yes/no, or 1/0" }); } const USAGE = `\ Usage: bun scripts/build.ts [options] [exec-args...] Options: --profile= Build profile (default: debug) Profiles: debug, debug-local, debug-no-asan, release, release-local, release-asan, release-assertions, ci-*, windows-{x64,arm64}[-release] (cross-compile from a non-Windows host) --= Override a config field. Boolean fields take on/off/true/false/yes/no/1/0. Fields: asan, lto, assertions, logs, baseline, canary, valgrind, webkit (prebuilt|local), local-deps (name=path[,name=path] — build a vendored dep from a local checkout), buildDir, mode (full|cpp-only|link-only), unifiedSources, timeTrace, os, arch, abi, winsysroot (Windows cross-compile SDK root) --target= Build a specific ninja target (repeatable) --configure-only Emit build.ninja, don't run it -j, -v, -k Passed through to ninja --help Show this help Any bare positional and everything after is passed to the built binary: bun scripts/build.ts test foo.test.ts → builds, then runs ./build/debug/bun-debug test foo.test.ts Examples: bun scripts/build.ts --profile=debug bun scripts/build.ts --profile=release --lto=off bun scripts/build.ts test foo.test.ts bun scripts/build.ts --profile=debug-local run script.ts bun scripts/build.ts --local-deps=mimalloc=~/code/mimalloc test foo.test.ts bun scripts/build.ts --target=bun-rust bun scripts/build.ts --configure-only `; // Entry point — must run after all module-level declarations (USAGE) are // initialized, otherwise parseArgs hits a TDZ ReferenceError on --help. try { await main(); } catch (err) { if (err instanceof BuildError) { process.stderr.write(err.format()); process.exit(1); } throw err; }