1670 lines
69 KiB
TypeScript
1670 lines
69 KiB
TypeScript
/**
|
||
* Source acquisition and external-build orchestration for vendored dependencies.
|
||
*
|
||
* Three-step dance per dep, each a ninja `build` with `restat = 1`:
|
||
*
|
||
* 1. fetch: tarball → vendor/<name>/ (outputs: .ref stamp)
|
||
* 2. configure: cmake -B ... -D... (outputs: CMakeCache.txt)
|
||
* 3. build: cmake --build ... (outputs: .a files)
|
||
*
|
||
* restat means: if the output mtime is unchanged after the command (e.g. fetch
|
||
* was a no-op because .ref already matches), ninja prunes downstream. This is
|
||
* what makes incremental builds fast.
|
||
*
|
||
* Source lives in `vendor/<name>/` (gitignored). Build output lives in
|
||
* `buildDir/deps/<name>/`. This supports "local" dep mode where the user edits
|
||
* vendored source directly — the fetch step is skipped and no .ref is written.
|
||
*
|
||
* Tarballs are cached in `cacheDir/tarballs/<identity-hash>.tar.gz` so
|
||
* re-extraction after a failed patch doesn't re-download.
|
||
*/
|
||
|
||
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||
import { ar, cc, cxx, nasm } from "./compile.ts";
|
||
import type { BuildType, Config } from "./config.ts";
|
||
import { assert } from "./error.ts";
|
||
import { computeSourceIdentity, fetchCliPath } from "./fetch-cli.ts";
|
||
import { computeDepFlags } from "./flags.ts";
|
||
import { writeIfChanged } from "./fs.ts";
|
||
import type { Ninja } from "./ninja.ts";
|
||
import { quote, quoteArgs, slash } from "./shell.ts";
|
||
import { streamPath } from "./stream.ts";
|
||
|
||
/**
|
||
* If the source dir exists with a stale (or missing) identity stamp,
|
||
* delete it. Called at configure time so ninja's startup stat sees the
|
||
* headers as missing — correctly marking dependent .o files dirty.
|
||
*
|
||
* See emitFetch() comment for the full why.
|
||
*
|
||
* Only called for github-archive deps (via emitFetch). Local-mode deps
|
||
* (WebKit) never go through here — their source is user-managed, we
|
||
* never touch vendor/WebKit/. Identity is commit + patch-content, NOT
|
||
* disk content, so hand-edits to vendor/<dep>/*.c are preserved (identity
|
||
* still matches, no wipe).
|
||
*/
|
||
function invalidateStaleSource(srcDir: string, refStamp: string, commit: string, patchPaths: string[]): void {
|
||
if (!existsSync(srcDir)) return;
|
||
|
||
const patchContents = patchPaths.map(p => {
|
||
try {
|
||
return readFileSync(p, "utf8");
|
||
} catch {
|
||
// Missing patch → identity won't match → wipe. The fetch will
|
||
// fail later with a clearer "patch file not found" error.
|
||
return "<missing>";
|
||
}
|
||
});
|
||
const expected = computeSourceIdentity(commit, patchContents);
|
||
|
||
let current = "";
|
||
try {
|
||
current = readFileSync(refStamp, "utf8").trim();
|
||
} catch {
|
||
// .ref missing but srcDir exists — can't verify what's there. Could
|
||
// be stale from a previous commit, could be a manual rm. Either way:
|
||
// untrusted, wipe.
|
||
}
|
||
|
||
if (current !== expected) {
|
||
rmSync(srcDir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// Types
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Where a dependency's source comes from.
|
||
*/
|
||
export type Source =
|
||
| {
|
||
kind: "github-archive";
|
||
/** "owner/repo" */
|
||
repo: string;
|
||
/**
|
||
* Commit sha or tag. Both work for github archive URLs
|
||
* (`/archive/<ref>.tar.gz`). Prefer commit shas — tags can move,
|
||
* breaking the identity hash. If upstream only publishes tags
|
||
* (e.g. brotli `v1.1.0`), fine, but be aware a retag will silently
|
||
* change what we fetch.
|
||
*/
|
||
commit: string;
|
||
}
|
||
| {
|
||
/**
|
||
* Source is edited directly in vendor/<name>/. No fetch, no .ref stamp.
|
||
* The user is responsible for putting the source there.
|
||
*/
|
||
kind: "local";
|
||
/**
|
||
* Absolute path to the source. Defaults to vendor/<name>/. Override
|
||
* when the source lives outside the worktree (e.g. a shared WebKit
|
||
* clone reused across worktrees).
|
||
*/
|
||
path?: string;
|
||
/** Custom hint for the "source not found" error. */
|
||
hint?: string;
|
||
}
|
||
| {
|
||
/**
|
||
* Source lives in the bun repo itself, not vendor/. Used for sqlite
|
||
* (src/jsc/bindings/sqlite/). The path IS the source dir — no fetch,
|
||
* build output still goes to buildDir/deps/<name>/.
|
||
*/
|
||
kind: "in-tree";
|
||
/** Path relative to repo root. */
|
||
path: string;
|
||
}
|
||
| {
|
||
/**
|
||
* Pre-compiled binaries from a release tarball. No build step —
|
||
* download + extract IS the acquisition. Use with `build: {kind:"none"}`.
|
||
*
|
||
* Currently only WebKit uses this. Other deps may migrate here if/when
|
||
* we ship prebuilt .a files for them.
|
||
*
|
||
* Identity check: we write a `.identity` stamp file after successful
|
||
* extraction. If it matches on next fetch, skip download (restat prunes).
|
||
* Simpler than WebKit's CMake approach (check package.json contents) —
|
||
* we control the stamp, so we use a consistent mechanism.
|
||
*/
|
||
kind: "prebuilt";
|
||
/** Download URL. Typically a GitHub release asset. */
|
||
url: string;
|
||
/**
|
||
* Identity string for the stamp. Changing this triggers re-download.
|
||
* Usually a version sha or a hash of (version + config flags that
|
||
* affect which tarball you need).
|
||
*/
|
||
identity: string;
|
||
/**
|
||
* Paths to delete (relative to destDir) after extraction. WebKit
|
||
* deletes `include/unicode` on macOS (conflicts with system ICU
|
||
* headers); nodejs-headers deletes openssl/uv (conflict with
|
||
* BoringSSL/our libuv). Most deps won't need it.
|
||
*
|
||
* Paths, not a shell command — cross-platform via fs.rm, no quoting
|
||
* through ninja.
|
||
*/
|
||
rmAfterExtract?: string[];
|
||
/**
|
||
* Where extracted files land. Default: `vendor/<name>/`. Prebuilt deps
|
||
* (WebKit, nodejs-headers) override to `cacheDir/<name>-<version>/`.
|
||
*/
|
||
destDir?: string;
|
||
};
|
||
|
||
/**
|
||
* How to build a dependency once its source is available.
|
||
*/
|
||
export type BuildSpec =
|
||
| NestedCmakeBuild
|
||
| CargoBuild
|
||
| DirectBuild
|
||
| {
|
||
/** No build step — headers-only or prebuilt binaries. */
|
||
kind: "none";
|
||
};
|
||
|
||
/** A source file with extra per-file flags (e.g. SIMD `-mavx2`). */
|
||
export interface DirectSource {
|
||
path: string;
|
||
cflags: string[];
|
||
}
|
||
|
||
/** A header derived from a template in the source tree. */
|
||
export interface HeaderSubst {
|
||
/** Template path relative to srcDir (e.g. "zlib.h.in"). */
|
||
from: string;
|
||
/**
|
||
* Literal replacements applied via `String.split(from).join(to)` — no
|
||
* regex. cmake's `configure_file(@ONLY)` is exactly this: each `@VAR@`
|
||
* token swaps for a fixed string. Order is as given.
|
||
*/
|
||
replace?: Array<[from: string, to: string]>;
|
||
}
|
||
|
||
/**
|
||
* Compile sources directly into our ninja graph — no cmake/cargo sub-process.
|
||
*
|
||
* Each source becomes a `cc`/`cxx`/`nasm` build edge; outputs are archived
|
||
* into `buildDir/deps/<name>/lib<name>.a`. Flags are the same globals that
|
||
* nested-cmake deps get (computeDepFlags) so ASAN/optimization/target stay
|
||
* consistent.
|
||
*/
|
||
export interface DirectBuild {
|
||
kind: "direct";
|
||
/**
|
||
* C/.S sources relative to srcDir. A bare string compiles with the dep's
|
||
* shared flags; the object form appends per-file cflags (used for SIMD
|
||
* kernels that need `-m<isa>` while the rest of the dep does not).
|
||
*/
|
||
sources: Array<string | DirectSource>;
|
||
/**
|
||
* Compile sources as C++ even when they're .c files. Uses cxxflags from
|
||
* computeDepFlags and prepends `-x c++`. Mimalloc needs this — its public
|
||
* headers are read by both C++ TUs and the allocator implementation, and
|
||
* C/C++ can disagree on struct layout for trailing flexible arrays.
|
||
*/
|
||
lang?: "c" | "cxx";
|
||
/**
|
||
* Same semantics as NestedCmakeBuild.pic. true → -fPIC; false (default)
|
||
* → on darwin add -fno-pic -fno-pie to undo apple-clang's PIC default,
|
||
* elsewhere nothing. Windows is a no-op either way.
|
||
*/
|
||
pic?: boolean;
|
||
/**
|
||
* Preprocessor defines. Value type controls the emitted form:
|
||
* true → -DNAME
|
||
* number → -DNAME=42
|
||
* string → -DNAME=\"value\" (shell-quoted C string literal)
|
||
* The shell escaping is handled here; callers pass plain strings.
|
||
*/
|
||
defines?: Record<string, string | number | true>;
|
||
/** Extra C flags beyond computeDepFlags globals. */
|
||
cflags?: string[];
|
||
/** Flags for `.asm` sources (nasm). Separate because nasm doesn't share clang's argv shape. */
|
||
nasmflags?: string[];
|
||
/** Include dirs relative to srcDir (no -I prefix). "." for the root. */
|
||
includes?: string[];
|
||
/**
|
||
* Headers written to buildDir/deps/<name>/. Key is the output filename;
|
||
* buildDir is added to -I so sources find them. Two value forms:
|
||
*
|
||
* string Literal contents written at configure time. For
|
||
* autotools-style `#include "config.h"` where we
|
||
* hand-write the answers instead of probing.
|
||
*
|
||
* HeaderSubst Derived from a template in srcDir at build time
|
||
* (ninja edge). For *.h.in files where the upstream
|
||
* header is too large to inline but the substitution
|
||
* is trivial.
|
||
*/
|
||
headers?: Record<string, string | HeaderSubst>;
|
||
/**
|
||
* Build-time host tool that generates headers the library sources
|
||
* include. Compiled WITHOUT sanitizers — it runs once on the build
|
||
* machine and gets discarded, so sanitizer coverage is useless and
|
||
* risks compiler-rt/OS incompatibility (macOS 26.4 ASAN deadlock,
|
||
* Linux ASLR/shadow-map collision).
|
||
*/
|
||
codegen?: DirectCodegen;
|
||
}
|
||
|
||
export interface DirectCodegen {
|
||
/** Tool source relative to srcDir. Compiled+linked to a host executable. */
|
||
tool: string;
|
||
/** Defines for the tool only. Same typing rules as DirectBuild.defines. */
|
||
toolDefines?: Record<string, string | number | true>;
|
||
/**
|
||
* Argv for the tool. "$out" is replaced with the output path; everything
|
||
* else is resolved relative to srcDir. Tool runs with srcDir as cwd.
|
||
*/
|
||
args: string[];
|
||
/** Generated output relative to buildDir/deps/<name>/. */
|
||
output: string;
|
||
}
|
||
|
||
export interface NestedCmakeBuild {
|
||
kind: "nested-cmake";
|
||
/**
|
||
* CMake targets to build (cmake --build --target X --target Y).
|
||
* If unspecified, the lib names from `provides.libs` are used as targets
|
||
* (most deps name their target the same as the output library).
|
||
*/
|
||
targets?: string[];
|
||
/**
|
||
* Extra cmake -D args (beyond the toolchain/flag forwarding we do
|
||
* automatically). Just the args, no -D prefix — we add it.
|
||
*/
|
||
args: Record<string, string>;
|
||
/**
|
||
* Extra C flags appended to CMAKE_C_FLAGS for this dep (beyond global
|
||
* dep flags). APPENDED, not replacing globals.
|
||
*/
|
||
extraCFlags?: string[];
|
||
extraCxxFlags?: string[];
|
||
/**
|
||
* Build type for this dep. Defaults to cfg.buildType. Some deps force
|
||
* Release (lshpack — its debug build exposes asan symbols we can't link).
|
||
*/
|
||
buildType?: BuildType;
|
||
/**
|
||
* Subdirectory within the build dir where libraries land.
|
||
* E.g. cares puts them in "lib/", hdrhistogram in "src/". Default: root.
|
||
*/
|
||
libSubdir?: string;
|
||
/**
|
||
* Subdirectory within the SOURCE dir containing CMakeLists.txt.
|
||
* E.g. zstd's cmake files live at `build/cmake/`, not the repo root.
|
||
* Becomes the `-S` arg to cmake. Default: source root.
|
||
*/
|
||
sourceSubdir?: string;
|
||
/**
|
||
* If true, add -fPIC to C/CXX flags (non-windows). This also SUPPRESSES
|
||
* the default apple -fno-pic -fno-pie — you can't have both.
|
||
*
|
||
* Most deps don't need this (we link statically into a non-PIE executable),
|
||
* but some build intermediate tools or have internal shared libs that
|
||
* require PIC. cares/highway/libarchive set it.
|
||
*/
|
||
pic?: boolean;
|
||
/**
|
||
* Script to run before cmake configure. Outputs become implicit inputs
|
||
* to configure — if they change (or don't exist), reconfigure.
|
||
*
|
||
* Used when a dep needs a non-cmake build step whose output cmake
|
||
* configure reads. Currently: ICU on Windows (build-icu.ps1 →
|
||
* msbuild → libs that WebKit's cmake needs via -DICU_ROOT).
|
||
*/
|
||
preBuild?: PreBuildSpec;
|
||
}
|
||
|
||
export interface PreBuildSpec {
|
||
/** Command argv. First element is the executable. */
|
||
command: string[];
|
||
/** Working directory (absolute). */
|
||
cwd: string;
|
||
/**
|
||
* Output files (absolute paths). These become implicit inputs to cmake
|
||
* configure, so configure waits on them and re-runs if they change.
|
||
* Also the ninja outputs — if missing, preBuild runs.
|
||
*/
|
||
outputs: string[];
|
||
}
|
||
|
||
export interface CargoBuild {
|
||
kind: "cargo";
|
||
/**
|
||
* Subdirectory within the source dir containing the Cargo.toml to build.
|
||
* E.g. lolhtml's C bindings crate lives at `c-api/`, not the repo root
|
||
* (which is the pure-rust crate).
|
||
*/
|
||
manifestDir: string;
|
||
/**
|
||
* Output library basename (no prefix/suffix). Cargo always names the output
|
||
* after the crate's `[lib] name`, which may differ from the directory name.
|
||
*/
|
||
libName: string;
|
||
/**
|
||
* Rust target triple override. Cargo defaults to the host triple, which
|
||
* is usually what we want — but cross-compiles (e.g. arm64-windows on an
|
||
* x64 windows CI runner) need this explicitly.
|
||
*
|
||
* When set, cargo's output path changes to `<target-dir>/<triple>/<profile>/`.
|
||
*/
|
||
rustTarget?: string;
|
||
/**
|
||
* RUSTFLAGS for this build. Passed via CARGO_ENCODED_RUSTFLAGS with
|
||
* unit-separator (\x1f) encoding so multi-word flags work.
|
||
*/
|
||
rustflags?: string[];
|
||
/**
|
||
* Tier 3 targets (e.g. aarch64-unknown-freebsd) have no prebuilt std, so
|
||
* `rustup target add` (dep_cargo_cross rule) won't help. When true, passes
|
||
* `-Zbuild-std=std,panic_abort` so cargo builds std from source. Requires
|
||
* nightly cargo + `rustup component add rust-src`.
|
||
*/
|
||
buildStd?: boolean;
|
||
}
|
||
|
||
/**
|
||
* What a dependency provides to bun's build: libraries to link, headers to
|
||
* include, defines to set. All paths are resolved to absolute during
|
||
* `resolveDep`.
|
||
*/
|
||
export interface Provides {
|
||
/**
|
||
* Library outputs to link. Paths relative to the dep's BUILD directory
|
||
* (or its libSubdir if set). May be bare names ("mimalloc" → libmimalloc.a)
|
||
* or exact paths ("CMakeFiles/mimalloc-obj.dir/src/static.c.o").
|
||
*
|
||
* Ignored for `direct` builds — emitDirect names the archive
|
||
* `lib<dep.name>` and returns that path itself.
|
||
*/
|
||
libs: string[];
|
||
/** Include directories. Paths relative to the dep's SOURCE directory. */
|
||
includes: string[];
|
||
/** Preprocessor defines to add to bun's compilation. */
|
||
defines?: string[];
|
||
/**
|
||
* Source files (relative to the SOURCE dir) that bun compiles directly
|
||
* into its own binary — no nested build producing a `.a`. Declared as
|
||
* implicit outputs of the fetch rule so ninja knows where they come from;
|
||
* bun.ts adds them to its C/C++ source lists.
|
||
*
|
||
* Most deps provide `.a` files via `libs`. This is for the rare case of
|
||
* a single-file dep with no build system (picohttpparser: one .c file).
|
||
*/
|
||
sources?: string[];
|
||
}
|
||
|
||
/**
|
||
* A vendored dependency definition. Lives in scripts/build/deps/<name>.ts.
|
||
*/
|
||
export interface Dependency {
|
||
name: string;
|
||
|
||
/** Where source comes from. Evaluated per-config so local mode can be dynamic. */
|
||
source: (cfg: Config) => Source;
|
||
|
||
/**
|
||
* Patch files to apply after extraction. Paths relative to repo root.
|
||
* Patches are included in the source identity hash — changing a patch
|
||
* invalidates the fetched source and triggers re-fetch.
|
||
*
|
||
* Files ending in `.patch` are applied with `git apply`. Other files are
|
||
* OVERLAYS — copied into the source root as-is. Useful for injecting a
|
||
* CMakeLists.txt into a project that lacks one (tinycc).
|
||
*
|
||
* Function form allows conditional patches (e.g. zlib's arm64-windows
|
||
* machine-type fix is only needed on that target).
|
||
*/
|
||
patches?: string[] | ((cfg: Config) => string[]);
|
||
|
||
/**
|
||
* Other deps that must be BUILT before this dep's configure runs.
|
||
* Used for header-level dependencies — e.g. libarchive needs zlib's
|
||
* headers at configure time (`check_include_file("zlib.h")`). zlib-ng
|
||
* generates `zlib.h` during its own cmake configure, so libarchive must
|
||
* wait for zlib's full build, not just its source fetch.
|
||
*
|
||
* Resolves to the named dep's build outputs (lib files for nested-cmake,
|
||
* source stamp for header-only). Order-only on configure, implicit on
|
||
* build. Does NOT link the other dep's libs (that's `provides.libs`).
|
||
*/
|
||
fetchDeps?: string[];
|
||
|
||
/** How to build. */
|
||
build: (cfg: Config) => BuildSpec;
|
||
|
||
/** What the dep provides to bun's build. */
|
||
provides: (cfg: Config) => Provides;
|
||
|
||
/**
|
||
* Whether this dep participates in the build at all. Defaults to always-on.
|
||
* E.g. libuv is windows-only, tinycc is disabled on Android/FreeBSD.
|
||
*/
|
||
enabled?: (cfg: Config) => boolean;
|
||
|
||
/**
|
||
* Macro name suffix for `bun_dependency_versions.h` — becomes
|
||
* `BUN_DEP_<macro>` / `BUN_VERSION_<macro>`. The value is derived from
|
||
* `source(cfg)`: `github-archive.commit`, `prebuilt.identity`, etc.
|
||
*
|
||
* Omit for deps that shouldn't appear in `process.versions` (e.g.
|
||
* nodejs-headers — they're build-time only). The naming is constrained
|
||
* by what BunProcess.cpp already expects; some have `_HASH` suffix for
|
||
* historical reasons.
|
||
*/
|
||
versionMacro?: string;
|
||
}
|
||
|
||
/**
|
||
* Resolved dependency — absolute paths ready for link()/cxx() calls.
|
||
*/
|
||
export interface ResolvedDep {
|
||
name: string;
|
||
/**
|
||
* Absolute paths to .a/.lib files for link(). Populated by nested-cmake/
|
||
* cargo/prebuilt deps, and by `direct` deps when `cfg.archiveDeps` is on.
|
||
*/
|
||
libs: string[];
|
||
/**
|
||
* Absolute paths to .o/.obj files for link(). Populated by `direct` deps
|
||
* when `cfg.archiveDeps` is off (the default) — the dep's sources are
|
||
* compiled in our graph and the resulting objects go straight into bun's
|
||
* link line / cpp-only archive instead of an intermediate `.a`.
|
||
*/
|
||
objects: string[];
|
||
/** Absolute include paths for -I flags. */
|
||
includes: string[];
|
||
defines: string[];
|
||
/**
|
||
* Absolute paths to .c/.cpp files bun compiles directly (from
|
||
* Provides.sources). Empty for most deps — they provide .a files.
|
||
*/
|
||
sources: string[];
|
||
/**
|
||
* The final build output(s). Use these as implicit inputs on anything
|
||
* downstream that needs this dep built first.
|
||
* For nested-cmake deps, these ARE the libs. For header-only deps, this is
|
||
* the source stamp (.ref).
|
||
*/
|
||
outputs: string[];
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// Ninja rule registration (call once)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Register ninja rules shared by all deps. Call once before any resolveDep().
|
||
*/
|
||
export function registerDepRules(n: Ninja, cfg: Config): void {
|
||
// Shell quoting: tool/script paths may contain spaces (e.g. cargo
|
||
// in "C:\Program Files\Rust\..."). quote() passes through safe paths
|
||
// unchanged so there's no cost on the common case. Host shell syntax
|
||
// (dep rules don't run in rust-only cross-compile, so host == target,
|
||
// but use host.os for consistency with other modules).
|
||
const hostWin = cfg.host.os === "windows";
|
||
const q = (p: string) => quote(p, hostWin);
|
||
const cmake = q(cfg.cmake);
|
||
const fetchCli = q(fetchCliPath);
|
||
|
||
// stream.ts wraps commands to give live prefixed output while ninja runs
|
||
// them in parallel. Ninja buffers non-console subprocess output (confirmed
|
||
// in subprocess-posix.cc / status_printer.cc — BuildEdgeFinished receives
|
||
// the full buffer only when the command exits), but FDs > 2 are inherited
|
||
// through posix_spawn/CreateProcessA unchanged. build.ts dups stderr into
|
||
// FD 3; stream.ts writes prefixed lines to FD 3; output lands on the
|
||
// terminal directly. Deps run 4-at-a-time, every line streams live.
|
||
const stream = `${cfg.jsRuntime} ${q(streamPath)} $name`;
|
||
|
||
// Fetch: downloads github archive tarball, extracts, patches, writes .ref.
|
||
// The command encodes: name, repo, commit, dest path, cache path, and patch
|
||
// files. If any of those change, the ninja command string changes, and ninja
|
||
// re-runs fetch. The fetch script is also an implicit input.
|
||
n.rule("dep_fetch", {
|
||
command: `${stream} ${cfg.jsRuntime} ${fetchCli} dep $name $repo $commit $dest $cache $patches`,
|
||
description: "fetch $name",
|
||
restat: true,
|
||
pool: "dep",
|
||
});
|
||
|
||
// Prebuilt fetch: download tarball with pre-compiled .a/.lib files, extract,
|
||
// write .identity stamp. Used for WebKit prebuilt (and any future deps that
|
||
// ship prebuilts). Outputs are the lib files directly; stamp confirms
|
||
// identity for restat.
|
||
//
|
||
// $rm_paths: space-separated paths (relative to dest) to delete after
|
||
// extraction. Trailing positional args to fetch-cli, may be empty.
|
||
n.rule("dep_fetch_prebuilt", {
|
||
command: `${stream} ${cfg.jsRuntime} ${fetchCli} prebuilt $name $url $dest $identity $rm_paths`,
|
||
description: "fetch $name (prebuilt)",
|
||
restat: true,
|
||
pool: "dep",
|
||
});
|
||
|
||
// Configure: runs cmake in the dep source dir, outputs CMakeCache.txt.
|
||
// The full cmake args are baked into $args per-build — flag changes
|
||
// invalidate configure, which invalidates the .a outputs.
|
||
//
|
||
// --fresh (cmake 3.24+) drops the cache before configuring. This matters
|
||
// because cmake caches -D values: if a previous configure set -DFOO=ON and
|
||
// this one doesn't pass -DFOO at all, cmake keeps the cached ON. Since ninja
|
||
// only reruns this rule when $args actually changed (tracked in .ninja_log),
|
||
// we always want a clean slate when it does run.
|
||
n.rule("dep_configure", {
|
||
command: `${stream} --cwd=$srcdir ${cmake} --fresh -B$builddir $args`,
|
||
description: "cmake $name",
|
||
restat: true,
|
||
pool: "dep",
|
||
});
|
||
|
||
// Build: runs cmake --build. Restat is critical — if no source changed in
|
||
// the dep, cmake --build is a no-op (inner ninja re-stats), and our restat
|
||
// prunes everything downstream.
|
||
n.rule("dep_build", {
|
||
command: `${stream} ${cmake} --build $builddir --config $buildtype $targets`,
|
||
description: "build $name",
|
||
restat: true,
|
||
pool: "dep",
|
||
});
|
||
|
||
// Cargo build: runs `cargo build` in the manifest dir. Only registered
|
||
// if cargo is available — a missing rust toolchain makes ninja fail with
|
||
// a clear "unknown build rule 'dep_cargo'" instead of a cryptic sh error.
|
||
//
|
||
// Env is passed via stream.ts --env (ninja has no native env support).
|
||
// restat: cargo's incremental build doesn't touch unchanged outputs.
|
||
if (cfg.cargo !== undefined) {
|
||
n.rule("dep_cargo", {
|
||
command: `${stream} --cwd=$manifestdir $env ${q(cfg.cargo)} build $args`,
|
||
description: "cargo $name",
|
||
restat: true,
|
||
pool: "dep",
|
||
});
|
||
// Cross-compile variant: ensure the rust std for the target triple is
|
||
// installed before building. CI images install rustup as a different
|
||
// user/HOME than the build runs under, so the target may be missing even
|
||
// though `rustup target add` ran at image-build time. `rustup toolchain
|
||
// install --force` reinstalls missing components rather than trusting
|
||
// "the dir exists" — also repairs a partially auto-installed pinned
|
||
// toolchain (no distributable manifest, which would otherwise error with
|
||
// `Missing manifest in toolchain '<channel>-<host>'` before cargo even
|
||
// ran). ~70ms no-op when complete. Same pattern as `rust_build_cross` in
|
||
// rust.ts — see the longer comment there.
|
||
const rustup = q(join(dirname(cfg.cargo), `rustup${cfg.host.exeSuffix}`));
|
||
const cargoCrossEnsure =
|
||
cfg.rustToolchain !== undefined
|
||
? `${stream} $env ${rustup} -q toolchain install ${cfg.rustToolchain} --force --no-self-update --component rust-src --target $rust_target`
|
||
: `${stream} $env ${rustup} -q target add $rust_target`;
|
||
// Windows: ninja runs commands via CreateProcess (no shell) — wrap in
|
||
// `cmd /c "..."` so `&&` is interpreted as a chain operator instead of
|
||
// being passed as a literal arg. See rust.ts `rust_build_cross`.
|
||
const cargoCrossChain = `${cargoCrossEnsure} && ${stream} --cwd=$manifestdir $env ${q(cfg.cargo)} build $args`;
|
||
n.rule("dep_cargo_cross", {
|
||
command: hostWin ? `cmd /c "${cargoCrossChain}"` : cargoCrossChain,
|
||
description: "cargo $name ($rust_target)",
|
||
restat: true,
|
||
pool: "dep",
|
||
});
|
||
}
|
||
|
||
// preBuild: runs an arbitrary command before cmake configure. Used for
|
||
// build steps that live outside cmake (ICU via msbuild on Windows).
|
||
// restat: if outputs are unchanged (script is idempotent), prune
|
||
// downstream re-configure.
|
||
n.rule("dep_prebuild", {
|
||
command: `${stream} --cwd=$cwd $cmd`,
|
||
description: "prebuild $name",
|
||
restat: true,
|
||
pool: "dep",
|
||
});
|
||
|
||
// DirectBuild host tool: compile+link in one clang invocation with NO
|
||
// cfg target/arch flags — the tool runs on the build host. cc()/link()
|
||
// would add --target which breaks cross-compiles. cfg.hostCc (not cfg.cc):
|
||
// when cross-compiling for windows, cc is clang-cl and defaults to a
|
||
// *-windows-msvc triple — host tools must stay plain clang.
|
||
n.rule("dep_host_cc", {
|
||
command: `${q(cfg.hostCc)} $flags -o $out $in`,
|
||
description: "host-cc $out",
|
||
});
|
||
|
||
// DirectBuild codegen: runs a host tool built by this graph to produce a
|
||
// header. cwd is the dep source dir so the tool sees its inputs; output
|
||
// path is absolute. restat: no-op if the header content is unchanged.
|
||
n.rule("dep_codegen", {
|
||
command: `${stream} --cwd=$cwd $tool $args`,
|
||
description: "codegen $name",
|
||
restat: true,
|
||
});
|
||
|
||
// DirectBuild header substitution: literal string replacement on a
|
||
// template file (cmake's configure_file(@ONLY) without the cmake).
|
||
// restat is what makes this cheap — if the output text is unchanged
|
||
// (unmodified template, same replacements), downstream .o files are
|
||
// pruned via their depfile entries.
|
||
n.rule("dep_subst", {
|
||
command: `${cfg.jsRuntime} ${fetchCli} subst $in $out $pairs`,
|
||
description: "subst $out",
|
||
restat: true,
|
||
});
|
||
|
||
// The `dep` pool: depth-4 balances two concerns. Each nested cmake/cargo
|
||
// build spawns its own -j parallelism; running all 15 at once would
|
||
// oversubscribe cores badly (15 × nproc jobs). Four-at-a-time keeps CPU
|
||
// saturated without thrashing. Output streams live via FD 3 regardless —
|
||
// the pool is purely about scheduling, not display.
|
||
n.pool("dep", 4);
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// Resolution — emit ninja rules, return absolute paths
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Path to a dep's source tree: its `--local-deps` checkout if redirected,
|
||
* else vendor/<name>/. Cross-dep references (lsquic's -I into boringssl,
|
||
* boringssl's nasm -I) go through here so they follow a redirect too. Does
|
||
* NOT handle in-tree sources or WebKit's $BUN_WEBKIT_PATH — use the per-dep
|
||
* `srcDir` computed in resolveDep() for those.
|
||
*/
|
||
export function depSourceDir(cfg: Config, name: string): string {
|
||
return cfg.localDeps[name] ?? resolve(cfg.vendorDir, name);
|
||
}
|
||
|
||
/**
|
||
* Path to a dep's cmake build output. Separate from source so multiple
|
||
* profiles (debug/release) don't clash.
|
||
*/
|
||
export function depBuildDir(cfg: Config, name: string): string {
|
||
return resolve(cfg.buildDir, "deps", name);
|
||
}
|
||
|
||
/**
|
||
* The dep's source, with `--local-deps` applied: a dep named there is
|
||
* redirected from its pinned github-archive tarball to the local checkout.
|
||
* Only github-archive sources can be redirected — prebuilt/in-tree deps
|
||
* have their own switches (e.g. `--webkit=local`).
|
||
*/
|
||
export function depSource(cfg: Config, dep: Dependency): Source {
|
||
const source = dep.source(cfg);
|
||
const localPath = cfg.localDeps[dep.name];
|
||
if (localPath === undefined) return source;
|
||
assert(
|
||
source.kind === "github-archive",
|
||
`--local-deps: ${dep.name} has a ${source.kind} source; only github-archive deps can be redirected`,
|
||
dep.name === "WebKit" ? { hint: "Use --webkit=local (and $BUN_WEBKIT_PATH) for WebKit" } : {},
|
||
);
|
||
return {
|
||
kind: "local",
|
||
path: localPath,
|
||
hint: `--local-deps points ${dep.name} at ${localPath} — clone ${source.repo} there`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolve a dependency: emit ninja rules for fetch → configure → build,
|
||
* return absolute paths for linking.
|
||
*
|
||
* If the dep is disabled (enabled() returns false), returns null. Caller
|
||
* should skip.
|
||
*/
|
||
export function resolveDep(
|
||
n: Ninja,
|
||
cfg: Config,
|
||
dep: Dependency,
|
||
resolved: ReadonlyMap<string, ResolvedDep>,
|
||
): ResolvedDep | null {
|
||
if (dep.enabled && !dep.enabled(cfg)) {
|
||
return null;
|
||
}
|
||
|
||
const source = depSource(cfg, dep);
|
||
const buildSpec = dep.build(cfg);
|
||
const provides = dep.provides(cfg);
|
||
|
||
// ─── Prebuilt: entire acquisition is download + extract. No build step. ───
|
||
// Handled separately because there's no "source dir" in the usual sense —
|
||
// the extracted tarball IS the output, and `provides.libs` are paths into
|
||
// it directly. buildSpec is ignored (should be `{kind:"none"}` but we
|
||
// don't enforce it — the dep definition knows what it's doing).
|
||
if (source.kind === "prebuilt") {
|
||
return emitPrebuilt(n, cfg, dep.name, source, provides);
|
||
}
|
||
|
||
// Source directory. For in-tree deps (sqlite), this points into the bun
|
||
// repo instead of vendor/. Local deps can override via `path` to point
|
||
// outside the worktree. Everything else is vendor/<name>/.
|
||
const srcDir =
|
||
source.kind === "in-tree"
|
||
? resolve(cfg.cwd, source.path)
|
||
: source.kind === "local" && source.path
|
||
? source.path
|
||
: depSourceDir(cfg, dep.name);
|
||
|
||
// Resolve conditional patches. Same list for the whole configure run —
|
||
// we don't want patches changing between emitFetch and the hash check.
|
||
const patches = dep.patches === undefined ? [] : typeof dep.patches === "function" ? dep.patches(cfg) : dep.patches;
|
||
|
||
// Sources bun compiles directly (from Provides.sources). Resolved to
|
||
// absolute paths for (a) the ResolvedDep return and (b) declaring as
|
||
// implicit outputs of fetch so ninja knows where they come from.
|
||
const resolvedSources = (provides.sources ?? []).map(s => resolve(srcDir, s));
|
||
|
||
// DirectBuild sources are ALSO compiled in our ninja graph, so they need
|
||
// the same implicit-output-of-fetch treatment. Include the codegen tool
|
||
// source, its input, and any HeaderSubst templates — all read at build
|
||
// time from the fetched tree.
|
||
const directSources: string[] = [];
|
||
if (buildSpec.kind === "direct") {
|
||
for (const s of buildSpec.sources) {
|
||
directSources.push(resolve(srcDir, typeof s === "string" ? s : s.path));
|
||
}
|
||
for (const h of Object.values(buildSpec.headers ?? {})) {
|
||
if (typeof h !== "string") directSources.push(resolve(srcDir, h.from));
|
||
}
|
||
if (buildSpec.codegen !== undefined) {
|
||
directSources.push(resolve(srcDir, buildSpec.codegen.tool));
|
||
for (const a of buildSpec.codegen.args) {
|
||
if (a !== "$out") directSources.push(resolve(srcDir, a));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Step 1: source acquisition ───
|
||
// Emits a ninja node producing the "source is ready" stamp.
|
||
// For github-archive: this runs fetchCli which downloads/extracts/patches.
|
||
// For local/in-tree: source is already on disk; we use a sentinel file
|
||
// (CMakeLists.txt) as the stamp. Editing it → reconfigure.
|
||
let sourceStamp: string | undefined;
|
||
if (source.kind === "github-archive") {
|
||
sourceStamp = emitFetch(n, cfg, dep.name, source, patches, [...resolvedSources, ...directSources]);
|
||
} else {
|
||
// Local/in-tree: no .ref to write. Use the build system's manifest file
|
||
// as the stamp — touching it triggers reconfigure/rebuild.
|
||
// cmake deps → CMakeLists.txt (in sourceSubdir if set, e.g. zstd)
|
||
// cargo deps → Cargo.toml (in manifestDir)
|
||
// direct/header-only → none: the sources are on disk before ninja
|
||
// starts, so the compiler depfiles see edits directly. (Stamping the
|
||
// directory would rebuild the PCH whenever a top-level entry moved.)
|
||
let stampDir: string;
|
||
let stampFile: string;
|
||
if (buildSpec.kind === "nested-cmake") {
|
||
stampDir = buildSpec.sourceSubdir ? resolve(srcDir, buildSpec.sourceSubdir) : srcDir;
|
||
stampFile = "CMakeLists.txt";
|
||
} else if (buildSpec.kind === "cargo") {
|
||
stampDir = resolve(srcDir, buildSpec.manifestDir);
|
||
stampFile = "Cargo.toml";
|
||
} else {
|
||
stampDir = srcDir;
|
||
stampFile = "";
|
||
}
|
||
sourceStamp = stampFile ? resolve(stampDir, stampFile) : undefined;
|
||
|
||
const modeName = source.kind === "in-tree" ? "in-tree" : "local";
|
||
assert(existsSync(sourceStamp ?? stampDir), `${modeName} dep "${dep.name}" source not found at ${stampDir}`, {
|
||
hint:
|
||
source.kind === "in-tree"
|
||
? `Expected ${stampFile || "source"} at ${source.path}/ — check deps/${dep.name}.ts`
|
||
: (source.hint ?? `Clone the dep to vendor/${dep.name}/ manually`),
|
||
});
|
||
}
|
||
|
||
// ─── Resolve fetchDeps → extra inputs on configure + build ───
|
||
// These are deps that must be BUILT before we configure (not link).
|
||
// E.g. libarchive's configure runs check_include_file("zlib.h"), and
|
||
// zlib-ng generates zlib.h during its own cmake configure — so we depend
|
||
// on zlib's lib output (which implies its configure ran).
|
||
//
|
||
// On CONFIGURE: order-only. Configure needs the headers to exist, but
|
||
// doesn't track their content — feature detection is cached in
|
||
// CMakeCache.txt regardless.
|
||
//
|
||
// On BUILD: implicit. If the cross-dep rebuilds (commit bump), its
|
||
// headers may have changed; our .o files track them via the inner
|
||
// ninja's .d files. Restat prunes downstream when nothing changed.
|
||
const fetchDepStamps = (dep.fetchDeps ?? []).flatMap(d => {
|
||
const r = resolved.get(d);
|
||
assert(r, `${dep.name}: fetchDeps references '${d}' but it wasn't resolved first — fix allDeps ordering`);
|
||
return r.outputs;
|
||
});
|
||
|
||
// ─── Step 2+3: build ───
|
||
let libs: string[];
|
||
let objects: string[] = [];
|
||
let outputs: string[];
|
||
|
||
if (buildSpec.kind === "nested-cmake") {
|
||
const result = emitNestedCmake(n, cfg, dep.name, buildSpec, {
|
||
srcDir,
|
||
sourceStamp: sourceStamp!, // .ref or CMakeLists.txt — always set for cmake deps
|
||
provides,
|
||
fetchDepStamps,
|
||
// Local-mode deps: always re-invoke inner build. We can't track
|
||
// source changes reliably (git checkout preserves mtimes of files
|
||
// unchanged between commits). The inner ninja detects what's stale.
|
||
alwaysBuild: source.kind === "local",
|
||
});
|
||
libs = result.libs;
|
||
outputs = result.libs;
|
||
} else if (buildSpec.kind === "cargo") {
|
||
const result = emitCargo(n, cfg, dep.name, buildSpec, { srcDir, sourceStamp: sourceStamp! }); // .ref or Cargo.toml
|
||
libs = result.libs;
|
||
outputs = result.libs;
|
||
} else if (buildSpec.kind === "direct") {
|
||
const result = emitDirect(n, cfg, dep.name, buildSpec, { srcDir, sourceStamp, fetchDepStamps });
|
||
libs = result.libs;
|
||
objects = result.objects;
|
||
// outputs is the "downstream needs me built" signal — for direct deps
|
||
// that's the generated headers + source stamp, NOT the .o files (those
|
||
// are link inputs, not include-order dependencies).
|
||
outputs = result.headerOutputs;
|
||
} else {
|
||
// No build step. The fetch stamp (if any) is the only output. For deps
|
||
// with provides.sources (picohttpparser), emitBun adds a phony pointing
|
||
// at the compiled .o files so `--target <name>` actually compiles them.
|
||
libs = [];
|
||
outputs = sourceStamp === undefined ? [] : [sourceStamp];
|
||
}
|
||
|
||
// ─── Resolve include paths ───
|
||
// Includes are relative to the SOURCE dir (in-tree or vendor). Not the
|
||
// cmake subdir — e.g. zstd's headers are at vendor/zstd/lib/, not
|
||
// vendor/zstd/build/cmake/lib/.
|
||
//
|
||
// Includes CAN be absolute — for deps whose headers land in the BUILD dir
|
||
// (generated during configure), the `provides` function computes absolute
|
||
// paths itself using `depBuildDir()`. Relative paths resolve against srcDir.
|
||
const includes = provides.includes.map(inc => {
|
||
if (isAbsolute(inc)) return inc;
|
||
return inc === "." ? srcDir : resolve(srcDir, inc);
|
||
});
|
||
|
||
return {
|
||
name: dep.name,
|
||
libs,
|
||
objects,
|
||
includes,
|
||
defines: provides.defines ?? [],
|
||
sources: resolvedSources,
|
||
outputs,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Compute the lib paths a dep produces WITHOUT emitting ninja rules.
|
||
*
|
||
* Used by link-only mode: artifacts (the .a/.lib files) are downloaded
|
||
* from cpp-only's buildkite upload into the SAME paths this returns.
|
||
* Ninja sees them as source files (no build rule) — errors cleanly if
|
||
* download failed.
|
||
*
|
||
* Must stay in sync with the path computation inside emitNestedCmake /
|
||
* emitCargo / emitPrebuilt — that's the contract between cpp-only
|
||
* (producer) and link-only (consumer). If those emit-side paths change,
|
||
* change this too.
|
||
*/
|
||
export function computeDepLibs(cfg: Config, dep: Dependency): string[] {
|
||
if (dep.enabled && !dep.enabled(cfg)) {
|
||
return [];
|
||
}
|
||
|
||
const source = dep.source(cfg);
|
||
const buildSpec = dep.build(cfg);
|
||
const provides = dep.provides(cfg);
|
||
|
||
// Prebuilt: provides.libs are paths relative to destDir.
|
||
if (source.kind === "prebuilt") {
|
||
const destDir = source.destDir ?? depSourceDir(cfg, dep.name);
|
||
return provides.libs.map(lib => resolve(destDir, lib));
|
||
}
|
||
|
||
// nested-cmake: provides.libs are bare names (prefix/suffix added) or
|
||
// paths with "." (used as-is), relative to buildDir/libSubdir.
|
||
// preBuild outputs (ICU) are absolute paths already — appended.
|
||
if (buildSpec.kind === "nested-cmake") {
|
||
const buildDir = depBuildDir(cfg, dep.name);
|
||
const libDir = buildSpec.libSubdir ? resolve(buildDir, buildSpec.libSubdir) : buildDir;
|
||
const libs = provides.libs.map(lib =>
|
||
lib.includes(".") ? resolve(libDir, lib) : resolve(libDir, `${cfg.libPrefix}${lib}${cfg.libSuffix}`),
|
||
);
|
||
if (buildSpec.preBuild !== undefined) {
|
||
libs.push(...buildSpec.preBuild.outputs);
|
||
}
|
||
return libs;
|
||
}
|
||
|
||
// cargo: single lib in targetDir/<triple?>/<profile>/.
|
||
if (buildSpec.kind === "cargo") {
|
||
const targetDir = depBuildDir(cfg, dep.name);
|
||
const profile = cfg.release ? "release" : "debug";
|
||
const outSubdir = buildSpec.rustTarget ? join(buildSpec.rustTarget, profile) : profile;
|
||
return [resolve(targetDir, outSubdir, `${cfg.libPrefix}${buildSpec.libName}${cfg.libSuffix}`)];
|
||
}
|
||
|
||
// direct: single lib<name>.a when archiveDeps; otherwise the dep's .o
|
||
// files are folded into libbun.a in cpp-only and there's no separate
|
||
// artifact for link-only to fetch.
|
||
if (buildSpec.kind === "direct") {
|
||
if (!cfg.archiveDeps) return [];
|
||
const buildDir = depBuildDir(cfg, dep.name);
|
||
return [resolve(buildDir, `${cfg.libPrefix}${dep.name}${cfg.libSuffix}`)];
|
||
}
|
||
|
||
// none: no libs (header-only or directly-compiled sources).
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* Emit a ninja fetch rule. Returns absolute path to the .ref stamp.
|
||
*
|
||
* The .ref stamp contains the "source identity": hash(commit + patch contents).
|
||
* If the identity matches what's on disk, fetch is a no-op (and restat kicks in).
|
||
* If it doesn't match, fetch blows away the source dir and re-extracts.
|
||
*/
|
||
function emitFetch(
|
||
n: Ninja,
|
||
cfg: Config,
|
||
name: string,
|
||
source: Extract<Source, { kind: "github-archive" }>,
|
||
patches: string[],
|
||
compiledSources: string[],
|
||
): string {
|
||
const srcDir = depSourceDir(cfg, name);
|
||
const refStamp = resolve(srcDir, ".ref");
|
||
const patchPaths = patches.map(p => resolve(cfg.cwd, p));
|
||
|
||
// ─── Preemptive stale-source cleanup ───
|
||
// If vendor/<dep>/ exists but .ref is missing OR doesn't match the
|
||
// expected identity, wipe the source dir NOW (configure-time, before
|
||
// ninja starts). This forces header files to be missing when ninja does
|
||
// its startup stat, correctly marking .o files that depend on them as
|
||
// dirty — so they recompile on THIS build, not the next one.
|
||
//
|
||
// Without this: ninja stats everything at startup. Stale headers still
|
||
// have OLD mtimes. .o files look clean. Fetch runs, headers get NEW
|
||
// mtimes. Too late — ninja already scheduled .o as clean. You'd need
|
||
// a SECOND build to pick up the header changes. This closes that gap.
|
||
//
|
||
// Only deletes when identity is demonstrably wrong — normal no-op
|
||
// builds skip it (identity matches, nothing touched).
|
||
invalidateStaleSource(srcDir, refStamp, source.commit, patchPaths);
|
||
|
||
n.build({
|
||
outputs: [refStamp],
|
||
// Source files bun compiles directly (picohttpparser.c). Declaring
|
||
// them as outputs tells ninja "fetch creates these" — otherwise ninja
|
||
// errors "missing and no known rule to make it" on fresh checkouts.
|
||
...(compiledSources.length > 0 && { implicitOutputs: compiledSources }),
|
||
rule: "dep_fetch",
|
||
inputs: [],
|
||
// fetch-cli.ts (which has fetchDep) + patch files. Not this file —
|
||
// it's configure-time ninja emission, not fetch logic.
|
||
implicitInputs: [fetchCliPath, ...patchPaths],
|
||
vars: {
|
||
name,
|
||
repo: source.repo,
|
||
commit: source.commit,
|
||
dest: srcDir,
|
||
cache: resolve(cfg.cacheDir, "tarballs"),
|
||
// Pass patches space-separated. Shell-safe because patch paths are
|
||
// under our control (no spaces in repo paths per convention).
|
||
patches: patchPaths.join(" "),
|
||
},
|
||
});
|
||
|
||
// Phony convenience target: `ninja clone-<name>`
|
||
n.phony(`clone-${name}`, [refStamp]);
|
||
|
||
return refStamp;
|
||
}
|
||
|
||
/**
|
||
* Emit a prebuilt fetch rule. Returns a complete ResolvedDep — no further
|
||
* build steps needed, the tarball IS the output.
|
||
*
|
||
* `provides.libs` and `provides.includes` are paths relative to the
|
||
* extracted directory (`destDir` or the default `vendor/<name>/`).
|
||
*/
|
||
function emitPrebuilt(
|
||
n: Ninja,
|
||
cfg: Config,
|
||
name: string,
|
||
source: Extract<Source, { kind: "prebuilt" }>,
|
||
provides: Provides,
|
||
): ResolvedDep {
|
||
// Dest dir: default to vendor/<name>/, but deps like WebKit override to
|
||
// a shared cache location (WebKit's ~200MB, you don't want it per-buildDir).
|
||
const destDir = source.destDir ?? depSourceDir(cfg, name);
|
||
const stamp = resolve(destDir, ".identity");
|
||
|
||
// Libs: paths relative to destDir. Unlike nested-cmake (where bare names
|
||
// get libX.a prefix/suffix), prebuilt tarballs ship full filenames — we
|
||
// take `provides.libs` entries as-is relative to destDir.
|
||
const libs = provides.libs.map(lib => resolve(destDir, lib));
|
||
const includes = provides.includes.map(inc => {
|
||
if (isAbsolute(inc)) return inc;
|
||
return inc === "." ? destDir : resolve(destDir, inc);
|
||
});
|
||
|
||
// Outputs: stamp + all libs. Stamp is the explicit output; libs are
|
||
// implicit (so deleting them correctly retriggers fetch, and restat
|
||
// prunes downstream when fetch was a no-op).
|
||
n.build({
|
||
outputs: [stamp],
|
||
implicitOutputs: libs,
|
||
rule: "dep_fetch_prebuilt",
|
||
inputs: [],
|
||
// Only fetch-cli.ts. download.ts has a lot of shared helpers — editing
|
||
// those shouldn't re-download a multi-hundred-MB WebKit tarball.
|
||
implicitInputs: [fetchCliPath],
|
||
vars: {
|
||
name,
|
||
url: source.url,
|
||
dest: destDir,
|
||
identity: source.identity,
|
||
// Space-separated relative paths. No quoting needed — paths are
|
||
// under our control (include/node/openssl etc.), no spaces.
|
||
rm_paths: (source.rmAfterExtract ?? []).join(" "),
|
||
},
|
||
});
|
||
// Downstream should depend on: libs if there are any (compile-link deps),
|
||
// otherwise the stamp (header-only deps like nodejs-headers — downstream
|
||
// just needs the files to EXIST, stamp proves extraction happened).
|
||
const outputs = libs.length > 0 ? libs : [stamp];
|
||
n.phony(name, outputs);
|
||
|
||
return {
|
||
name,
|
||
libs,
|
||
objects: [],
|
||
includes,
|
||
defines: provides.defines ?? [],
|
||
sources: [],
|
||
outputs,
|
||
};
|
||
}
|
||
|
||
interface EmitNestedCmakeInput {
|
||
/** Resolved source dir (vendor/<name> or in-tree path). */
|
||
srcDir: string;
|
||
/** The "source is ready" file (vendor/<name>/.ref or CMakeLists.txt). */
|
||
sourceStamp: string;
|
||
provides: Provides;
|
||
/**
|
||
* Cross-dep source stamps. Order-only on configure (existence suffices),
|
||
* implicit on build (content changes must trigger rebuild).
|
||
*/
|
||
fetchDepStamps: string[];
|
||
/**
|
||
* Always re-invoke the inner build. For `local` mode deps where we can't
|
||
* track source changes (git checkout doesn't touch unchanged files). The
|
||
* inner ninja does its own staleness check; restat=1 prunes our downstream
|
||
* when it's a no-op. Matches CMake's `add_custom_target ALL`.
|
||
*/
|
||
alwaysBuild: boolean;
|
||
}
|
||
|
||
/**
|
||
* Emit ninja configure + build rules for a nested cmake project.
|
||
* Returns resolved absolute library paths.
|
||
*/
|
||
function emitNestedCmake(
|
||
n: Ninja,
|
||
cfg: Config,
|
||
name: string,
|
||
spec: NestedCmakeBuild,
|
||
input: EmitNestedCmakeInput,
|
||
): { libs: string[] } {
|
||
const { srcDir, sourceStamp, provides, fetchDepStamps, alwaysBuild } = input;
|
||
const buildDir = depBuildDir(cfg, name);
|
||
const cacheFile = resolve(buildDir, "CMakeCache.txt");
|
||
const buildType = spec.buildType ?? cfg.buildType;
|
||
// Shell quoting follows HOST (the shell runs there). Always matches
|
||
// cfg.windows in modes that reach here (we don't cross-compile deps),
|
||
// but stays explicit for the pattern.
|
||
const hostWin = cfg.host.os === "windows";
|
||
|
||
// cmake source dir (where CMakeLists.txt lives). Usually srcDir, but
|
||
// some projects nest it (zstd: vendor/zstd/build/cmake/).
|
||
const cmakeSrcDir = spec.sourceSubdir ? resolve(srcDir, spec.sourceSubdir) : srcDir;
|
||
|
||
// ─── Assemble cmake configure args ───
|
||
const args: string[] = [];
|
||
|
||
// slash() on all tool paths: cmake writes some -D values verbatim into
|
||
// generated .cmake files (e.g. CMakeRCCompiler.cmake), then re-parses
|
||
// them — `\U` in `C:\Users\...` becomes an invalid escape. CMake
|
||
// normalizes CMAKE_C_COMPILER itself but not RC/MT/LINKER.
|
||
|
||
// Toolchain forwarding — same compiler/archiver as bun.
|
||
args.push(`-DCMAKE_C_COMPILER=${slash(cfg.cc)}`);
|
||
args.push(`-DCMAKE_CXX_COMPILER=${slash(cfg.cxx)}`);
|
||
args.push(`-DCMAKE_AR=${slash(cfg.ar)}`);
|
||
if (cfg.ranlib !== undefined) {
|
||
args.push(`-DCMAKE_RANLIB=${slash(cfg.ranlib)}`);
|
||
}
|
||
if (cfg.linux && cfg.ld) {
|
||
// Force lld for any executable the dep build produces (e.g. codegen tools).
|
||
// Most deps are static-lib-only so this usually doesn't matter, but when
|
||
// it does (dep builds a tool to generate a header), using lld keeps the
|
||
// toolchain consistent.
|
||
args.push(`-DCMAKE_EXE_LINKER_FLAGS=--ld-path=${cfg.ld}`);
|
||
args.push(`-DCMAKE_SHARED_LINKER_FLAGS=--ld-path=${cfg.ld}`);
|
||
}
|
||
if (cfg.windows) {
|
||
// Windows-specific toolchain forwarding. When CMAKE_C_COMPILER is
|
||
// an explicit path, cmake's find_program for the supporting tools
|
||
// (rc, mt, linker) may not search the compiler's directory — it
|
||
// searches PATH. We resolved these at configure time; pass them
|
||
// explicitly rather than relying on cmake's detection.
|
||
//
|
||
// NOT setting TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY: it stops
|
||
// try_compile from linking, which makes check_function_exists and
|
||
// check_library_exists always succeed → libarchive "finds" fork,
|
||
// posix_spawnp, libmd on Windows. If llvm-mt is missing, better to
|
||
// fail fast at "compiler works" than mis-detect every feature.
|
||
args.push(`-DCMAKE_LINKER=${slash(cfg.ld)}`);
|
||
if (cfg.rc !== undefined) args.push(`-DCMAKE_RC_COMPILER=${slash(cfg.rc)}`);
|
||
if (cfg.mt !== undefined) args.push(`-DCMAKE_MT=${slash(cfg.mt)}`);
|
||
}
|
||
if (cfg.ccache !== undefined) {
|
||
args.push(`-DCMAKE_C_COMPILER_LAUNCHER=${slash(cfg.ccache)}`);
|
||
args.push(`-DCMAKE_CXX_COMPILER_LAUNCHER=${slash(cfg.ccache)}`);
|
||
}
|
||
// Both may be undefined in rust-only cross-compile (no xcode on the linux
|
||
// CI box); that's fine — the cmake rules are emitted but never pulled.
|
||
// If pulled without an SDK, cmake fails with its own clear error.
|
||
if (cfg.darwin && cfg.osxDeploymentTarget !== undefined && cfg.osxSysroot !== undefined) {
|
||
args.push(`-DCMAKE_OSX_DEPLOYMENT_TARGET=${cfg.osxDeploymentTarget}`);
|
||
args.push(`-DCMAKE_OSX_SYSROOT=${cfg.osxSysroot}`);
|
||
}
|
||
|
||
// Generator + build type. BUILD_SHARED_LIBS=OFF by default — every dep
|
||
// wants static, and many (boringssl, zlib, highway...) rely on this
|
||
// being set globally rather than having their own MY_LIB_SHARED=OFF flag.
|
||
args.push(`-DCMAKE_GENERATOR=Ninja`);
|
||
args.push(`-DCMAKE_BUILD_TYPE=${buildType}`);
|
||
args.push(`-DCMAKE_EXPORT_COMPILE_COMMANDS=ON`);
|
||
args.push(`-DBUILD_SHARED_LIBS=OFF`);
|
||
|
||
// Windows MSVC runtime: CMP0091 NEW (CMake 3.15+) uses this property
|
||
// instead of injecting /MD into CMAKE_<LANG>_FLAGS_<CONFIG>. Without
|
||
// setting it, CMake defaults to MultiThreadedDLL and appends -MD after
|
||
// our /MT in CMAKE_C_FLAGS, poisoning vendor libs with
|
||
// /DEFAULTLIB:msvcrt.lib → link fails with CRT conflict or worse,
|
||
// silently pulls in the dynamic CRT.
|
||
if (cfg.windows) {
|
||
const rt = cfg.debug ? "MultiThreadedDebug" : "MultiThreaded";
|
||
args.push(`-DCMAKE_MSVC_RUNTIME_LIBRARY=${rt}`);
|
||
}
|
||
|
||
// Compiler flags — GLOBAL flags only. These are the dep-safe subset:
|
||
// CPU target, optimization level, debug info, visibility, sections.
|
||
// NO -Werror, NO bun-specific constexpr limits.
|
||
const depFlags = computeDepFlags(cfg);
|
||
let cflags = depFlags.cflags.join(" ");
|
||
let cxxflags = depFlags.cxxflags.join(" ");
|
||
|
||
// PIC handling:
|
||
// spec.pic=true → add -fPIC (non-windows), also tell cmake
|
||
// spec.pic=false → on apple, add -fno-pic -fno-pie (apple clang defaults
|
||
// to PIC; the resulting .o can't link into our non-PIE executable)
|
||
//
|
||
// Windows has no PIC concept (all code is relocatable), so both branches
|
||
// are guarded — no-op there.
|
||
if (spec.pic) {
|
||
if (!cfg.windows) {
|
||
cflags += " -fPIC";
|
||
cxxflags += " -fPIC";
|
||
}
|
||
args.push(`-DCMAKE_POSITION_INDEPENDENT_CODE=ON`);
|
||
} else if (cfg.darwin) {
|
||
cflags += " -fno-pic -fno-pie";
|
||
cxxflags += " -fno-pic -fno-pie";
|
||
}
|
||
|
||
// Dep-specific extra flags. Appended to globals, not replacing them.
|
||
if (spec.extraCFlags) cflags += " " + spec.extraCFlags.join(" ");
|
||
if (spec.extraCxxFlags) cxxflags += " " + spec.extraCxxFlags.join(" ");
|
||
|
||
args.push(`-DCMAKE_C_FLAGS=${cflags}`);
|
||
args.push(`-DCMAKE_CXX_FLAGS=${cxxflags}`);
|
||
|
||
// Dep-specific -D args go LAST so a dep can override anything above
|
||
// if it really needs to. (Rare — we don't expect deps to fight the
|
||
// toolchain settings, but boringssl's build system is known to be picky.)
|
||
for (const [k, v] of Object.entries(spec.args)) {
|
||
args.push(`-D${k}=${v}`);
|
||
}
|
||
|
||
// ─── Emit preBuild node (if specified) ───
|
||
// Runs before configure. Outputs are implicit inputs to configure — if
|
||
// they change (or don't exist), reconfigure. Restat prunes downstream
|
||
// when the script is a no-op (e.g. ICU already built at this profile).
|
||
let preBuildOutputs: string[] = [];
|
||
if (spec.preBuild !== undefined) {
|
||
preBuildOutputs = spec.preBuild.outputs;
|
||
n.build({
|
||
outputs: preBuildOutputs,
|
||
rule: "dep_prebuild",
|
||
inputs: [],
|
||
// Rebuild if source changed (the script itself is under srcDir).
|
||
implicitInputs: [sourceStamp],
|
||
vars: {
|
||
name,
|
||
cwd: spec.preBuild.cwd,
|
||
cmd: quoteArgs(spec.preBuild.command, hostWin),
|
||
},
|
||
});
|
||
n.phony(`prebuild-${name}`, preBuildOutputs);
|
||
}
|
||
|
||
// ─── Emit configure node ───
|
||
n.build({
|
||
outputs: [cacheFile],
|
||
rule: "dep_configure",
|
||
inputs: [],
|
||
// Configure re-runs if: source changed, cmake binary changed, preBuild
|
||
// outputs changed. fetchDeps stamps are order-only — can't configure
|
||
// until cross-dep headers are on disk (libarchive's check_include_file
|
||
// for zlib.h runs at configure time).
|
||
implicitInputs: [sourceStamp, cfg.cmake, ...preBuildOutputs],
|
||
orderOnlyInputs: fetchDepStamps,
|
||
vars: {
|
||
name,
|
||
srcdir: cmakeSrcDir,
|
||
builddir: buildDir,
|
||
args: quoteArgs(args, hostWin),
|
||
},
|
||
});
|
||
n.phony(`configure-${name}`, [cacheFile]);
|
||
|
||
// ─── Resolve library output paths ───
|
||
// Provides.libs can be bare names ("mimalloc" → libmimalloc.a) or paths
|
||
// with a dot ("CMakeFiles/.../static.c.o" → use as-is).
|
||
const libDir = spec.libSubdir ? resolve(buildDir, spec.libSubdir) : buildDir;
|
||
const libs = provides.libs.map(lib => {
|
||
if (lib.includes(".")) {
|
||
return resolve(libDir, lib);
|
||
}
|
||
return resolve(libDir, `${cfg.libPrefix}${lib}${cfg.libSuffix}`);
|
||
});
|
||
|
||
// Targets default to lib names — for deps where the cmake target and
|
||
// the output library share a name. Any dep that diverges sets
|
||
// `targets` explicitly.
|
||
const targets = spec.targets ?? provides.libs.filter(l => !l.includes("."));
|
||
|
||
// ─── Emit build node ───
|
||
// fetchDeps stamps are implicit (not order-only like on configure) because
|
||
// a cross-dep re-fetch may have changed headers our .o files track — we
|
||
// must re-invoke the inner build so ITS ninja can detect and rebuild.
|
||
const buildImplicits = [cacheFile, sourceStamp, ...fetchDepStamps];
|
||
if (alwaysBuild) {
|
||
// Local-mode: inner build always runs. Its own ninja checks staleness.
|
||
// restat=1 prunes our downstream when the .a files didn't change.
|
||
buildImplicits.push(n.always());
|
||
}
|
||
|
||
n.build({
|
||
outputs: libs,
|
||
rule: "dep_build",
|
||
inputs: [],
|
||
implicitInputs: buildImplicits,
|
||
vars: {
|
||
name,
|
||
builddir: buildDir,
|
||
buildtype: buildType,
|
||
targets: targets.map(t => `--target ${t}`).join(" "),
|
||
},
|
||
});
|
||
|
||
// preBuild outputs are produced by dep_prebuild (not dep_build), but
|
||
// link still needs them. Append here so they flow to the resolved dep
|
||
// — NOT to dep_build outputs (that would double-declare).
|
||
const allLibs = [...libs, ...preBuildOutputs];
|
||
n.phony(name, allLibs);
|
||
|
||
return { libs: allLibs };
|
||
}
|
||
|
||
interface EmitCargoInput {
|
||
srcDir: string;
|
||
sourceStamp: string;
|
||
}
|
||
|
||
/**
|
||
* Emit a ninja build rule for a cargo project. Returns the single static lib
|
||
* cargo produces.
|
||
*
|
||
* Cargo's build model is self-contained — no separate configure step. We
|
||
* just point it at a manifest dir, set the target dir, and let it resolve
|
||
* everything. Its own incremental build is reliable, so restat=1 on the
|
||
* rule keeps our downstream no-ops fast.
|
||
*/
|
||
function emitCargo(n: Ninja, cfg: Config, name: string, spec: CargoBuild, input: EmitCargoInput): { libs: string[] } {
|
||
const hostWin = cfg.host.os === "windows";
|
||
assert(cfg.cargo !== undefined, `dep "${name}" requires cargo but no rust toolchain was found`, {
|
||
hint: "Install rust: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh",
|
||
});
|
||
|
||
const { srcDir, sourceStamp } = input;
|
||
const manifestDir = resolve(srcDir, spec.manifestDir);
|
||
const targetDir = depBuildDir(cfg, name);
|
||
const profile = cfg.release ? "release" : "debug";
|
||
|
||
// ─── Resolve output path ───
|
||
// Cargo's staticlib output layout:
|
||
// <target-dir>/<profile>/{lib}<name>.{a,lib} (no --target)
|
||
// <target-dir>/<triple>/<profile>/{lib}<name>.{a,lib} (with --target)
|
||
// Follows platform convention (cfg.libPrefix/libSuffix).
|
||
const outSubdir = spec.rustTarget ? join(spec.rustTarget, profile) : profile;
|
||
const lib = resolve(targetDir, outSubdir, `${cfg.libPrefix}${spec.libName}${cfg.libSuffix}`);
|
||
|
||
// ─── Build args ───
|
||
const args: string[] = ["--locked", "--target-dir", targetDir];
|
||
if (cfg.release) args.push("--release");
|
||
if (spec.rustTarget) args.push("--target", spec.rustTarget);
|
||
if (spec.buildStd) args.push("-Zbuild-std=std,panic_abort");
|
||
|
||
// ─── Environment ───
|
||
// CARGO_ENCODED_RUSTFLAGS: the separator is U+001F (unit separator), not
|
||
// space. This is cargo's way of passing multi-argument flags unambiguously.
|
||
const env: Record<string, string> = {
|
||
CARGO_TERM_COLOR: "always",
|
||
};
|
||
if (cfg.cargoHome !== undefined) env.CARGO_HOME = cfg.cargoHome;
|
||
if (cfg.rustupHome !== undefined) env.RUSTUP_HOME = cfg.rustupHome;
|
||
// Pin the toolchain explicitly. `vendor/` is commonly a symlink shared
|
||
// across worktrees; rustup's directory walk from manifestDir resolves
|
||
// through the symlink and picks up the *target* worktree's
|
||
// `rust-toolchain.toml`. The dep then bundles a libstd that doesn't match
|
||
// the workspace staticlib's, and the link dies on duplicate
|
||
// `rust_eh_personality`. RUSTUP_TOOLCHAIN overrides the directory walk.
|
||
if (cfg.rustToolchain !== undefined) env.RUSTUP_TOOLCHAIN = cfg.rustToolchain;
|
||
|
||
// Path remapping (CI reproducibility) — mirrors the C/C++
|
||
// `-ffile-prefix-map` entries in flags.ts and the same block in rust.ts,
|
||
// so vendored Rust deps built here (lol-html) don't embed the absolute
|
||
// checkout path in `file!()`/panic locations/debuginfo either.
|
||
const rustflags: string[] = [...(spec.rustflags ?? [])];
|
||
if (cfg.ci) {
|
||
rustflags.push(`--remap-path-prefix=${cfg.cwd}=.`);
|
||
rustflags.push(`--remap-path-prefix=${cfg.vendorDir}=vendor`);
|
||
}
|
||
|
||
if (rustflags.length > 0) {
|
||
// The \x1f encoding is deliberate — see cargo's docs on CARGO_ENCODED_RUSTFLAGS.
|
||
env.CARGO_ENCODED_RUSTFLAGS = rustflags.join("\x1f");
|
||
}
|
||
|
||
// Windows: pin the linker to MSVC's link.exe. Without this, if Git Bash
|
||
// is in PATH, its /usr/bin/link (GNU hard-link tool) shadows the real
|
||
// linker and cargo's link step fails with a baffling error.
|
||
if (cfg.windows && cfg.msvcLinker !== undefined) {
|
||
// Triple-specific linker env var. Cargo reads CARGO_TARGET_<TRIPLE>_LINKER
|
||
// where <TRIPLE> is uppercased with hyphens→underscores.
|
||
const triple = spec.rustTarget ?? (cfg.arm64 ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc");
|
||
const envKey = `CARGO_TARGET_${triple.toUpperCase().replace(/-/g, "_")}_LINKER`;
|
||
env[envKey] = cfg.msvcLinker;
|
||
}
|
||
|
||
// Cross-compile (Android): cargo's default `cc` linker can't handle the
|
||
// foreign ELF objects. Use our clang as the linker driver and pass
|
||
// --target/--sysroot through, same as the C/C++ deps do via globalFlags.
|
||
// The cdylib output also wants -lunwind, which lives in the NDK's
|
||
// bundled clang resource dir (not the sysroot), so we add that -L too.
|
||
if (cfg.crossTarget !== undefined && spec.rustTarget !== undefined) {
|
||
const envKey = `CARGO_TARGET_${spec.rustTarget.toUpperCase().replace(/-/g, "_")}_LINKER`;
|
||
env[envKey] = cfg.cc;
|
||
const linkArgs = [`-Clink-arg=--target=${cfg.crossTarget}`];
|
||
if (cfg.sysroot !== undefined) linkArgs.push(`-Clink-arg=--sysroot=${cfg.sysroot}`);
|
||
if (cfg.androidNdkRuntimeDir !== undefined) {
|
||
const llvmArch = cfg.arm64 ? "aarch64" : "x86_64";
|
||
linkArgs.push(`-Clink-arg=-L${join(cfg.androidNdkRuntimeDir, llvmArch)}`);
|
||
}
|
||
env.CARGO_ENCODED_RUSTFLAGS = [...rustflags, ...linkArgs].join("\x1f");
|
||
}
|
||
|
||
// ─── Emit build node ───
|
||
// dep_cargo_cross prepends `rustup target add` for Tier 2 cross targets.
|
||
// Tier 3 targets (buildStd=true) have no prebuilt std, so target-add would
|
||
// fail — they use plain dep_cargo with -Zbuild-std instead.
|
||
const cross = cfg.crossTarget !== undefined && spec.rustTarget !== undefined && !spec.buildStd;
|
||
n.build({
|
||
outputs: [lib],
|
||
rule: cross ? "dep_cargo_cross" : "dep_cargo",
|
||
inputs: [],
|
||
// Rebuild if source changed, cargo binary changed, or the pinned
|
||
// toolchain changed. Cargo's own dependency tracking handles file-level
|
||
// granularity below manifestDir. `rust-toolchain.toml` matters because
|
||
// the dep's staticlib bundles a copy of libstd — if the workspace
|
||
// staticlib is built with a different nightly, the two archives carry
|
||
// mismatched std hashes and both get pulled into the link, colliding on
|
||
// unmangled symbols like `rust_eh_personality`.
|
||
implicitInputs: [sourceStamp, cfg.cargo, resolve(cfg.cwd, "rust-toolchain.toml")],
|
||
vars: {
|
||
name,
|
||
manifestdir: manifestDir,
|
||
args: quoteArgs(args, hostWin),
|
||
...(cross ? { rust_target: spec.rustTarget! } : {}),
|
||
// stream.ts's --env=K=V format. Values platform-quoted since ninja
|
||
// passes the command line through the host's argv parser; stream.ts
|
||
// receives them as proper argv entries.
|
||
env: Object.entries(env)
|
||
.map(([k, v]) => `--env=${k}=${quote(v, hostWin)}`)
|
||
.join(" "),
|
||
},
|
||
});
|
||
n.phony(name, [lib]);
|
||
|
||
return { libs: [lib] };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Direct — compile sources inline into our ninja graph
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface EmitDirectInput {
|
||
srcDir: string;
|
||
/** Fetch `.ref` stamp; undefined for local/in-tree sources (already on disk). */
|
||
sourceStamp: string | undefined;
|
||
fetchDepStamps: string[];
|
||
}
|
||
|
||
/**
|
||
* Compile a dep's sources directly — no cmake/cargo sub-process.
|
||
*
|
||
* Each .c becomes a `cc` build edge with the same global flags nested-cmake
|
||
* deps get (computeDepFlags), so ASAN/opt/target stay consistent with the
|
||
* rest of the build. Objects land under obj/vendor/<name>/ (via objectPath)
|
||
* and get archived into buildDir/deps/<name>/lib<name>.a.
|
||
*
|
||
* If spec.codegen is set, first compile+link the tool WITHOUT sanitizers,
|
||
* run it to produce the header, and make all library objects depend on it.
|
||
* The no-sanitize policy is the point of DirectBuild existing: host tools
|
||
* are disposable and inherit compiler-rt/OS incompatibilities for no gain.
|
||
*/
|
||
function emitDirect(
|
||
n: Ninja,
|
||
cfg: Config,
|
||
name: string,
|
||
spec: DirectBuild,
|
||
input: EmitDirectInput,
|
||
): { libs: string[]; objects: string[]; headerOutputs: string[] } {
|
||
const { srcDir, sourceStamp, fetchDepStamps } = input;
|
||
const buildDir = depBuildDir(cfg, name);
|
||
const hostWin = cfg.host.os === "windows";
|
||
const q = (p: string) => quote(p, hostWin);
|
||
|
||
n.comment(`─── ${name} (direct) ───`);
|
||
|
||
// Library flags: globals (includes ASAN when cfg.asan) + dep's own includes
|
||
// and defines. Same base as what gets forwarded to nested cmake via
|
||
// CMAKE_C_FLAGS / CMAKE_CXX_FLAGS. `lang` picks the flag set; the compile
|
||
// function is chosen per-file by extension below.
|
||
const depFlags = computeDepFlags(cfg);
|
||
const isCxx = spec.lang === "cxx";
|
||
const baseFlags = isCxx ? depFlags.cxxflags : depFlags.cflags;
|
||
|
||
// PIC: mirror emitNestedCmake's handling so direct deps get the same
|
||
// codegen as cmake deps would. spec.pic → -fPIC; otherwise on darwin
|
||
// undo apple-clang's PIC default to match the non-PIE final binary.
|
||
const picFlags: string[] = [];
|
||
if (spec.pic) {
|
||
if (!cfg.windows) picFlags.push("-fPIC");
|
||
} else if (cfg.darwin) {
|
||
picFlags.push("-fno-pic", "-fno-pie");
|
||
}
|
||
|
||
const incFlags = (spec.includes ?? []).map(i => `-I${q(resolve(srcDir, i))}`);
|
||
const defFlags = Object.entries(spec.defines ?? {}).map(([k, v]) => defineFlag(k, v));
|
||
const libFlags = [...baseFlags, ...picFlags, ...incFlags, ...defFlags, ...(spec.cflags ?? [])];
|
||
|
||
// Sources must exist before compile attempts. sourceStamp (or the fetch
|
||
// .ref) is order-only: we don't want every .o recompiling when the stamp
|
||
// mtime bumps but the .c files are unchanged — the depfile knows better.
|
||
const orderOnly = [...(sourceStamp === undefined ? [] : [sourceStamp]), ...fetchDepStamps];
|
||
|
||
// ─── Generated headers (optional) ───
|
||
// Literal-string headers are written at configure time via writeIfChanged
|
||
// (mtime only moves when contents change). HeaderSubst headers become
|
||
// ninja edges — their template lives in srcDir, which doesn't exist
|
||
// until fetch runs. Either way buildDir goes on -I and the outputs are
|
||
// implicit inputs to every cc edge.
|
||
const headers = Object.entries(spec.headers ?? {});
|
||
const needsBuildDirInc = headers.length > 0 || spec.codegen !== undefined;
|
||
const generated: string[] = [];
|
||
if (headers.length > 0) {
|
||
mkdirSync(buildDir, { recursive: true });
|
||
for (const [h, body] of headers) {
|
||
const out = resolve(buildDir, h);
|
||
if (typeof body === "string") {
|
||
writeIfChanged(out, body === "" ? "/* stub — generated at configure */\n" : body);
|
||
} else {
|
||
n.build({
|
||
outputs: [out],
|
||
rule: "dep_subst",
|
||
inputs: [resolve(srcDir, body.from)],
|
||
implicitInputs: [fetchCliPath],
|
||
orderOnlyInputs: orderOnly,
|
||
vars: { pairs: quoteArgs((body.replace ?? []).flat(), hostWin) },
|
||
});
|
||
generated.push(out);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Codegen (optional) ───
|
||
let generatedHeader: string | undefined;
|
||
if (spec.codegen !== undefined) {
|
||
const cg = spec.codegen;
|
||
const toolSrc = resolve(srcDir, cg.tool);
|
||
// Host exe suffix: clang on Windows auto-appends .exe to `-o foo`, so
|
||
// the ninja output name must match or the edge is permanently dirty.
|
||
const toolOut = resolve(buildDir, `codegen-tool${cfg.host.exeSuffix}`);
|
||
|
||
// Host tool: runs at build time to generate headers, so it must target
|
||
// the BUILD host, not the bun target. cc()/link() add cfg's target/arch
|
||
// flags which break cross-compiles (musl CI: "file format not
|
||
// recognized"). Emit a bare clang invocation instead — no opt, no
|
||
// target triple, just the tool defines and -w. Compile+link in one go
|
||
// so host-arch objects never land in obj/ (which would dirty ccache
|
||
// for the target build).
|
||
const toolDefs = Object.entries(cg.toolDefines ?? {}).map(([k, v]) => defineFlag(k, v));
|
||
n.build({
|
||
outputs: [toolOut],
|
||
rule: "dep_host_cc",
|
||
inputs: [toolSrc],
|
||
orderOnlyInputs: orderOnly,
|
||
vars: { flags: ["-w", ...toolDefs].join(" ") },
|
||
});
|
||
const toolExe = toolOut;
|
||
|
||
// Run tool. "$out" in args expands to the generated header's absolute
|
||
// path; other args resolve against srcDir. Tool runs with srcDir as
|
||
// cwd so relative input paths it opens internally Just Work.
|
||
generatedHeader = resolve(buildDir, cg.output);
|
||
const argv = cg.args.map(a => (a === "$out" ? generatedHeader! : resolve(srcDir, a)));
|
||
|
||
n.build({
|
||
outputs: [generatedHeader],
|
||
rule: "dep_codegen",
|
||
inputs: [toolExe],
|
||
vars: {
|
||
name,
|
||
cwd: srcDir,
|
||
tool: q(toolExe),
|
||
args: quoteArgs(argv, hostWin),
|
||
},
|
||
});
|
||
}
|
||
|
||
// ─── Compile + archive ───
|
||
// Generated headers (codegen + subst) are implicit inputs to every .o —
|
||
// library sources include them. buildDir goes on -I so #include "foo.h"
|
||
// finds literal, subst, and codegen headers alike.
|
||
if (generatedHeader !== undefined) generated.push(generatedHeader);
|
||
const implicit = generated;
|
||
const genInc = needsBuildDirInc ? [`-I${q(buildDir)}`] : [];
|
||
|
||
const objects = spec.sources.map(s => {
|
||
const path = typeof s === "string" ? s : s.path;
|
||
const extra = typeof s === "string" ? [] : s.cflags;
|
||
const abs = resolve(srcDir, path);
|
||
// .asm → nasm() (NASM syntax, Windows-x64). .c/.S → cc() (clang's
|
||
// integrated assembler handles .S), prepending `-x c++` when lang:"cxx"
|
||
// forces a C source through the C++ frontend (mimalloc). Everything
|
||
// else (.cc/.cpp/.cxx) → cxx().
|
||
if (path.endsWith(".asm")) {
|
||
return nasm(n, cfg, abs, { flags: [...(spec.nasmflags ?? []), ...extra], orderOnlyInputs: orderOnly });
|
||
}
|
||
const isC = path.endsWith(".c");
|
||
const isAsm = path.endsWith(".S");
|
||
const opts = {
|
||
flags: [...(isC && isCxx ? ["-x", "c++"] : []), ...libFlags, ...genInc, ...extra],
|
||
orderOnlyInputs: orderOnly,
|
||
implicitInputs: implicit,
|
||
};
|
||
return isC || isAsm ? cc(n, cfg, abs, opts) : cxx(n, cfg, abs, opts);
|
||
});
|
||
|
||
// Default: hand the objects straight to bun's link line — no intermediate
|
||
// archive. With cfg.archiveDeps the old per-dep .a is produced instead
|
||
// (useful for bisecting duplicate-symbol issues, since a .a only
|
||
// contributes members the linker actually pulls).
|
||
if (cfg.archiveDeps) {
|
||
// ar's output dir + the per-source obj dirs both need pre-creating —
|
||
// configure.ts:mkdirAll only sees `output.objects`, which is empty
|
||
// in this branch.
|
||
mkdirSync(buildDir, { recursive: true });
|
||
for (const o of objects) mkdirSync(resolve(o, ".."), { recursive: true });
|
||
const lib = ar(n, cfg, join("deps", name, `${cfg.libPrefix}${name}${cfg.libSuffix}`), objects);
|
||
n.phony(name, [lib]);
|
||
return { libs: [lib], objects: [], headerOutputs: [lib] };
|
||
}
|
||
n.phony(name, objects);
|
||
// headerOutputs: what downstream needs to wait on for HEADERS to be
|
||
// ready. For no-archive direct deps that's the generated header set
|
||
// (subst/literal/codegen) plus the fetch stamp — not the .o files.
|
||
return { libs: [], objects, headerOutputs: sourceStamp === undefined ? generated : [...generated, sourceStamp] };
|
||
}
|
||
|
||
/**
|
||
* Format a -D flag. String values become shell-escaped C string literals
|
||
* (-DNAME=\"val\" → compiler sees "val"); numbers/true pass through bare.
|
||
*/
|
||
function defineFlag(name: string, value: string | number | true): string {
|
||
if (value === true) return `-D${name}`;
|
||
if (typeof value === "number") return `-D${name}=${value}`;
|
||
return `-D${name}=\\"${value}\\"`;
|
||
}
|