diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index b117a316ffa..2cb72f1af3e 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2160,6 +2160,19 @@ pub mod bv2_impl { ); } + /// Callers require an entry point, so none after parsing means one was dropped without an error. + fn fail_if_no_entry_points(&self) -> Result<(), Error> { + if !self.graph.entry_points.is_empty() { + return Ok(()); + } + self.transpiler.log_mut().add_error( + None, + bun_ast::Loc::EMPTY, + "None of the entry points could be bundled", + ); + Err(crate::Error::BuildFailed) + } + /// `BUN_THREADPOOL_STATS=1` instrumentation hook — dump aggregate worker /// idle/busy time since the previous call. No-op when env var unset. #[inline] @@ -3911,10 +3924,7 @@ pub mod bv2_impl { // sidestep for the `&mut self` overlap. this.enqueue_entry_points_normal(unsafe { &*entry_points })?; - if this.transpiler.log().has_errors() { - return Err(crate::Error::BuildFailed); - } - + // Like `run_from_js_in_new_thread`: drain the pool, then report entry point errors. this.wait_for_parse(); this.dump_pool_stats("parse"); @@ -3926,6 +3936,7 @@ pub mod bv2_impl { if this.transpiler.log().has_errors() { return Err(crate::Error::BuildFailed); } + this.fail_if_no_entry_points()?; this.scan_for_secondary_paths(); @@ -4085,10 +4096,7 @@ pub mod bv2_impl { this.enqueue_entry_points_bake_production(entry_points)?; - if this.transpiler.log().has_errors() { - return Err(crate::Error::BuildFailed); - } - + // Drain the pool, then report entry point errors (as `generate_from_cli` does). this.wait_for_parse(); if this.transpiler.log().has_errors() { @@ -5054,6 +5062,7 @@ pub mod bv2_impl { if self.transpiler.log().errors > 0 { return Err(crate::Error::BuildFailed); } + self.fail_if_no_entry_points()?; self.scan_for_secondary_paths(); diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index c0b455f7fa5..a7354cb0056 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -458,7 +458,7 @@ impl<'a> Transpiler<'a> { /// retrying once on failure before reporting the error to the log. pub fn resolve_entry_point(&mut self, entry_point: &[u8]) -> crate::Result { match self._resolve_entry_point(entry_point) { - Ok(r) => Ok(r), + Ok(r) => self.reject_disabled_entry_point(r, entry_point), // Nothing that long names a directory whose cache could be stale // (and the join below has a PathBuffer to fit `top_level_dir/entry/..` in). Err(err) @@ -515,7 +515,7 @@ impl<'a> Transpiler<'a> { // Only re-query if we previously had something cached. if busted { if let Ok(result) = self._resolve_entry_point(entry_point) { - return Ok(result); + return self.reject_disabled_entry_point(result, entry_point); } // ignore this error, we will print the original error } @@ -534,6 +534,39 @@ impl<'a> Transpiler<'a> { } } + /// A disabled module (no usable path) imports as `{}`, but an entry point has nothing to emit. + fn reject_disabled_entry_point( + &self, + resolved: resolver::Result, + entry_point: &[u8], + ) -> crate::Result { + if resolved.path_const().is_some() { + return Ok(resolved); + } + + // Stubbed builtins carry the "node" namespace; anything else came from a "browser" map. + if resolved.path_pair.primary.namespace == b"node" { + self.log_mut().add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "Cannot use Node.js builtin \"{}\" as an entry point", + bstr::BStr::new(entry_point) + ), + ); + } else { + self.log_mut().add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "\"{}\" is disabled due to \"browser\" field in package.json (entry point)", + bstr::BStr::new(entry_point) + ), + ); + } + Err(crate::Error::ResolveMessage) + } + /// Load env files and build `options.define`. Idempotent — a no-op once /// `options.defines_loaded` is set. pub fn configure_defines(&mut self) -> crate::Result<()> { diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 8be40eb1b8a..5f5d22de4ac 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -214,6 +214,58 @@ describe("Bun.build", () => { } }); + // Runs in a child because the unfixed behavior was a process abort: the + // disabled entry point was dropped without an error and the linker ran with + // zero entry points. + test.concurrent("an entry point disabled by the package.json browser field is a build error", async () => { + using dir = tempDir("build-entry-point-disabled-by-browser-field", { + "package.json": JSON.stringify({ name: "app", browser: { "./entry.js": false } }), + "entry.js": `console.log("entry");`, + "build.mjs": ` + const returned = await Bun.build({ entrypoints: ["./entry.js"], target: "browser", throw: false }); + let thrown; + try { + await Bun.build({ entrypoints: ["./entry.js"], target: "browser" }); + } catch (e) { + thrown = { + isAggregateError: e instanceof AggregateError, + errors: e.errors.map(error => ({ name: error.name, level: error.level, position: error.position, message: error.message })), + }; + } + console.log(JSON.stringify({ + returned: { + success: returned.success, + outputs: returned.outputs.length, + logs: returned.logs.map(log => ({ name: log.name, level: log.level, position: log.position, message: log.message })), + }, + thrown, + })); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const message = { + name: "BuildMessage", + level: "error", + position: null, + message: '"./entry.js" is disabled due to "browser" field in package.json (entry point)', + }; + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + returned: { success: false, outputs: 0, logs: [message] }, + thrown: { isAggregateError: true, errors: [message] }, + }); + expect(exitCode).toBe(0); + }); + test("returns output files", async () => { Bun.gc(true); const build = await Bun.build({ diff --git a/test/bundler/bundler_browser.test.ts b/test/bundler/bundler_browser.test.ts index 104fee29e02..5804374078d 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -374,6 +374,87 @@ describe("bundler", () => { }, }); + // An entry point the "browser" field maps to false has nothing to bundle. + // This used to reach the linker with zero entry points and crash + // ("index out of bounds" in generateChunksInParallel); with a second, live + // entry point it silently built only that one. + const browserFieldDisabledEntryPointFiles = { + "/package.json": /* json */ ` + { "name": "app", "browser": { "./entry.js": false } } + `, + "/entry.js": /* js */ ` + console.log("entry"); + `, + "/other.js": /* js */ ` + console.log("other"); + `, + }; + itBundled("browser/EntryPointDisabledByBrowserField", { + skipOnEsbuild: true, + backend: "cli", + files: browserFieldDisabledEntryPointFiles, + entryPointsRaw: ["./entry.js"], + target: "browser", + bundleErrors: { + "": ['"./entry.js" is disabled due to "browser" field in package.json (entry point)'], + }, + }); + itBundled("browser/EntryPointDisabledByBrowserFieldNextToLiveEntryPoint", { + skipOnEsbuild: true, + backend: "cli", + files: browserFieldDisabledEntryPointFiles, + entryPointsRaw: ["./entry.js", "./other.js"], + target: "browser", + bundleErrors: { + "": ['"./entry.js" is disabled due to "browser" field in package.json (entry point)'], + }, + }); + itBundled("browser/EntryPointDisabledByBrowserFieldOnlyAppliesToBrowserTarget", { + skipOnEsbuild: true, + backend: "cli", + files: browserFieldDisabledEntryPointFiles, + entryPointsRaw: ["./entry.js"], + target: "bun", + run: { + file: "/out/entry.js", + stdout: "entry", + }, + }); + itBundled("browser/EntryPointDisabledByPackageMainBrowserField", { + // The disabled module is reached through a package's "main", so the entry + // point specifier and the disabled file differ. + skipOnEsbuild: true, + backend: "cli", + files: { + "/node_modules/pkg/package.json": /* json */ ` + { "name": "pkg", "main": "./node.js", "browser": { "./node.js": false } } + `, + "/node_modules/pkg/node.js": /* js */ ` + console.log("node only"); + `, + }, + entryPointsRaw: ["pkg"], + target: "browser", + bundleErrors: { + "": ['"pkg" is disabled due to "browser" field in package.json (entry point)'], + }, + }); + itBundled("browser/EntryPointIsNodeBuiltinStubbedForBrowser", { + // Browser builds replace "fs" (and node:* builtins without a polyfill) with + // an empty module, so as entry points they have nothing to bundle either. + skipOnEsbuild: true, + backend: "cli", + files: {}, + entryPointsRaw: ["fs", "node:fs"], + target: "browser", + bundleErrors: { + "": [ + `Cannot use Node.js builtin "fs" as an entry point`, + `Cannot use Node.js builtin "node:fs" as an entry point`, + ], + }, + }); + // unsure: do we want polyfills or no-op stuff like node:* has // right now all error except bun:wrap which errors at resolve time, but is included if external const bunModules: Record = { diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index e009c304867..121e11a3f32 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -1688,4 +1688,75 @@ describe("bundler", () => { expect(exitCode).toBe(0); }); } + + // An entry point that onResolve leaves without a module used to be dropped + // without a log entry, and the linker then aborted on an empty chunk list. + // A declined entry point that the package.json "browser" field disables gets + // the same error as without plugins; an entry point a plugin marks external + // has no error of its own yet, so it reaches the generic one. + test.concurrent("plugin/entry point left without a module by onResolve fails the build", async () => { + using dir = tempDir("plugin-entry-point-without-module", { + "package.json": JSON.stringify({ name: "app", browser: { "./disabled.js": false } }), + "entry.js": `console.log("entry");`, + "disabled.js": `console.log("disabled");`, + "build.mjs": ` + const declined = []; + const plugins = [ + { + name: "externalize-entry", + setup(build) { + build.onResolve({ filter: /entry\\.js$/ }, args => ({ path: args.path, external: true })); + }, + }, + { + name: "decline-disabled", + setup(build) { + build.onResolve({ filter: /disabled\\.js$/ }, args => { + declined.push(args.path); + }); + }, + }, + ]; + const results = {}; + for (const [name, entrypoints] of Object.entries({ + external: ["./entry.js"], + declinedThenDisabledByBrowserField: ["./disabled.js"], + })) { + const result = await Bun.build({ entrypoints, target: "browser", plugins, throw: false }); + results[name] = { + success: result.success, + logs: result.logs.map(log => log.message), + outputs: result.outputs.map(output => output.path), + }; + } + results.declined = declined; + console.log(JSON.stringify(results)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + external: { + success: false, + logs: ["None of the entry points could be bundled"], + outputs: [], + }, + declinedThenDisabledByBrowserField: { + success: false, + logs: ['"./disabled.js" is disabled due to "browser" field in package.json (entry point)'], + outputs: [], + }, + declined: ["./disabled.js"], + }); + expect(exitCode).toBe(0); + }); });