diff --git a/docs/bundler/executables.mdx b/docs/bundler/executables.mdx index 77cb6a6ba3c4..224a54763490 100644 --- a/docs/bundler/executables.mdx +++ b/docs/bundler/executables.mdx @@ -228,6 +228,8 @@ To build for macOS x64: The segments of the `--target` value can appear in any order, as long as they're delimited by `-`. +Linux targets also accept a libc segment such as `-glibc` or `-musl` (for example `bun-linux-x64-glibc`), which selects that libc's build of Bun no matter which build is running. Without one, a Linux target uses the libc of the `bun` running the build: the table below describes a glibc build of Bun, while a musl build of Bun turns `bun-linux-x64` into a musl executable. + | --target | Operating System | Architecture | Modern | Baseline | Libc | | -------------------- | ---------------- | ------------ | ------ | -------- | ----- | | bun-linux-x64 | Linux | x64 | ✅ | ✅ | glibc | @@ -1282,6 +1284,8 @@ type CompileTarget = | "bun-linux-x64-baseline" | "bun-linux-x64-modern" | "bun-linux-arm64" + | "bun-linux-x64-glibc" + | "bun-linux-arm64-glibc" | "bun-linux-x64-musl" | "bun-linux-arm64-musl" | "bun-windows-x64" diff --git a/src/options_types/compile_target.rs b/src/options_types/compile_target.rs index 1875cab88df8..09b2d4815d96 100644 --- a/src/options_types/compile_target.rs +++ b/src/options_types/compile_target.rs @@ -49,7 +49,7 @@ impl Default for CompileTarget { } #[repr(u8)] -#[derive(Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)] pub enum Libc { /// The default libc for the target /// "glibc" for linux, unspecified for other OSes @@ -77,6 +77,15 @@ impl fmt::Display for Libc { } } +bun_core::comptime_string_map! { + /// `--target` segments that pick a libc; only meaningful together with linux. + static LIBC_NAMES: Libc = { + b"glibc" => Libc::Default, + b"musl" => Libc::Musl, + b"android" => Libc::Android, + }; +} + struct BaselineFormatter { baseline: bool, } @@ -90,12 +99,49 @@ impl fmt::Display for BaselineFormatter { } } -#[derive(thiserror::Error, Debug, strum::IntoStaticStr)] -pub enum ParseError { - #[error("UnsupportedTarget")] - UnsupportedTarget, - #[error("InvalidTarget")] - InvalidTarget, +/// Why a `--target` string was rejected. `Display` is the CLI error message. +#[derive(thiserror::Error, Debug, Clone, Copy)] +pub enum ParseError<'a> { + /// `segment` of `input` is not an architecture, OS, CPU tier, libc or version. + UnsupportedSegment { + segment: &'a [u8], + input: &'a [u8], + }, + /// A `-vX.Y.Z` segment missing one of its three components. + IncompleteVersion, + /// A libc segment was combined with a non-linux OS. + LibcRequiresLinux(Libc), + Wasm, +} + +impl fmt::Display for ParseError<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + ParseError::UnsupportedSegment { segment, input } => write!( + f, + "Unsupported target {} in \"bun{}\"\n\ + To see the supported targets:\n \ + https://bun.com/docs/bundler/executables", + bun_fmt::quote(segment), + bstr::BStr::new(input), + ), + ParseError::IncompleteVersion => write!( + f, + "Please pass a complete version number to --target. For example, --target=bun-v{}", + Environment::VERSION_STRING, + ), + ParseError::LibcRequiresLinux(Libc::Default) => { + f.write_str("invalid target, glibc only exists on linux") + } + ParseError::LibcRequiresLinux(Libc::Musl) => { + f.write_str("invalid target, musl libc only exists on linux") + } + ParseError::LibcRequiresLinux(Libc::Android) => f.write_str( + "invalid target, android only exists with linux (use bun-linux-arm64-android)", + ), + ParseError::Wasm => f.write_str("invalid target, WebAssembly is not supported. Sorry!"), + } + } } impl CompileTarget { @@ -238,7 +284,7 @@ impl CompileTarget { } } - pub fn try_from(input_: &[u8]) -> Result { + pub fn try_from(input_: &[u8]) -> Result> { let mut this = CompileTarget::default(); let input = strings::trim(input_, b" \t\r"); if input.is_empty() { @@ -284,7 +330,7 @@ impl CompileTarget { || version.version.minor.is_none() || version.version.patch.is_none() { - return Err(ParseError::InvalidTarget); + return Err(ParseError::IncompleteVersion); } this.version = Version { @@ -297,16 +343,15 @@ impl CompileTarget { _found_version = true; continue; } - } else if token == b"musl" { - this.libc = Libc::Musl; - found_libc = true; - continue; - } else if token == b"android" { - this.libc = Libc::Android; + } else if let Some(libc) = LIBC_NAMES.get(token) { + this.libc = *libc; found_libc = true; continue; } else { - return Err(ParseError::UnsupportedTarget); + return Err(ParseError::UnsupportedSegment { + segment: token, + input: input_, + }); } } @@ -328,12 +373,13 @@ impl CompileTarget { this.baseline = false; } - if this.libc != Libc::Default && this.os != OperatingSystem::Linux { - return Err(ParseError::InvalidTarget); + // Not `libc != Default`: an explicit "glibc" parses to `Default` and is just as invalid off linux. + if found_libc && this.os != OperatingSystem::Linux { + return Err(ParseError::LibcRequiresLinux(this.libc)); } if this.arch == Architecture::Wasm || this.os == OperatingSystem::Wasm { - return Err(ParseError::InvalidTarget); + return Err(ParseError::Wasm); } Ok(this) @@ -342,61 +388,8 @@ impl CompileTarget { pub fn from(input_: &[u8]) -> CompileTarget { match Self::try_from(input_) { Ok(t) => t, - Err(ParseError::UnsupportedTarget) => { - let input = strings::trim(input_, b" \t\r"); - let mut splitter = strings::split(input, b"-"); - let mut unsupported_token: Option<&[u8]> = None; - while let Some(token) = splitter.next() { - if token.is_empty() { - continue; - } - if ARCHITECTURE_NAMES.get(token).is_none() - && OPERATING_SYSTEM_NAMES.get(token).is_none() - && token != b"modern" - && token != b"baseline" - && token != b"musl" - && token != b"android" - && !(strings::has_prefix(token, b"v1.") - || strings::has_prefix(token, b"v0.")) - { - unsupported_token = Some(token); - break; - } - } - - if let Some(token) = unsupported_token { - bun_core::err_generic!( - "Unsupported target {} in \"bun{}\"\n\ - To see the supported targets:\n \ - https://bun.com/docs/bundler/executables", - bun_fmt::quote(token), - bstr::BStr::new(input_), - ); - } else { - bun_core::err_generic!("Unsupported target: {}", bstr::BStr::new(input_)); - } - Global::exit(1); - } - Err(ParseError::InvalidTarget) => { - let input = strings::trim(input_, b" \t\r"); - if strings::contains(input, b"musl") && !strings::contains(input, b"linux") { - bun_core::err_generic!("invalid target, musl libc only exists on linux"); - } else if strings::contains(input, b"android") - && !strings::contains(input, b"linux") - { - bun_core::err_generic!( - "invalid target, android only exists with linux (use bun-linux-arm64-android)" - ); - } else if strings::contains(input, b"wasm") { - bun_core::err_generic!("invalid target, WebAssembly is not supported. Sorry!"); - } else if strings::contains(input, b"v") { - bun_core::err_generic!( - "Please pass a complete version number to --target. For example, --target=bun-v{}", - Environment::VERSION_STRING, - ); - } else { - bun_core::err_generic!("Invalid target: {}", bstr::BStr::new(input_)); - } + Err(err) => { + bun_core::err_generic!("{}", err); Global::exit(1); } } diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 6d6c2055012f..7d8a359b781b 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isArm64, isLinux, isMacOS, isMusl, isPosix, isWindows, tempDir } from "harness"; -import { chmodSync, closeSync, cpSync, existsSync, openSync, readSync } from "node:fs"; +import { bunEnv, bunExe, isArm64, isGlibc, isLinux, isMacOS, isMusl, isPosix, isWindows, tempDir } from "harness"; +import { chmodSync, closeSync, cpSync, existsSync, openSync, readdirSync, readSync } from "node:fs"; import { join } from "path"; describe("Bun.build compile", () => { @@ -122,6 +122,211 @@ describe("Bun.build compile", () => { }); }); +// `Bun.Build.Libc` is "glibc" | "musl". "-glibc" selects the glibc build of bun, which on a glibc host +// is the running binary itself, so the accepting tests below never download anything. +describe("compile target libc segments", () => { + const arch = isArm64 ? "arm64" : "x64"; + // Targets are named `-vX.Y.Z`; Bun.version additionally carries `-debug` / `-canary.N` suffixes. + const version = Bun.version.split("-")[0]; + // Tests that actually compile copy and rewrite the whole bun binary (~1GB under debug+ASAN), which + // blows the 5s default; the parse-only tests below stay on the default. + const compileTimeout = 60_000; + // `bun build --compile` prints one timed line per stage; only the timings vary. A cross-compile + // additionally appends the resolved target to the compile line, so these summaries also assert + // that the target was the running binary. + const stages = (stdout: string) => stdout.replace(/^\s*\[[\d.]+m?s\] +/gm, ""); + const bundledAndCompiled = "bundle 1 modules\ncompile app\n"; + + test.skipIf(!isGlibc)( + "Bun.build accepts a bun-linux--glibc target", + async () => { + using dir = tempDir("build-compile-glibc-api", { + "app.js": `console.log("glibc-target-ok");`, + }); + + const result = await Bun.build({ + entrypoints: [join(String(dir), "app.js")], + compile: { + target: `bun-linux-${arch}-glibc`, + outfile: join(String(dir), "app"), + }, + }); + expect(result.success).toBe(true); + + await using proc = Bun.spawn({ + cmd: [result.outputs[0].path], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "glibc-target-ok\n", stderr: "", exitCode: 0 }); + }, + compileTimeout, + ); + + test.skipIf(!isGlibc)( + "bun build --compile accepts --target=bun-linux--glibc", + async () => { + using dir = tempDir("build-compile-glibc-cli", { + "app.js": `console.log("glibc-target-ok");`, + }); + + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "--compile", `--target=bun-linux-${arch}-glibc`, "app.js", "--outfile", "app"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [buildStdout, buildStderr, buildExitCode] = await Promise.all([ + build.stdout.text(), + build.stderr.text(), + build.exited, + ]); + expect({ stdout: stages(buildStdout), stderr: buildStderr, exitCode: buildExitCode }).toEqual({ + stdout: bundledAndCompiled, + stderr: "", + exitCode: 0, + }); + + await using proc = Bun.spawn({ + cmd: [join(String(dir), "app")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "glibc-target-ok\n", stderr: "", exitCode: 0 }); + }, + compileTimeout, + ); + + // Rejected while parsing the target, on every host. The message has to describe what the parser + // actually tripped on, not whichever libc word happens to appear somewhere in the target. + test.concurrent.each([ + ["bun-windows-x64-glibc", "error: invalid target, glibc only exists on linux\n"], + ["bun-windows-x64-musl", "error: invalid target, musl libc only exists on linux\n"], + [ + "bun-darwin-arm64-android", + "error: invalid target, android only exists with linux (use bun-linux-arm64-android)\n", + ], + [ + "bun-linux-x64-glibc-invalid", + 'error: Unsupported target "invalid" in "bun-linux-x64-glibc-invalid"\n' + + "To see the supported targets:\n" + + " https://bun.com/docs/bundler/executables\n", + ], + [ + "bun-x64-glibc-v1.2", + `error: Please pass a complete version number to --target. For example, --target=bun-v${version}\n`, + ], + ["bun-wasm", "error: invalid target, WebAssembly is not supported. Sorry!\n"], + ])("bun build --compile rejects --target=%s", async (target, expectedStderr) => { + using dir = tempDir("build-compile-target-invalid", { + "app.js": `console.log("unreachable");`, + }); + + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "--compile", `--target=${target}`, "app.js", "--outfile", "app"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); + expect({ stdout, stderr, exitCode, files: readdirSync(String(dir)) }).toEqual({ + stdout: "", + stderr: expectedStderr, + exitCode: 1, + files: ["app.js"], + }); + }); + + test.each(["bun-darwin-arm64-glibc", "bun-linux-x64-glibc-invalid"])("Bun.build rejects target %s", target => { + using dir = tempDir("build-compile-target-invalid-api", { + "app.js": `console.log("unreachable");`, + }); + + expect(() => + Bun.build({ + entrypoints: [join(String(dir), "app.js")], + compile: { + target: target as Bun.Build.CompileTarget, + outfile: join(String(dir), "app"), + }, + }), + ).toThrow(new TypeError(`Unknown compile target: ${target}`)); + }); + + // Which libc a target resolved to is only visible from a bun built against the other one: the + // running bun's own libc is satisfied by the running binary, anything else has to be fetched. + // The fetch is pointed at a local 404 server, so instead of downloading, the build fails naming + // the bun it resolved to. + describe.skipIf(!isLinux)("libc selection", () => { + const npmArch = isArm64 ? "aarch64" : "x64"; + const [otherLibc, otherSuffix] = isMusl ? ["glibc", ""] : ["musl", "-musl"]; + + async function compileFor(target: string) { + using dir = tempDir("build-compile-libc-selection", { + "app.js": `console.log("libc-selection");`, + }); + let fetches = 0; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch() { + fetches++; + return new Response(null, { status: 404 }); + }, + }); + + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "--compile", `--target=${target}`, "app.js", "--outfile", "app"], + env: { + ...bunEnv, + BUN_COMPILE_TARGET_TARBALL_URL: server.url.href, + BUN_INSTALL_CACHE_DIR: join(String(dir), "cache"), + // A CI proxy must not sit between the build and the local server. + HTTP_PROXY: undefined, + http_proxy: undefined, + HTTPS_PROXY: undefined, + https_proxy: undefined, + }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); + return { stdout: stages(stdout), stderr, exitCode, fetches }; + } + + test( + "a Linux target without a libc segment is the running bun's libc", + async () => { + expect(await compileFor(`bun-linux-${arch}`)).toEqual({ + stdout: bundledAndCompiled, + stderr: "", + exitCode: 0, + fetches: 0, + }); + }, + compileTimeout, + ); + + test("a libc segment for the other libc selects that build instead of the running bun", async () => { + expect(await compileFor(`bun-linux-${arch}-${otherLibc}`)).toEqual({ + stdout: "bundle 1 modules\n", + stderr: + `Target platform 'bun-linux-${npmArch}${otherSuffix}-v${version}' is not available for download. ` + + "Check if this version of Bun supports this target.\n", + exitCode: 1, + fetches: 1, + }); + }); + }); +}); + describe("compiled binary validity", () => { test("output binary has valid executable header", async () => { using dir = tempDir("build-compile-valid-header", {