/** * Configure: resolve config → emit build.ninja. * * Separated from build.ts so configure can be called standalone (just * regenerate ninja without running the build) and so CI orchestration * can configure once then run specific targets. */ import { existsSync, globSync, mkdirSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { globAllSources } from "../glob-sources.ts"; import { type BunOutput, bunExeName, emitBun, shouldStrip, validateBunConfig } from "./bun.ts"; import { generateCargoConfig } from "./cargo-config.ts"; import { type Config, type OS, type PartialConfig, type Toolchain, detectHost, findRepoRoot, resolveConfig, } from "./config.ts"; import { BuildError } from "./error.ts"; import { orderFilePath, usesOrderFile } from "./flags.ts"; import { mkdirAll, writeIfChanged } from "./fs.ts"; import { ensureMacosSdk } from "./macos-sdk.ts"; import { Ninja } from "./ninja.ts"; import { getProfile } from "./profiles.ts"; import { registerAllRules } from "./rules.ts"; import { quote } from "./shell.ts"; import { findBun, findCargo, findMsvcLinker, findSystemTool, resolveLlvmToolchain } from "./tools.ts"; import { ensureWindowsSysroot } from "./winsysroot.ts"; import { checkWorkarounds } from "./workarounds.ts"; /** * Full toolchain discovery. Returns absolute paths to all required tools. * * `targetOs` (defaults to the host) decides which tool family is resolved — * a windows target needs the MSVC-style drivers (clang-cl, llvm-lib, * lld-link, llvm-rc) even from a linux/macOS host. * * Throws BuildError with a hint if a required tool is missing. Optional * tools (ccache, cargo if no rust deps needed) become `undefined`. */ export function resolveToolchain(targetOs?: OS): Toolchain { const host = detectHost(); const llvm = resolveLlvmToolchain(host.os, host.arch, targetOs ?? host.os); // cmake — required for nested dep builds. const cmake = findSystemTool("cmake", { required: true, hint: "Install cmake (>= 3.24)" }); if (cmake === undefined) throw new BuildError("unreachable: findSystemTool required=true returned undefined"); // cargo — required for lolhtml. Not found → build will fail at that dep // with a clear "install rust" hint. We don't hard-fail here because // someone might be testing a subset that doesn't need lolhtml. const rust = findCargo(host.os); // Windows: MSVC link.exe path (to prevent Git Bash's /usr/bin/link // shadowing). Only needed when cargo builds with the msvc target. const msvcLinker = host.os === "windows" ? findMsvcLinker(host.arch) : undefined; // esbuild path is relative to REPO ROOT, not process.cwd() — when // ninja's generator rule invokes reconfigure, cwd is the build dir. const repoRoot = findRepoRoot(); // esbuild — comes from the root bun install. Path is deterministic. // If not present, the first codegen build will fail with a clear error // (and the build itself runs `bun install` first via the root install // stamp, so this path will exist by the time esbuild rules fire). const esbuild = resolve(repoRoot, "node_modules", ".bin", host.os === "windows" ? "esbuild.exe" : "esbuild"); const bun = findBun(host.os); // jsRuntime: shell-ready prefix for running .ts subprocesses. Propagate // whatever's running us — if node, the strip-types flag comes along; if // bun, it's just the path. process.versions.bun distinguishes (undefined // in node). Pre-quoted so rule commands can splice it directly. const q = (p: string) => quote(p, host.os === "windows"); const jsRuntime = process.versions.bun !== undefined ? q(process.execPath) : `${q(process.execPath)} --experimental-strip-types`; return { ...llvm, cmake, bun, jsRuntime, esbuild, cargo: rust?.cargo, cargoHome: rust?.cargoHome, rustupHome: rust?.rustupHome, msvcLinker, }; } export interface ConfigureResult { cfg: Config; output: BunOutput; /** Build.ninja absolute path. */ ninjaFile: string; /** Env vars the caller should set before spawning ninja. */ env: Record; /** Wall-clock ms for the configure pass. */ elapsed: number; /** True if build.ninja actually changed (vs an idempotent re-run). */ changed: boolean; /** Final executable name (e.g. "bun-debug"). For status messages. */ exe: string; } /** * Files that, when changed, should trigger a reconfigure. Globbed at * configure time — if you add a new build script, it'll be picked up * on the next reconfigure (since adding a .ts usually means editing * an existing one to import it). * * Excludes runtime-only files (fetch-cli.ts, download.ts, ci.ts) and * runtime-only scripts — changes to those don't affect the build graph. */ function configureInputs(cwd: string): string[] { const buildDir = resolve(cwd, "scripts", "build"); const excluded = new Set(["fetch-cli.ts", "download.ts", "ci.ts", "stream.ts"]); const scripts = globSync("*.ts", { cwd: buildDir }) .filter(f => !excluded.has(f)) .map(f => resolve(buildDir, f)); const deps = globSync("deps/*.ts", { cwd: buildDir }).map(f => resolve(buildDir, f)); return [...scripts, ...deps, resolve(cwd, "scripts", "glob-sources.ts"), resolve(cwd, "package.json")].sort(); } /** * What the user asked for, *before* profile expansion. This is what gets * persisted to configure.json and replayed by ninja's generator rule. * * We persist the profile NAME (not its expanded values) so that editing * profiles.ts propagates to existing build dirs on the next regen. The old * scheme persisted the post-merge PartialConfig, which froze whatever the * profile said at first-configure time — a build dir created from * `--profile=release --build-dir=build/btg` would keep replaying * `lto:false` forever even after a `btg` profile with `lto:true` was added. */ export interface ConfigureInput { /** Profile name to resolve via getProfile(). Omitted = no profile base. */ profile?: string; /** Explicit CLI overrides layered on top of the profile. */ overrides?: PartialConfig; } /** * Emit the generator rule — makes build.ninja self-rebuilding. When you * run `ninja` directly and a build script has changed, ninja runs * reconfigure first, then restarts with the fresh graph. * * The *unresolved* ConfigureInput (profile name + CLI overrides) is * persisted to configure.json; the regen command reads it back via * --config-file and re-expands the profile against the current * profiles.ts. Edits to a profile therefore take effect on the next * `ninja` in an existing build dir without `rm -rf`. */ function emitGeneratorRule(n: Ninja, cfg: Config, input: ConfigureInput): void { const configFile = resolve(cfg.buildDir, "configure.json"); const buildScript = resolve(cfg.cwd, "scripts", "build.ts"); // Persist the unresolved input. writeIfChanged — same input → no mtime // bump → no unnecessary regen on identical reconfigures. // This runs before n.write() (which mkdir's), so ensure dir exists. mkdirSync(cfg.buildDir, { recursive: true }); writeIfChanged(configFile, JSON.stringify(input, null, 2) + "\n"); const hostWin = cfg.host.os === "windows"; n.rule("regen", { command: `${cfg.jsRuntime} ${quote(buildScript, hostWin)} --config-file=$in`, description: "reconfigure", // generator = 1: exempt from `ninja -t clean`, triggers manifest restart // when the output (build.ninja) is rebuilt. generator: true, // restat: configure uses writeIfChanged on build.ninja. If nothing // actually changed (unlikely when inputs changed, but possible for // cosmetic edits), no restart happens. restat: true, pool: "console", }); n.build({ outputs: [resolve(cfg.buildDir, "build.ninja")], rule: "regen", inputs: [configFile], implicitInputs: configureInputs(cfg.cwd), }); } /** * ccache environment to set for compile commands. Points ccache into * cfg.cacheDir (machine-shared locally, per-build in CI — see resolveConfig). */ function ccacheEnv(cfg: Config): Record { if (cfg.ccache === undefined) return {}; const env: Record = { CCACHE_DIR: resolve(cfg.cacheDir, "ccache"), // basedir + nohashdir: relativize paths in cache keys so the same // source at different checkout locations shares cache entries. CCACHE_BASEDIR: cfg.cwd, CCACHE_NOHASHDIR: "1", // Copy-on-write for cache entries — near-free on btrfs/APFS/ReFS. CCACHE_FILECLONE: "1", CCACHE_STATSLOG: resolve(cfg.buildDir, "ccache.log"), }; if (!cfg.ci) { env.CCACHE_MAXSIZE = "100G"; // Sloppiness: ignore differences that don't affect output. pch_defines: // PCH can change without the includer's -D list changing. time_macros: // __TIME__ differs every build. random_seed: -frandom-seed=0 is in our // flags but ccache doesn't know that. clang_index_store: clangd state. env.CCACHE_SLOPPINESS = "pch_defines,time_macros,locale,random_seed,clang_index_store,gcno_cwd"; } return env; } /** * Configure: resolve config → emit build.ninja. Returns the resolved config * and emitted build info. * * `input` is the profile name + explicit CLI overrides. The profile is * expanded here (not by the caller) so the generator rule can persist the * unresolved input and re-expand it on regen — see emitGeneratorRule. If * no buildDir is set, one is computed from the build type (build/debug, * build/release, etc). */ export async function configure(input: ConfigureInput): Promise { const start = performance.now(); const trace = process.env.BUN_BUILD_TRACE === "1"; const mark = (label: string) => { if (trace) process.stderr.write(` ${label}: ${Math.round(performance.now() - start)}ms\n`); }; // Expand profile → PartialConfig. Overrides win. const partial: PartialConfig = { ...(input.profile !== undefined ? getProfile(input.profile) : {}), ...(input.overrides ?? {}), }; // Guard: build/btg is reserved for the LTO bench profile. Configuring it // with any other profile (e.g. `--profile=release --build-dir=build/btg`, // or a legacy configure.json migrated to {profile:"release",overrides:{…}}) // persists lto:false and silently links the non-LTO WebKit prebuilt — the // bench suite then reports a phantom ~6-8% time / ~1 MB RSS "regression" // that is pure binary layout (.data.rel.ro vtables, outlined JSC slow- // paths), not src/ code. Fail loudly so the bench harness can't produce a // de-LTO'd comparison binary. See profiles.ts:btg. if ( partial.buildDir !== undefined && resolve(partial.buildDir) === resolve("build", "btg") && input.profile !== "btg" ) { throw new BuildError(`build/btg must be configured with --profile=btg (lto:true)`, { hint: `Got profile=${input.profile ?? ""}. Run \`bun run build:btg\` ` + `(or \`rm build/btg/configure.json\` first if regen is replaying a stale config).`, }); } const toolchain = resolveToolchain(partial.os); mark("resolveToolchain"); const cfg = resolveConfig(partial, toolchain); validateBunConfig(cfg); // Darwin cross-compile: the SDK must exist before ninja runs (every compile // edge passes -isysroot) and before checkWorkarounds() (the darwin-cross // workaround predicates inspect the SDK). resolveConfig picked the path; // this downloads the pinned SDK into the cache dir when nothing was found. // No-op otherwise. await ensureMacosSdk(cfg); mark("ensureMacosSdk"); checkWorkarounds(cfg); // Windows cross-compile: make sure the MSVC CRT + Windows SDK splat is // usable BEFORE the graph is emitted — emitBun() enumerates its include // dirs (llvm-rc's /I flags) at configure time, so the sysroot must exist // by then, not just before ninja runs. CI fetches a missing sysroot into // the per-build cache; local builds require a provisioned one (the fetch // would be a surprise multi-GB download) and only get the case-alias // fixup + completeness check. if (cfg.windows && cfg.host.os !== "windows") { await ensureWindowsSysroot(cfg); mark("ensureWindowsSysroot"); } // Generated `.cargo/config.toml` — written at configure time (not a ninja // rule), like `bun_dependency_versions.h`. Holds the per-target `linker = ` // (the discovered clang++ from `tools.ts`) so a contributor running `cargo` // directly / rust-analyzer use the same toolchain the ninja build does. generateCargoConfig(cfg); mark("generateCargoConfig"); // Perl check: LUT codegen (create-hash-table.ts) shells out to the // perl script from JSC. If perl is missing, codegen fails cryptically. // Check here so the error is at configure time with a clear hint. // rust-only/link-only don't run LUT codegen — skip the check so split-CI // steps don't require perl on the rust cross-compile box. if (cfg.mode === "full" || cfg.mode === "cpp-only" || cfg.mode === "archive-link") { if (findSystemTool("perl") === undefined) { throw new BuildError("perl not found in PATH", { hint: "LUT codegen (create-hash-table.ts) needs perl. Install it: apt install perl / brew install perl", }); } } mark("validate+perl"); // Glob all source lists — one pass, consistent filesystem snapshot. const sources = globAllSources(); mark("globAllSources"); // Emit ninja. const n = new Ninja({ buildDir: cfg.buildDir }); registerAllRules(n, cfg); emitGeneratorRule(n, cfg, input); const output = emitBun(n, cfg, sources); mark("emitBun"); // Default targets. cpp-only sets its own default inside emitBun (archive, // no smoke test). Full/link-only: `bun` phony (or stripped file) + `check`. // Release builds produce both bun-profile and stripped bun; `bun` is the // stripped one. Debug produces bun-debug; `bun` is a phony pointing at it. // dsym: darwin release only — pulled into defaults so ninja actually builds // it (no other node depends on it, and unlike cmake's POST_BUILD it doesn't // auto-trigger). if (output.exe !== undefined) { const defaultTarget = output.strippedExe !== undefined ? n.rel(output.strippedExe) : "bun"; const targets = [defaultTarget, "check"]; if (output.dsym !== undefined) targets.push(n.rel(output.dsym)); for (const stamp of output.uploadStamps ?? []) targets.push(n.rel(stamp)); n.default(targets); } // Write build.ninja (only if changed). const changed = await n.write(); mark("n.write"); // Pre-create all object file parent directories. Ninja doesn't mkdir; // CMake pre-creates CMakeFiles/.dir/* at generate time, we do // the same. Derived from output.objects so there's no hidden state — // the orchestrator already knows every .o path. mkdirAll(output.objects.map(dirname)); mark("mkdirAll"); // Seed an empty symbol ordering file so the link flag always points at // something. lld and Apple ld both treat an empty file as a no-op, which is // exactly the unordered pass-1 link; a later `generateOrderFile()` overwrites // it and ninja relinks (linkDepends lists it). Never clobber an existing one // — that would throw away the file a release relink or a canary download // just put there. if (usesOrderFile(cfg) && !existsSync(orderFilePath(cfg))) { writeIfChanged(orderFilePath(cfg), "# no order file yet — an empty file is a no-op for the linker\n"); } mark("orderFile"); const ninjaFile = resolve(cfg.buildDir, "build.ninja"); const elapsed = Math.round(performance.now() - start); const exe = bunExeName(cfg) + (shouldStrip(cfg) ? " → bun (stripped)" : ""); return { cfg, output, ninjaFile, env: ccacheEnv(cfg), elapsed, changed, exe }; }