Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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");

Expand All @@ -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();

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();

Expand Down
37 changes: 35 additions & 2 deletions src/bundler/transpiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<resolver::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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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<resolver::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<()> {
Expand Down
52 changes: 52 additions & 0 deletions test/bundler/bun-build-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
81 changes: 81 additions & 0 deletions test/bundler/bundler_browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
"<bun>": ['"./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: {
"<bun>": ['"./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: {
"<bun>": ['"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: {
"<bun>": [
`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<string, "no-op" | "polyfill" | "error"> = {
Expand Down
71 changes: 71 additions & 0 deletions test/bundler/bundler_plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading