From 36148ec75c33de43582f701eaeab275d6aa4d06f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:46:16 +0000 Subject: [PATCH 1/7] resolver: keep every main field of a package.json in the shared cache Every resolver in a process shares one cache of parsed package.json files: the runtime, each Bun.build(), a macro, the page of a Bun.serve HTML route or of a server build. PackageJSON::parse stored only the main fields in the list of the resolver that parsed the file first. A later resolver with another list did not find its field and took the next one, so a page got the "main" file of a package that has a string "browser". parse now stores the four default main fields for every target and no longer reads the resolver's list. Each resolver selects with its own list when it resolves. A name that only --main-fields can give is read from the root rows of the retained JSON tape at that time. The bundler test harness now passes mainFields to the CLI, which runs the six ported esbuild cases that set it. --- src/ast/target.rs | 3 +- src/bundler/options.rs | 10 +- src/resolver/package_json.rs | 86 +++++-- src/resolver/resolver.rs | 7 +- test/bake/dev/bundle.test.ts | 30 +++ test/bake/dev/esm.test.ts | 34 +++ test/bundler/expectBundled.ts | 10 +- test/bundler/resolver/cache-runtime.test.ts | 260 +++++++++++++++++++- test/js/bun/http/bun-serve-html.test.ts | 33 +++ 9 files changed, 438 insertions(+), 35 deletions(-) diff --git a/src/ast/target.rs b/src/ast/target.rs index 45598a28f61e..c8262bf16654 100644 --- a/src/ast/target.rs +++ b/src/ast/target.rs @@ -80,7 +80,8 @@ impl Target { // `bake_graph()` stays in bun_bake (would back-edge into tier-6). // `out_extensions()` stays in bun_bundler (allocator-heavy, only used there). - const MAIN_FIELD_NAMES: [&'static str; 4] = [ + /// Every main field that the default list of some target names. + pub const MAIN_FIELD_NAMES: [&'static str; 4] = [ "browser", "module", "main", diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 2fe2aff4ccc3..e00ce9eb686a 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -206,12 +206,10 @@ pub(crate) use bun_ast::Target; pub use bun_ast::target::TARGET_MAP; pub(crate) const TARGET_MAIN_FIELD_NAMES: [&[u8]; 4] = [ - b"browser", - b"module", - b"main", - // https://github.com/jsforum/jsforum/issues/5 - // Older packages might use jsnext:main in place of module - b"jsnext:main", + Target::MAIN_FIELD_NAMES[0].as_bytes(), + Target::MAIN_FIELD_NAMES[1].as_bytes(), + Target::MAIN_FIELD_NAMES[2].as_bytes(), + Target::MAIN_FIELD_NAMES[3].as_bytes(), ]; // Note that this means if a package specifies "module" and "main", the ES6 diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index 1aa43e1a9881..f361db538428 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -1,4 +1,5 @@ use bun_ast as js_ast; +use bun_ast::Target; use bun_collections::{ArrayHashMap, StringArrayHashMap}; use bun_core::Output; use bun_core::StackCheck; @@ -45,7 +46,8 @@ pub type MacroMap = StringArrayHashMap; // borrow kept alive by `PackageJSON::source_contents` (the owning field). type ScriptsMap = StringArrayHashMap<&'static [u8]>; -type MainFieldMap = StringMap; +// A slot per `Target::MAIN_FIELD_NAMES`: a string value in the source buffer or the tape, or empty. +type MainFields = [js_ast::E::Str; Target::MAIN_FIELD_NAMES.len()]; #[derive(Default)] pub struct DependencyMap { @@ -85,7 +87,10 @@ pub struct PackageJSON { /// field is what keeps that borrow — and the map values above — alive.) pub(crate) source_contents: Box<[u8]>, pub(crate) json_tape: Option>, - pub(crate) main_fields: MainFieldMap, + /// Not only those of the resolver that parses: every resolver shares this, and has its own list. + pub(crate) main_fields: MainFields, + /// The rows of the root object in `json_tape`. `main_field` finds any other name there. + pub(crate) root_rows: core::ops::Range, pub module_type: ModuleType, pub version: Box<[u8]>, @@ -145,7 +150,8 @@ impl Default for PackageJSON { source: bun_ast::Source::default(), source_contents: Box::default(), json_tape: None, - main_fields: MainFieldMap::default(), + main_fields: MainFields::default(), + root_rows: 0..0, module_type: ModuleType::default(), version: Box::default(), scripts: None, @@ -210,6 +216,30 @@ impl PackageJSON { }) } + /// The file that the main field `name` gives. A resolver asks for the names of its own list. + pub(crate) fn main_field(&self, name: &[u8]) -> Option<&[u8]> { + let file = match default_main_field(name) { + Some(slot) => self.main_fields[slot].slice(), + None => self.main_field_outside_defaults(name), + }; + (!file.is_empty()).then_some(file) + } + + /// Only `--main-fields` gives a resolver such a name, so it has no slot. + #[cold] + #[inline(never)] + fn main_field_outside_defaults(&self, name: &[u8]) -> &[u8] { + let Some(tape) = self.json_tape.as_deref() else { + return b""; + }; + string_property(tape.properties(self.root_rows.clone()), name).slice() + } + + /// Returns true if `main_field` finds one of `Target::MAIN_FIELD_NAMES`. + pub(crate) fn has_default_main_field(&self) -> bool { + self.main_fields.iter().any(|file| !file.is_empty()) + } + /// Normalize path separators to forward slashes for glob matching /// This is needed because glob patterns use forward slashes but Windows uses backslashes fn normalize_path_for_glob(path: &[u8]) -> Result, bun_alloc::AllocError> { @@ -219,6 +249,24 @@ impl PackageJSON { } } +/// The slot of `name` in `PackageJSON::main_fields`. +fn default_main_field(name: &[u8]) -> Option { + Target::MAIN_FIELD_NAMES + .iter() + .position(|default| default.as_bytes() == name) +} + +/// The value of the first of `rows` that has the key `name`, when it is a string. Else empty. +fn string_property(rows: &[js_ast::E::PropertyJSON], name: &[u8]) -> js_ast::E::Str { + match rows.iter().find(|row| row.key.slice() == name) { + Some(js_ast::E::PropertyJSON { + value: js_ast::E::JsonValue::String(value), + .. + }) => *value, + _ => js_ast::E::Str::EMPTY, + } +} + #[derive(Default)] pub enum SideEffects { /// either `package.json` is missing "sideEffects", it is true, or some @@ -506,12 +554,13 @@ impl PackageJSON { }; let json: js_ast::Expr = parsed_json.root; - if !json.is_object() { + let js_ast::ExprData::EObjectJSON(root) = &json.data else { // Invalid package.json in node_modules is noisy. // Let's just ignore it. // (allocator.free dropped — entry.contents owned by `entry`) return None; - } + }; + let root = root.get(); let mut package_json = PackageJSON { name: Box::default(), @@ -524,7 +573,8 @@ impl PackageJSON { json_tape: None, module_type: ModuleType::Unknown, browser_map: BrowserMap::default(), - main_fields: MainFieldMap::default(), + main_fields: MainFields::default(), + root_rows: root.rows(), scripts: None, config: None, package_manager_package_id: INVALID_PACKAGE_ID, @@ -594,18 +644,14 @@ impl PackageJSON { } // Read the "main" fields - for main in r.opts.main_fields.iter() { - if let Some(main_json) = json.as_property(main) { - let expr: &js_ast::Expr = &main_json.expr; - - if let Some(str) = expr.as_utf8_string_literal() { - if !str.is_empty() { - package_json - .main_fields - .put(main, Box::from(str)) - .expect("unreachable"); - } - } + for (file, name) in package_json + .main_fields + .iter_mut() + .zip(Target::MAIN_FIELD_NAMES) + { + // The lookup of the "browser" property below stores the one that is a string. + if name != "browser" { + *file = string_property(root.properties(), name.as_bytes()); } } @@ -682,6 +728,10 @@ impl PackageJSON { } } } + } else if let Some(file) = browser_prop.expr.as_utf8_string_literal() + && let Some(slot) = default_main_field(b"browser") + { + package_json.main_fields[slot] = js_ast::E::Str::new(file); } } } diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 08cac08e4ce9..d1045e035a05 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -5691,8 +5691,7 @@ impl<'a> Resolver<'a> { // Try using the main field(s) from "package.json" if let Some(pkg_json) = dir_info.package_json() { package_json = Some(std::ptr::from_ref(pkg_json)); - if pkg_json.main_fields.count() > 0 { - let main_field_values = &pkg_json.main_fields; + if pkg_json.has_default_main_field() || !self.opts.main_fields_is_default { // BACKREF: `RawSlice` detaches the `&self.opts.main_fields` // borrow so the loop body can take `&mut self`. Backing // `Box<[Box<[u8]>]>` heap buffer is owned by `self.opts` and @@ -5713,7 +5712,7 @@ impl<'a> Resolver<'a> { for key in main_field_keys.iter() { let key: &[u8] = key; - let field_rel_path = match main_field_values.get(key) { + let field_rel_path = match pkg_json.main_field(key) { Some(v) => v, None => { if let Some(debug) = self.debug_logs.as_mut() { @@ -5758,7 +5757,7 @@ impl<'a> Resolver<'a> { let mut auto_main_result = MatchResult::default(); let mut auto_main_found = false; - if let Some(main_rel_path) = main_field_values.get(b"main".as_slice()) { + if let Some(main_rel_path) = pkg_json.main_field(b"main") { if !main_rel_path.is_empty() { auto_main_found = self .load_from_main_field( diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 6e7e8359aa46..cb06f960ba8a 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -80,6 +80,36 @@ devTest('uses "development" condition', { await dev.fetch("/").equals("Environment: development"); }, }); +// The server graph has the main fields of a browser (a defect of its own), so a route takes the +// "browser" file of a package. It takes the same file when a preload reads the package first. +for (const [reader, bunfig] of [ + ["the server graph", ""], + ["a preload", `preload = ["dual"]`], +]) { + devTest(`a route takes the same file of a package when ${reader} reads it first`, { + framework: minimalFramework, + files: { + "bunfig.toml": bunfig, + "node_modules/dual/package.json": JSON.stringify({ + name: "dual", + version: "1.0.0", + main: "./node.js", + browser: "./browser.js", + }), + "node_modules/dual/node.js": `module.exports = "node.js";`, + "node_modules/dual/browser.js": `module.exports = "browser.js";`, + "routes/index.ts": ` + import file from 'dual'; + export default function (req, meta) { + return new Response('dual: ' + file); + } + `, + }, + async test(dev) { + await dev.fetch("/").equals("dual: browser.js"); + }, + }); +} devTest("importing a file before it is created", { files: { "index.html": emptyHtmlFile({ diff --git a/test/bake/dev/esm.test.ts b/test/bake/dev/esm.test.ts index 240b9b6ff50d..48c920eaba74 100644 --- a/test/bake/dev/esm.test.ts +++ b/test/bake/dev/esm.test.ts @@ -453,6 +453,40 @@ devTest("browser field is used", { }, }); +devTest("browser field is used when it is a string", { + files: { + // The runtime reads the package.json first, and its main fields do not include "browser". + "bunfig.toml": ` + preload = [ + "axios", + ] + `, + "index.html": emptyHtmlFile({ + scripts: ["index.ts"], + }), + "node_modules/axios/package.json": JSON.stringify({ + name: "axios", + version: "1.0.0", + main: "./lib/node.js", + browser: "./lib/browser.js", + }), + "node_modules/axios/lib/node.js": ` + export default "FAIL"; + `, + "node_modules/axios/lib/browser.js": ` + export default "PASS"; + `, + "index.ts": ` + import axios from "axios"; + console.log(axios); + `, + }, + async test(dev) { + await using c = await dev.client(); + await c.expectMessage("PASS"); + }, +}); + devTest("browser console forwarding strips terminal control bytes", { files: { "index.html": emptyHtmlFile({ diff --git a/test/bundler/expectBundled.ts b/test/bundler/expectBundled.ts index ad5ccafccb1e..47a7389aa71e 100644 --- a/test/bundler/expectBundled.ts +++ b/test/bundler/expectBundled.ts @@ -157,7 +157,9 @@ function resolveBackend(opts: BundlerTestInput): "cli" | "api" { opts.bundleWarnings || opts.env || hasValidate || - opts.define + opts.define || + // Bun.build() has no main fields option: only `bun build --main-fields` sets the list. + opts.mainFields ? "cli" : "api"; } @@ -653,8 +655,8 @@ function expectBundled( if (!ESBUILD && unsupportedCSSFeatures && unsupportedCSSFeatures.length) { throw new UnsupportedOptionError("unsupportedCSSFeatures not implemented in bun build"); } - if (!ESBUILD && mainFields) { - throw new UnsupportedOptionError("mainFields not implemented in bun build"); + if (!ESBUILD && mainFields && resolveBackend(opts) === "api") { + throw new UnsupportedOptionError("mainFields not implemented in Bun.build"); } if (!ESBUILD && inject) { throw new UnsupportedOptionError("inject not implemented in bun build"); @@ -906,7 +908,7 @@ function expectBundled( // legalComments && `--legal-comments=${legalComments}`, // treeShaking === false && `--no-tree-shaking`, // ?? keepNames && `--keep-names`, - // mainFields && `--main-fields=${mainFields}`, + mainFields && mainFields.map(field => `--main-fields=${field}`), loader && Object.entries(loader).map(([k, v]) => ["--loader", `${k}:${v}`]), publicPath && `--public-path=${publicPath}`, bytecode && "--bytecode", diff --git a/test/bundler/resolver/cache-runtime.test.ts b/test/bundler/resolver/cache-runtime.test.ts index 1e03e191d077..a5fb24552fe0 100644 --- a/test/bundler/resolver/cache-runtime.test.ts +++ b/test/bundler/resolver/cache-runtime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { promises as fs } from "fs"; -import { tempDir } from "harness"; +import { promises as fs, readdirSync } from "fs"; +import { bunEnv, bunExe, tempDir } from "harness"; import * as path from "path"; // These tests verify that the resolver properly invalidates cache at runtime @@ -118,3 +118,259 @@ describe.concurrent("runtime cache invalidation", () => { expect(result3.value).toBe("recreated"); }); }); + +// Every resolver in a process reads a package.json from one shared cache: the runtime, a macro, +// each Bun.build(), and the browser side of a build that has an HTML page. Each of them takes the +// package entry that its own main-field list selects, whichever of them read the package first. +describe.concurrent("main fields of a package.json that another resolver read first", () => { + const files = { + // "main" for a server, and the string form of "browser" for a browser. + "node_modules/dual/package.json": JSON.stringify({ name: "dual", main: "./node.js", browser: "./browser.js" }), + "node_modules/dual/node.js": `module.exports = "NODE";`, + "node_modules/dual/browser.js": `module.exports = "BROWSER";`, + // "jsnext:main" is a default main field of the browser and bun targets, not of the node target. + "node_modules/jsnext/package.json": JSON.stringify({ + name: "jsnext", + "main": "./main.js", + "jsnext:main": "./next.js", + }), + "node_modules/jsnext/main.js": `module.exports = "MAIN";`, + "node_modules/jsnext/next.js": `export default "JSNEXT";`, + "node_modules/jsnext-only/package.json": JSON.stringify({ name: "jsnext-only", "jsnext:main": "./next.js" }), + "node_modules/jsnext-only/next.js": `export default "JSNEXT";`, + "node_modules/jsnext-only/other.js": `export default "OTHER";`, + // "custom" is not a default main field of any target: only --main-fields names it. + "node_modules/custom/package.json": JSON.stringify({ name: "custom", main: "./main.js", custom: "./custom.js" }), + "node_modules/custom/main.js": `module.exports = "MAIN";`, + "node_modules/custom/custom.js": `module.exports = "CUSTOM";`, + "node_modules/custom-only/package.json": JSON.stringify({ name: "custom-only", custom: "./custom.js" }), + "node_modules/custom-only/custom.js": `module.exports = "CUSTOM";`, + "node_modules/custom-only/other.js": `module.exports = "OTHER";`, + // In the parsed file, the rows of "publishConfig" are before the rows of the root object. + "node_modules/custom-nested/package.json": JSON.stringify({ + name: "custom-nested", + publishConfig: { custom: "./nested.js" }, + main: "./main.js", + custom: "./custom.js", + }), + "node_modules/custom-nested/main.js": `module.exports = "MAIN";`, + "node_modules/custom-nested/custom.js": `module.exports = "CUSTOM";`, + "node_modules/custom-nested/nested.js": `module.exports = "NESTED";`, + "node_modules/custom-dual/package.json": JSON.stringify({ + name: "custom-dual", + main: "./main.js", + browser: "./browser.js", + custom: "./custom.js", + }), + "node_modules/custom-dual/main.js": `module.exports = "MAIN";`, + "node_modules/custom-dual/browser.js": `module.exports = "BROWSER";`, + "node_modules/custom-dual/custom.js": `module.exports = "CUSTOM";`, + // A list that does not name "main" takes the index file. + "node_modules/main-index/package.json": JSON.stringify({ name: "main-index", main: "./main.js" }), + "node_modules/main-index/main.js": `module.exports = "MAIN";`, + "node_modules/main-index/index.js": `module.exports = "INDEX";`, + "lib.ts": ` + let entries = 0; + // The package file that a build of 'import it from ""' bundles, or why the build fails. + export async function build(specifier: string, target: "bun" | "browser" | "node") { + const entry = "./entry" + entries++ + ".ts"; + await Bun.write(entry, "import it from " + JSON.stringify(specifier) + "; console.log(it);"); + const { success, outputs, logs } = await Bun.build({ entrypoints: [entry], target, throw: false }); + if (!success) return logs.map(log => log.message).join("; "); + return /"(NODE|BROWSER|MAIN|JSNEXT|CUSTOM|OTHER|NESTED|INDEX)"/.exec(await outputs[0].text())?.[1]; + } + // The package file that the runtime loads, or why it does not load. + export async function load(specifier: string) { + try { + return (await import(specifier)).default; + } catch (error) { + return error.message; + } + } + `, + }; + + // Each is a process of its own: `run` is the body of an async function, in the order it says. + const orders: { name: string; flags?: string[]; run: string; expected: unknown }[] = [ + ...[ + `await import("dual")`, + `require("dual")`, + `Bun.resolveSync("dual", import.meta.dir)`, + `import.meta.resolve("dual")`, + `require.resolve("dual")`, + `require.resolve("dual/package.json")`, + ].map(touch => ({ + name: `${touch}, then a browser build`, + run: `${touch}; return await build("dual", "browser");`, + expected: "BROWSER", + })), + { + name: "builds for bun, browser, node, browser", + run: `return [await build("dual", "bun"), await build("dual", "browser"), await build("dual", "node"), await build("dual", "browser")];`, + expected: ["NODE", "BROWSER", "NODE", "BROWSER"], + }, + { + name: `"jsnext:main" in a browser build after a node build`, + run: `return [await build("jsnext", "node"), await build("jsnext", "browser")];`, + expected: ["MAIN", "JSNEXT"], + }, + { + name: `"jsnext:main" at runtime after a node build`, + run: `return [await build("jsnext-only/other.js", "node"), await load("jsnext-only")];`, + expected: ["OTHER", "JSNEXT"], + }, + { + name: "a --main-fields name at runtime after a build", + flags: ["--main-fields=custom", "--main-fields=main"], + run: `return [await build("custom", "browser"), await load("custom")];`, + expected: ["MAIN", "CUSTOM"], + }, + { + name: "a --main-fields name at runtime after a build, in a package with no other main field", + flags: ["--main-fields=custom"], + run: `return [await build("custom-only/other.js", "browser"), await load("custom-only")];`, + expected: ["OTHER", "CUSTOM"], + }, + { + name: "a browser build after the runtime loaded the package under --main-fields", + flags: ["--main-fields=custom"], + run: `return [await load("custom"), await build("custom", "browser")];`, + expected: ["CUSTOM", "MAIN"], + }, + { + name: "a --main-fields name that a nested object of the package.json has too", + flags: ["--main-fields=custom", "--main-fields=main"], + run: `return [await build("custom-nested", "browser"), await load("custom-nested")];`, + expected: ["MAIN", "CUSTOM"], + }, + { + name: `a browser build after the runtime skipped "main" under --main-fields`, + flags: ["--main-fields=custom"], + run: `return [await load("main-index"), await build("main-index", "browser")];`, + expected: ["INDEX", "MAIN"], + }, + { + name: `the runtime skips "main" under --main-fields after a browser build took it`, + flags: ["--main-fields=custom"], + run: `return [await build("main-index", "browser"), await load("main-index")];`, + expected: ["MAIN", "INDEX"], + }, + ]; + + test.each(orders)("$name", async ({ flags = [], run, expected }) => { + using dir = tempDir("main-fields-read-first", { + ...files, + "run.ts": ` + import { build, load } from "./lib.ts"; + console.log(JSON.stringify(await (async () => { ${run} })())); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...flags, "run.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout === "" ? stderr : JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); + }); + + // The macro runs while its importer is parsed, so the macro's resolver reads the package first. + test("a browser build whose macro imports the package", async () => { + using dir = tempDir("main-fields-read-first-macro", { + ...files, + "macro.ts": `import it from "dual"; export const macro = () => "macro " + it;`, + "entry.ts": `import { macro } from "./macro.ts" with { type: "macro" }; import it from "dual"; console.log(macro(), it);`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--target=browser", "entry.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.match(/"(?:macro )?(?:NODE|BROWSER)"/g) ?? stderr).toEqual([`"BROWSER"`, `"macro NODE"`]); + expect(exitCode).toBe(0); + }); + + // One build, two resolvers: the server's entry is resolved before the page's script. + test("the page of a server build whose entry imports the package too", async () => { + using dir = tempDir("main-fields-read-first-page", { + ...files, + "index.html": ``, + "client.ts": `import it from "dual"; console.log(it);`, + "server.ts": `import it from "dual"; import page from "./index.html"; console.log(it, page);`, + "run.ts": ` + const { outputs } = await Bun.build({ entrypoints: ["./server.ts"], target: "bun" }); + const bundled: Record = {}; + for (const output of outputs) { + if (!output.path.endsWith(".js")) continue; + bundled[output.path.endsWith("server.js") ? "server" : "page"] = /"(NODE|BROWSER)"/.exec(await output.text())?.[1]; + } + console.log(JSON.stringify(bundled)); + `, + }); + await using proc = Bun.spawn({ cmd: [bunExe(), "run.ts"], env: bunEnv, cwd: String(dir), stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout === "" ? stderr : JSON.parse(stdout)).toEqual({ server: "NODE", page: "BROWSER" }); + expect(exitCode).toBe(0); + }); + + // `bun build --main-fields` gives the list to the server. The page keeps the list of a browser. + const serverWithPage = (server: string, client: string) => ({ + ...files, + "index.html": ``, + "client.ts": client, + "server.ts": `${server} import page from "./index.html"; console.log(it, page);`, + }); + + // The package file in the server's chunk and in the chunk of the page. + async function buildServerWithPage(dir: string) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--target=bun", + "--main-fields=custom", + "--main-fields=main", + "server.ts", + "--outdir=out", + ], + env: bunEnv, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) return stderr; + const bundled: Record = {}; + for (const name of readdirSync(path.join(dir, "out"))) { + if (!name.endsWith(".js")) continue; + const text = await Bun.file(path.join(dir, "out", name)).text(); + bundled[name === "server.js" ? "server" : "page"] = /"(BROWSER|MAIN|CUSTOM)"/.exec(text)?.[1]; + } + return bundled; + } + + test('the server of a --main-fields build and its page share a package with a string "browser"', async () => { + using dir = tempDir( + "main-fields-read-first-cli-page", + serverWithPage(`import it from "custom-dual";`, `import it from "custom-dual"; console.log(it);`), + ); + expect(await buildServerWithPage(String(dir))).toEqual({ server: "CUSTOM", page: "BROWSER" }); + }); + + // The page is two files away from the package and the server is a hundred, so the page reads it first. + test("the server of a --main-fields build reaches a package after its page", async () => { + const chain = Array.from({ length: 100 }, (_, i) => [ + `chain${i}.ts`, + i === 99 ? `export { default } from "custom";` : `export { default } from "./chain${i + 1}.ts";`, + ]); + using dir = tempDir("main-fields-read-first-cli-chain", { + ...serverWithPage(`import it from "./chain0.ts";`, `import it from "custom"; console.log(it);`), + ...Object.fromEntries(chain), + }); + expect(await buildServerWithPage(String(dir))).toEqual({ server: "CUSTOM", page: "MAIN" }); + }); +}); diff --git a/test/js/bun/http/bun-serve-html.test.ts b/test/js/bun/http/bun-serve-html.test.ts index 928f19dc0891..d702d6d765e8 100644 --- a/test/js/bun/http/bun-serve-html.test.ts +++ b/test/js/bun/http/bun-serve-html.test.ts @@ -1021,6 +1021,39 @@ describe("html route whose file a plugin resolves to a different file", () => { }); }); +// https://github.com/oven-sh/bun/issues/22887 +// The server and the page's bundler read a package.json from one shared cache. The server reads +// it first here, and selects "main". The page still takes the file that "browser" names. +test.concurrent('an html route takes the "browser" file of a package that the server imports too', async () => { + using dir = tempDir("bun-serve-html-browser-main-field", { + "node_modules/dual/package.json": JSON.stringify({ name: "dual", main: "./node.js", browser: "./browser.js" }), + "node_modules/dual/node.js": `module.exports = "dual/node.js";`, + "node_modules/dual/browser.js": `module.exports = "dual/browser.js";`, + "index.html": ``, + "app.ts": `import it from "dual"; console.log(it);`, + "serve.ts": /*ts*/ ` + import it from "dual"; + import page from "./index.html"; + + async function pageTakes(development) { + using server = Bun.serve({ port: 0, development, routes: { "/": page } }); + const html = await (await fetch(server.url)).text(); + const script = await (await fetch(new URL(html.match(/]*\\ssrc="([^"]+)"/)[1], server.url))).text(); + return /"(dual\\/\\w+\\.js)"/.exec(script)?.[1]; + } + + console.log(JSON.stringify({ server: it, production: await pageTakes(false), development: await pageTakes(true) })); + `, + }); + const { stdout, stderr, exitCode } = await runServeFixture(dir); + expect(stdout === "" ? stderr : JSON.parse(stdout)).toEqual({ + server: "dual/node.js", + production: "dual/browser.js", + development: "dual/browser.js", + }); + expect(exitCode).toBe(0); +}); + // server.reload() hands the dev server a new route object for the same html // file. The dev server used to give it a second route bundle and deliver the // bundled html there, so a request that was deferred on the original bundle From 4d1f5de97843c76065a074f7982afc1803a7a3a3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:20:04 +0000 Subject: [PATCH 2/7] test --changed: make the scan's transpiler for bun from the start The module graph scan made a transpiler with no target, which is one for a browser, and set the target to bun afterwards. The main-field list and the use of package.json "browser" maps are decided when the transpiler is made, so the scan followed the "browser" file of a package and missed the tests that load its "main" file. The shared package.json cache hid that for a string "browser": the runtime reads the package first and its list has no "browser". The scan now sets the target before the transpiler is made. It keeps process.env out of the defines, as before. --- src/runtime/cli/test/ChangedFilesFilter.rs | 10 ++++-- test/cli/test/test-changed.test.ts | 40 ++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/runtime/cli/test/ChangedFilesFilter.rs b/src/runtime/cli/test/ChangedFilesFilter.rs index 09a9907bf271..0a1a5987285b 100644 --- a/src/runtime/cli/test/ChangedFilesFilter.rs +++ b/src/runtime/cli/test/ChangedFilesFilter.rs @@ -28,6 +28,7 @@ use bun_jsc as jsc; #[cfg(windows)] use bun_jsc::EventLoopHandle; use bun_jsc::virtual_machine::VirtualMachine; +use bun_options_types::schema::api; #[cfg(not(windows))] use bun_paths::SEP; use bun_paths::{self, platform, resolve_path}; @@ -131,8 +132,11 @@ pub(crate) fn filter<'a>( let arena: &'static Arena = crate::cli::cli_arena(); let log: &'static mut bun_ast::Log = arena.alloc(bun_ast::Log::new()); + // The target selects the main fields and the "browser" maps when the transpiler is made. + let mut args = ctx.args.clone(); + args.target = Some(api::Target::Bun); let scan_transpiler: &'static mut Transpiler<'static> = arena.alloc( - match Transpiler::init(arena, log, ctx.args.clone(), Some(vm.transpiler.env)) { + match Transpiler::init(arena, log, args, Some(vm.transpiler.env)) { Ok(t) => t, Err(err) => { Output::err_generic( @@ -143,7 +147,6 @@ pub(crate) fn filter<'a>( } }, ); - scan_transpiler.options.target = bun_ast::Target::Bun; // Do not follow bare specifiers into node_modules; changes there are not // considered local edits. scan_transpiler.options.packages = bun_bundler::options::PackagesOption::External; @@ -152,11 +155,12 @@ pub(crate) fn filter<'a>( scan_transpiler.options.ignore_module_resolution_errors = true; scan_transpiler.options.output_dir = Box::default(); scan_transpiler.options.tree_shaking = false; + // A transpiler for bun inlines `process.env`, which drops the imports of a branch that is dead now. + scan_transpiler.options.env.behavior = api::DotEnvBehavior::Disable; scan_transpiler.configure_linker(); let _ = scan_transpiler.configure_defines(); // `Transpiler::init` already projected resolver.opts, so sync only the // fields we changed above. - scan_transpiler.resolver.opts.target = scan_transpiler.options.target; scan_transpiler.resolver.opts.packages = bun_resolver::options::Packages::External; scan_transpiler.resolver.opts.output_dir = Box::default(); scan_transpiler.resolver.env_loader = core::ptr::NonNull::new(scan_transpiler.env); diff --git a/test/cli/test/test-changed.test.ts b/test/cli/test/test-changed.test.ts index 09a80ce39ef1..b128fd326134 100644 --- a/test/cli/test/test-changed.test.ts +++ b/test/cli/test/test-changed.test.ts @@ -394,6 +394,46 @@ describe.concurrent("bun test --changed", () => { expect(exitCode).toBe(0); }); + // The scan follows the file that the run loads. A test runs in bun, so a "browser" field of + // package.json, as a main field or as a map, does not send the scan to another file. + test.each([ + ["a main field", `"./browser.js"`], + ["a map", `{ "./node.js": "./browser.js" }`], + ])('package.json "browser" as %s does not redirect the module graph', async (_, browser) => { + using dir = tempDir("test-changed-browser-field", { + "package.json": `{ "name": "own", "main": "./node.js", "browser": ${browser} }`, + "node.js": `module.exports = "node";\n`, + "browser.js": `module.exports = "browser";\n`, + "tests/directory.test.ts": `import { test, expect } from "bun:test";\nimport own from "..";\ntest("directory", () => expect(own).toBe("node"));\n`, + "tests/file.test.ts": `import { test, expect } from "bun:test";\nimport own from "../node.js";\ntest("file", () => expect(own).toBe("node"));\n`, + "tests/unrelated.test.ts": `import { test, expect } from "bun:test";\ntest("unrelated", () => expect(1).toBe(1));\n`, + }); + initRepo(String(dir)); + appendFileSync(join(String(dir), "node.js"), "// touched\n"); + + const { stderr, exitCode } = await runTestChanged(String(dir)); + const testNames = ["directory.test.ts", "file.test.ts", "unrelated.test.ts"]; + expect(ranFiles(stderr, testNames)).toEqual(["directory.test.ts", "file.test.ts"]); + expect(exitCode).toBe(0); + }); + + // A test can set the variable before the branch runs, so the scan does not use its value now. + test("an import in a branch that the environment turns off is in the module graph", async () => { + using dir = tempDir("test-changed-env-branch", { + "package.json": JSON.stringify({ name: "env-branch", type: "module" }), + ".env": `TEST_CHANGED_GUARD=off\n`, + "src/guarded.ts": `export const guarded = 1;\n`, + "guarded.test.ts": `import { test, expect } from "bun:test";\ntest("guarded", async () => {\n if (process.env.TEST_CHANGED_GUARD !== "off") await import("./src/guarded");\n expect(1).toBe(1);\n});\n`, + "unrelated.test.ts": `import { test, expect } from "bun:test";\ntest("unrelated", () => expect(1).toBe(1));\n`, + }); + initRepo(String(dir)); + appendFileSync(join(String(dir), "src", "guarded.ts"), "// touched\n"); + + const { stderr, exitCode } = await runTestChanged(String(dir)); + expect(ranFiles(stderr, ["guarded.test.ts", "unrelated.test.ts"])).toEqual(["guarded.test.ts"]); + expect(exitCode).toBe(0); + }); + test("a node:vm timeout can interrupt a hot loop", async () => { using dir = tempDir("test-changed-vm-timeout", { "package.json": JSON.stringify({ name: "vm-timeout", type: "module" }), From c83cd97a1ed4dd9ef71f4d86093a378cbe109f8e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:31:09 +0000 Subject: [PATCH 3/7] bake, bundler: make the main fields of two more resolvers match their target bake makes the transpiler of every graph for the default target, a browser, and then assigns the target. The server and SSR graphs kept the main-field list of a browser, so a route took the "browser" file of a package unless runtime code had read the package first. They now get the list of bun. The page of a server build gets the default list of a browser, but under --main-fields it kept the server's flag that says the list is not the default. Its require() then took "module" and not "main". The flag now says default. The --changed scan takes the env behaviour of the run, so it keeps an import behind a process.env.NODE_ENV check. --- src/bundler/bundle_v2.rs | 2 ++ src/bundler/options.rs | 2 +- src/runtime/bake/config/transpiler.rs | 8 ++++++++ src/runtime/cli/test/ChangedFilesFilter.rs | 4 ++-- test/bake/dev/bundle.test.ts | 8 ++++---- test/bundler/resolver/cache-runtime.test.ts | 18 +++++++++++++++++- test/cli/test/test-changed.test.ts | 7 +++++-- 7 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 201b32587eeb..bb96c37100dd 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2045,6 +2045,8 @@ pub mod bv2_impl { // Re-project the resolver subset now that `target`/`conditions` etc. // have been overwritten for the browser. boxed.sync_resolver_opts(); + // The list above is the default of a browser, whatever `--main-fields` gave the server. + boxed.resolver.opts.main_fields_is_default = true; boxed.resolver.env_loader = NonNull::new(this_env.cast()); // Park the owning Box first, then derive both the published `NonNull` diff --git a/src/bundler/options.rs b/src/bundler/options.rs index e00ce9eb686a..8b10aa315108 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -1243,7 +1243,7 @@ pub struct BundleOptions<'a> { pub(crate) tsconfig_override: Option>, pub target: Target, - pub(crate) main_fields: Box<[Box<[u8]>]>, + pub main_fields: Box<[Box<[u8]>]>, /// TODO: remove this in favor accessing bundler.log /// raw `*mut` (not `&'a mut`) — the same `*Log` is aliased /// into `Transpiler.log` / `Resolver.log` / `Linker.log`. A stored diff --git a/src/runtime/bake/config/transpiler.rs b/src/runtime/bake/config/transpiler.rs index 031283d76ba2..ac8de8149cb0 100644 --- a/src/runtime/bake/config/transpiler.rs +++ b/src/runtime/bake/config/transpiler.rs @@ -37,6 +37,14 @@ impl Framework { Graph::Client => bun_ast::Target::Browser, Graph::Server | Graph::Ssr => bun_ast::Target::Bun, }; + // The transpiler was made with the main fields of the default target, a browser. + out.options.main_fields = out + .options + .target + .default_main_fields() + .iter() + .map(|name| name.as_bytes().into()) + .collect(); out.options.public_path = match renderer { Graph::Client => dev_server::CLIENT_PREFIX.as_bytes().into(), Graph::Server | Graph::Ssr => Box::default(), diff --git a/src/runtime/cli/test/ChangedFilesFilter.rs b/src/runtime/cli/test/ChangedFilesFilter.rs index 0a1a5987285b..ad6ab677ec35 100644 --- a/src/runtime/cli/test/ChangedFilesFilter.rs +++ b/src/runtime/cli/test/ChangedFilesFilter.rs @@ -155,8 +155,8 @@ pub(crate) fn filter<'a>( scan_transpiler.options.ignore_module_resolution_errors = true; scan_transpiler.options.output_dir = Box::default(); scan_transpiler.options.tree_shaking = false; - // A transpiler for bun inlines `process.env`, which drops the imports of a branch that is dead now. - scan_transpiler.options.env.behavior = api::DotEnvBehavior::Disable; + // The run reads `process.env` when the code runs, so the scan does not inline it and drop a branch. + scan_transpiler.options.env.behavior = vm.transpiler.options.env.behavior; scan_transpiler.configure_linker(); let _ = scan_transpiler.configure_defines(); // `Transpiler::init` already projected resolver.opts, so sync only the diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index cb06f960ba8a..bb0989e6b750 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -80,13 +80,13 @@ devTest('uses "development" condition', { await dev.fetch("/").equals("Environment: development"); }, }); -// The server graph has the main fields of a browser (a defect of its own), so a route takes the -// "browser" file of a package. It takes the same file when a preload reads the package first. +// A route is server code: it takes the "main" file of a package that also has a string "browser". +// That does not depend on which reads the package first, the server graph or a preload. for (const [reader, bunfig] of [ ["the server graph", ""], ["a preload", `preload = ["dual"]`], ]) { - devTest(`a route takes the same file of a package when ${reader} reads it first`, { + devTest(`a route takes the "main" file of a package when ${reader} reads it first`, { framework: minimalFramework, files: { "bunfig.toml": bunfig, @@ -106,7 +106,7 @@ for (const [reader, bunfig] of [ `, }, async test(dev) { - await dev.fetch("/").equals("dual: browser.js"); + await dev.fetch("/").equals("dual: node.js"); }, }); } diff --git a/test/bundler/resolver/cache-runtime.test.ts b/test/bundler/resolver/cache-runtime.test.ts index a5fb24552fe0..8aa791c9cf21 100644 --- a/test/bundler/resolver/cache-runtime.test.ts +++ b/test/bundler/resolver/cache-runtime.test.ts @@ -165,6 +165,10 @@ describe.concurrent("main fields of a package.json that another resolver read fi "node_modules/custom-dual/main.js": `module.exports = "MAIN";`, "node_modules/custom-dual/browser.js": `module.exports = "BROWSER";`, "node_modules/custom-dual/custom.js": `module.exports = "CUSTOM";`, + // "module" for an import and "main" for a require(), when the list is the default of the target. + "node_modules/both/package.json": JSON.stringify({ name: "both", main: "./main.js", module: "./module.js" }), + "node_modules/both/main.js": `module.exports = "MAIN";`, + "node_modules/both/module.js": `export default "MODULE";`, // A list that does not name "main" takes the index file. "node_modules/main-index/package.json": JSON.stringify({ name: "main-index", main: "./main.js" }), "node_modules/main-index/main.js": `module.exports = "MAIN";`, @@ -348,7 +352,7 @@ describe.concurrent("main fields of a package.json that another resolver read fi for (const name of readdirSync(path.join(dir, "out"))) { if (!name.endsWith(".js")) continue; const text = await Bun.file(path.join(dir, "out", name)).text(); - bundled[name === "server.js" ? "server" : "page"] = /"(BROWSER|MAIN|CUSTOM)"/.exec(text)?.[1]; + bundled[name === "server.js" ? "server" : "page"] = /"(BROWSER|MAIN|MODULE|CUSTOM)"/.exec(text)?.[1]; } return bundled; } @@ -373,4 +377,16 @@ describe.concurrent("main fields of a package.json that another resolver read fi }); expect(await buildServerWithPage(String(dir))).toEqual({ server: "CUSTOM", page: "MAIN" }); }); + + // The page's list is the default of a browser, so its require() takes "main" and not "module". + test.each([ + ["and the server imports it too", `import it from "both";`, { server: "MAIN", page: "MAIN" }], + ["and the server does not import it", `const it = 0;`, { page: "MAIN" }], + ])('the page of a --main-fields build requires a package with "module", %s', async (_, server, expected) => { + using dir = tempDir( + "main-fields-read-first-cli-require", + serverWithPage(server, `const it = require("both"); console.log(it);`), + ); + expect(await buildServerWithPage(String(dir))).toEqual(expected); + }); }); diff --git a/test/cli/test/test-changed.test.ts b/test/cli/test/test-changed.test.ts index b128fd326134..c4d0954f0196 100644 --- a/test/cli/test/test-changed.test.ts +++ b/test/cli/test/test-changed.test.ts @@ -418,12 +418,15 @@ describe.concurrent("bun test --changed", () => { }); // A test can set the variable before the branch runs, so the scan does not use its value now. - test("an import in a branch that the environment turns off is in the module graph", async () => { + test.each([ + ["a variable of .env", `process.env.TEST_CHANGED_GUARD !== "off"`], + ["NODE_ENV", `process.env.NODE_ENV === "production"`], + ])("an import in a branch that %s turns off is in the module graph", async (_, condition) => { using dir = tempDir("test-changed-env-branch", { "package.json": JSON.stringify({ name: "env-branch", type: "module" }), ".env": `TEST_CHANGED_GUARD=off\n`, "src/guarded.ts": `export const guarded = 1;\n`, - "guarded.test.ts": `import { test, expect } from "bun:test";\ntest("guarded", async () => {\n if (process.env.TEST_CHANGED_GUARD !== "off") await import("./src/guarded");\n expect(1).toBe(1);\n});\n`, + "guarded.test.ts": `import { test, expect } from "bun:test";\ntest("guarded", async () => {\n if (${condition}) await import("./src/guarded");\n expect(1).toBe(1);\n});\n`, "unrelated.test.ts": `import { test, expect } from "bun:test";\ntest("unrelated", () => expect(1).toBe(1));\n`, }); initRepo(String(dir)); From e1709ba64cb91883b1dd79222e9cee0da4875710 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:43:07 +0000 Subject: [PATCH 4/7] test --changed: keep the scan's parse and env settings, take "main" over "module" The scan's transpiler is made for bun, which turns on minify_syntax and sets the env behaviour to LoadAll. - minify_syntax replaced a constant by its value, and the scan dropped the import of a branch that the constant turns off. It is off again. - The scan took the env behaviour of the run, but not its file suffix. With NODE_ENV=development it read .env.development and .env.local into the env loader that the run shares. The behaviour is Disable again. The scan also takes "main" over "module" for an import of a directory whose package.json has both, as the runtime resolver does. It took "module", so an edit of the "main" file selected no test. --- src/runtime/cli/test/ChangedFilesFilter.rs | 8 +++- test/cli/test/test-changed.test.ts | 54 ++++++++++++++++------ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/runtime/cli/test/ChangedFilesFilter.rs b/src/runtime/cli/test/ChangedFilesFilter.rs index ad6ab677ec35..6345cddbc50f 100644 --- a/src/runtime/cli/test/ChangedFilesFilter.rs +++ b/src/runtime/cli/test/ChangedFilesFilter.rs @@ -155,14 +155,18 @@ pub(crate) fn filter<'a>( scan_transpiler.options.ignore_module_resolution_errors = true; scan_transpiler.options.output_dir = Box::default(); scan_transpiler.options.tree_shaking = false; - // The run reads `process.env` when the code runs, so the scan does not inline it and drop a branch. - scan_transpiler.options.env.behavior = vm.transpiler.options.env.behavior; + // The target turned this on. It drops the import of a branch that a constant turns off. + scan_transpiler.options.minify_syntax = false; + // The target set `LoadAll`, which reads env files into the loader of the run and inlines the variables. + scan_transpiler.options.env.behavior = api::DotEnvBehavior::Disable; scan_transpiler.configure_linker(); let _ = scan_transpiler.configure_defines(); // `Transpiler::init` already projected resolver.opts, so sync only the // fields we changed above. scan_transpiler.resolver.opts.packages = bun_resolver::options::Packages::External; scan_transpiler.resolver.opts.output_dir = Box::default(); + // The run takes "main" over "module" for an import too. + scan_transpiler.resolver.prefer_module_field = vm.transpiler.resolver.prefer_module_field; scan_transpiler.resolver.env_loader = core::ptr::NonNull::new(scan_transpiler.env); // Stack-owned Mini loop so its tasks/concurrent_tasks queues drop at diff --git a/test/cli/test/test-changed.test.ts b/test/cli/test/test-changed.test.ts index c4d0954f0196..55067297ff74 100644 --- a/test/cli/test/test-changed.test.ts +++ b/test/cli/test/test-changed.test.ts @@ -394,16 +394,18 @@ describe.concurrent("bun test --changed", () => { expect(exitCode).toBe(0); }); - // The scan follows the file that the run loads. A test runs in bun, so a "browser" field of - // package.json, as a main field or as a map, does not send the scan to another file. + // The scan follows the file that the run loads. A test runs in bun, which takes "main". A + // "browser" field, as a main field or as a map, does not send the scan to another file, and + // neither does "module". test.each([ - ["a main field", `"./browser.js"`], - ["a map", `{ "./node.js": "./browser.js" }`], - ])('package.json "browser" as %s does not redirect the module graph', async (_, browser) => { - using dir = tempDir("test-changed-browser-field", { - "package.json": `{ "name": "own", "main": "./node.js", "browser": ${browser} }`, + ['"browser" as a main field', `"browser": "./other.js"`], + ['"browser" as a map', `"browser": { "./node.js": "./other.js" }`], + ['"module"', `"module": "./other.js"`], + ])("package.json %s does not redirect the module graph", async (_, field) => { + using dir = tempDir("test-changed-main-field", { + "package.json": `{ "name": "own", "main": "./node.js", ${field} }`, "node.js": `module.exports = "node";\n`, - "browser.js": `module.exports = "browser";\n`, + "other.js": `module.exports = "other";\n`, "tests/directory.test.ts": `import { test, expect } from "bun:test";\nimport own from "..";\ntest("directory", () => expect(own).toBe("node"));\n`, "tests/file.test.ts": `import { test, expect } from "bun:test";\nimport own from "../node.js";\ntest("file", () => expect(own).toBe("node"));\n`, "tests/unrelated.test.ts": `import { test, expect } from "bun:test";\ntest("unrelated", () => expect(1).toBe(1));\n`, @@ -417,16 +419,17 @@ describe.concurrent("bun test --changed", () => { expect(exitCode).toBe(0); }); - // A test can set the variable before the branch runs, so the scan does not use its value now. + // The scan keeps the import of a branch that is off now. A test can set the variable before + // the branch runs, and the scan does not replace a constant by its value. test.each([ + ["a constant", `enabled`], ["a variable of .env", `process.env.TEST_CHANGED_GUARD !== "off"`], - ["NODE_ENV", `process.env.NODE_ENV === "production"`], ])("an import in a branch that %s turns off is in the module graph", async (_, condition) => { - using dir = tempDir("test-changed-env-branch", { - "package.json": JSON.stringify({ name: "env-branch", type: "module" }), + using dir = tempDir("test-changed-off-branch", { + "package.json": JSON.stringify({ name: "off-branch", type: "module" }), ".env": `TEST_CHANGED_GUARD=off\n`, "src/guarded.ts": `export const guarded = 1;\n`, - "guarded.test.ts": `import { test, expect } from "bun:test";\ntest("guarded", async () => {\n if (${condition}) await import("./src/guarded");\n expect(1).toBe(1);\n});\n`, + "guarded.test.ts": `import { test, expect } from "bun:test";\ntest("guarded", async () => {\n const enabled = false;\n if (${condition}) await import("./src/guarded");\n expect(enabled).toBe(false);\n});\n`, "unrelated.test.ts": `import { test, expect } from "bun:test";\ntest("unrelated", () => expect(1).toBe(1));\n`, }); initRepo(String(dir)); @@ -437,6 +440,31 @@ describe.concurrent("bun test --changed", () => { expect(exitCode).toBe(0); }); + // The scan and the run share one env loader. A test run loads `.env.test` whatever NODE_ENV is. + test("the scan loads no env file that the run does not load", async () => { + using dir = tempDir("test-changed-env-files", { + "package.json": JSON.stringify({ name: "env-files", type: "module" }), + ".env.development": `FROM_ENV_DEVELOPMENT=1\n`, + ".env.local": `FROM_ENV_LOCAL=1\n`, + ".env.test": `FROM_ENV_TEST=1\n`, + "env.test.ts": `import { test, expect } from "bun:test";\ntest("env", () => {\n const { FROM_ENV_DEVELOPMENT, FROM_ENV_LOCAL, FROM_ENV_TEST } = process.env;\n expect({ FROM_ENV_DEVELOPMENT, FROM_ENV_LOCAL, FROM_ENV_TEST }).toEqual({ FROM_ENV_TEST: "1" });\n});\n`, + }); + initRepo(String(dir)); + appendFileSync(join(String(dir), "env.test.ts"), "// touched\n"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--changed"], + cwd: String(dir), + env: { ...gitEnv, NODE_ENV: "development" }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("1 pass"); + expect(exitCode).toBe(0); + }); + test("a node:vm timeout can interrupt a hot loop", async () => { using dir = tempDir("test-changed-vm-timeout", { "package.json": JSON.stringify({ name: "vm-timeout", type: "module" }), From 0f1a0e98ef1edc9a506ff83db61be9f7a5a1d085 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:43:08 +0000 Subject: [PATCH 5/7] bake: read the default main fields from the bundler's table BundleOptions::from_api reads the list of a target from default_main_fields_map(). bake read Target::default_main_fields(), a second copy of the same lists. --- src/runtime/bake/config/transpiler.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/runtime/bake/config/transpiler.rs b/src/runtime/bake/config/transpiler.rs index ac8de8149cb0..4fb99d0b76ec 100644 --- a/src/runtime/bake/config/transpiler.rs +++ b/src/runtime/bake/config/transpiler.rs @@ -38,13 +38,12 @@ impl Framework { Graph::Server | Graph::Ssr => bun_ast::Target::Bun, }; // The transpiler was made with the main fields of the default target, a browser. - out.options.main_fields = out - .options - .target - .default_main_fields() - .iter() - .map(|name| name.as_bytes().into()) - .collect(); + out.options.main_fields = + ::default_main_fields_map() + [out.options.target] + .iter() + .map(|name| Box::from(*name)) + .collect(); out.options.public_path = match renderer { Graph::Client => dev_server::CLIENT_PREFIX.as_bytes().into(), Graph::Server | Graph::Ssr => Box::default(), From 7788cf7b553f21a552511f2f8a7f8f048fd58742 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:03:57 +0000 Subject: [PATCH 6/7] bake: build the server and SSR transpilers for bun `Framework::init_transpiler` built every transpiler for the default target, the browser, and then assigned the target and the main fields of the graph. The rest of what `Transpiler::init` derives from the target stayed the browser's: the extension order, the loaders, and the resolver's switch for package.json "browser" maps. A server route resolved a package through its "browser" map. - `init_transpiler` passes the target of the graph to `Transpiler::init` and no longer assigns the target and the main fields after it. `init` turns environment inlining on for the bun target, so the function sets it off when the config has no `env` option. - `BundleOptions::main_fields` is `pub(crate)` again. No other crate assigns it now. - `Framework::resolve` resolves `serverEntryPoint` with the server resolver. It used the client resolver. - The dev server tries a failed import again with the resolver of the graph that has the importer. It used the server resolver for every graph, which no longer reads "browser" maps. --- src/bundler/options.rs | 2 +- src/runtime/bake/config/framework.rs | 2 +- src/runtime/bake/config/transpiler.rs | 26 ++- .../bake/dev_server/directory_watch_store.rs | 8 +- .../bake/dev_server/hot_reload_event.rs | 18 +- test/bake/bake-harness.ts | 54 ++++++ test/bake/dev/bundle.test.ts | 60 ++++++ test/bake/dev/esm.test.ts | 179 +++++++++++++++++- test/bake/dev/production.test.ts | 162 +++++++++++++++- 9 files changed, 489 insertions(+), 22 deletions(-) diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 8b10aa315108..e00ce9eb686a 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -1243,7 +1243,7 @@ pub struct BundleOptions<'a> { pub(crate) tsconfig_override: Option>, pub target: Target, - pub main_fields: Box<[Box<[u8]>]>, + pub(crate) main_fields: Box<[Box<[u8]>]>, /// TODO: remove this in favor accessing bundler.log /// raw `*mut` (not `&'a mut`) — the same `*Log` is aliased /// into `Transpiler.log` / `Resolver.log` / `Linker.log`. A stored diff --git a/src/runtime/bake/config/framework.rs b/src/runtime/bake/config/framework.rs index 67678864f534..fa52deba52c2 100644 --- a/src/runtime/bake/config/framework.rs +++ b/src/runtime/bake/config/framework.rs @@ -203,7 +203,7 @@ impl Framework { } Self::resolve_helper( &self.built_in_modules, - client, + server, &mut fsr.entry_server, &mut had_errors, b"server side entrypoint", diff --git a/src/runtime/bake/config/transpiler.rs b/src/runtime/bake/config/transpiler.rs index 4fb99d0b76ec..f72c034ed74d 100644 --- a/src/runtime/bake/config/transpiler.rs +++ b/src/runtime/bake/config/transpiler.rs @@ -21,29 +21,24 @@ impl Framework { let mut ast_memory_allocator = bun_ast::ASTMemoryAllocator::borrowing(arena); let _ast_scope = ast_memory_allocator.enter(); + let target = match renderer { + Graph::Client => bun_schema::api::Target::browser, + Graph::Server | Graph::Ssr => bun_schema::api::Target::bun, + }; + // The caller (`DevServer::init`) hands us an uninitialized slot, so // use `MaybeUninit::write` (no drop of prior bytes) then reborrow as // `&mut Transpiler` for the field assignments below. let out: &mut bun_bundler::Transpiler = out.write(bun_bundler::Transpiler::init( arena, log, - // `TransformOptions::default()`: every `Option` is `None`, every - // slice empty, every scalar zero/false. - bun_schema::api::TransformOptions::default(), + bun_schema::api::TransformOptions { + target: Some(target), + ..Default::default() + }, None, )?); - out.options.target = match renderer { - Graph::Client => bun_ast::Target::Browser, - Graph::Server | Graph::Ssr => bun_ast::Target::Bun, - }; - // The transpiler was made with the main fields of the default target, a browser. - out.options.main_fields = - ::default_main_fields_map() - [out.options.target] - .iter() - .map(|name| Box::from(*name)) - .collect(); out.options.public_path = match renderer { Graph::Client => dev_server::CLIENT_PREFIX.as_bytes().into(), Graph::Server | Graph::Ssr => Box::default(), @@ -111,6 +106,9 @@ impl Framework { if bundler_options.env != bun_schema::api::DotEnvBehavior::_none { out.options.env.behavior = bundler_options.env; out.options.env.prefix = bundler_options.env_prefix.clone().unwrap_or_default(); + } else { + // `init` turns inlining on for the bun target. + out.options.env.behavior = bun_schema::api::DotEnvBehavior::disable; } // The resolver crate carries a FORWARD_DECL subset of // `BundleOptions`, so re-project via the dedicated helper rather than diff --git a/src/runtime/bake/dev_server/directory_watch_store.rs b/src/runtime/bake/dev_server/directory_watch_store.rs index 64651d0153b0..00de726ca7e5 100644 --- a/src/runtime/bake/dev_server/directory_watch_store.rs +++ b/src/runtime/bake/dev_server/directory_watch_store.rs @@ -116,6 +116,8 @@ pub(crate) struct Dep { pub(crate) source_file_path: bun_ptr::RawSlice, /// The specifier that failed. Allocated memory. pub(crate) specifier: Box<[u8]>, + /// The graph of the importer. Its resolver tries the import again. + pub(crate) graph: Graph, } impl Default for Dep { fn default() -> Self { @@ -123,6 +125,7 @@ impl Default for Dep { next: None, source_file_path: bun_ptr::RawSlice::EMPTY, specifier: Box::default(), + graph: Graph::Client, } } } @@ -213,7 +216,7 @@ impl DirectoryWatchStore { } }; - match self.insert(dir, owned_file_path, specifier) { + match self.insert(dir, owned_file_path, specifier, renderer) { Ok(()) => Ok(()), Err(DirectoryWatchInsertError::Ignore) => Ok(()), // ignoring watch errors. Err(DirectoryWatchInsertError::OutOfMemory) => Err(bun_alloc::AllocError), @@ -227,6 +230,7 @@ impl DirectoryWatchStore { dir_name_to_watch: &[u8], file_path: bun_ptr::RawSlice, specifier: &[u8], + graph: Graph, ) -> Result<(), DirectoryWatchInsertError> { debug_assert!(!specifier.is_empty()); // TODO: watch the parent dir too. @@ -282,6 +286,7 @@ impl DirectoryWatchStore { next: prev_first, source_file_path: file_path, specifier: specifier_cloned, + graph, }); self.watches.values_mut()[gop_index].first_dep = dep; return Ok(()); @@ -374,6 +379,7 @@ impl DirectoryWatchStore { next: None, source_file_path: file_path, specifier: specifier_cloned, + graph, }); self.watches.values_mut()[gop_index] = Entry { dir: fd, diff --git a/src/runtime/bake/dev_server/hot_reload_event.rs b/src/runtime/bake/dev_server/hot_reload_event.rs index 8ec10b18b6ec..66ea49293fed 100644 --- a/src/runtime/bake/dev_server/hot_reload_event.rs +++ b/src/runtime/bake/dev_server/hot_reload_event.rs @@ -117,16 +117,28 @@ impl HotReloadEvent { while let Some(index) = it { // Note: reshaped for borrowck — re-index per iteration instead of // holding `dep` ref across resolver call + appendFile + freeDependencyIndex. - let (source_file_path, specifier, next) = { + let (source_file_path, specifier, graph, next) = { let dep = &dev.directory_watchers.dependencies[index as usize]; - (dep.source_file_path, &raw const *dep.specifier, dep.next) + ( + dep.source_file_path, + &raw const *dep.specifier, + dep.graph, + dep.next, + ) }; it = next; + let transpiler = match graph { + Graph::Client => &mut dev.client_transpiler, + Graph::Server | Graph::Ssr => &mut dev.server_transpiler, + }; + // SAFETY: both transpilers are initialized in DevServer::init + // before any HotReloadEvent can fire. + let transpiler = unsafe { transpiler.assume_init_mut() }; // `specifier` points into the dep's owned `Box<[u8]>`, which is // not mutated until after `resolve` returns. // SAFETY: see `Dep` doc — neither slice is mutated mid-resolve. - let resolved = unsafe { dev.server_transpiler.assume_init_mut() } + let resolved = transpiler .resolver .resolve( bun_paths::resolve_path::dirname::( diff --git a/test/bake/bake-harness.ts b/test/bake/bake-harness.ts index c5ba2f53efaf..d046337a2df0 100644 --- a/test/bake/bake-harness.ts +++ b/test/bake/bake-harness.ts @@ -61,6 +61,60 @@ export const minimalFramework: Bake.Framework = { }, }; +// [package.json fields next to "main", the file bun resolves, the file a browser resolves] +const platformPackageShapes = { + "browser-string": [{ browser: "./browser.js" }, "main.js", "browser.js"], + "browser-map": [{ browser: { "./main.js": "./browser.js" } }, "main.js", "browser.js"], + // main.js exports what node.js exports, or its own path when node.js is empty. + "browser-false": [{ browser: { "./node.js": false } }, "node.js", "main.js"], + // The bun target reads "main" before "jsnext:main". + "jsnext-main": [{ "jsnext:main": "./jsnext.js" }, "main.js", "jsnext.js"], +} as const; + +/** + * Packages that send a server and a browser to different files. Each file + * exports its own path, and `resolved.ts` exports the paths that the graph + * which bundles it got. + */ +export const platformPackages: Record = (() => { + const files: Record = {}; + const names = Object.keys(platformPackageShapes); + for (const [name, [fields, ...resolved]] of Object.entries(platformPackageShapes)) { + const ownPath = (file: string) => JSON.stringify(`${name}/${file}`); + files[`node_modules/${name}/package.json`] = JSON.stringify({ + name, + version: "1.0.0", + main: "./main.js", + ...fields, + }); + for (const file of resolved) { + files[`node_modules/${name}/${file}`] = `export const file = ${ownPath(file)};`; + } + if (name === "browser-false") { + files[`node_modules/${name}/main.js`] = + `import { file as nodeFile } from "./node.js";\nexport const file = nodeFile ?? ${ownPath("main.js")};`; + } + } + files["resolved.ts"] = [ + ...names.map((name, i) => `import { file as file${i} } from "${name}";`), + `export default [${names.map((_, i) => `file${i}`).join(", ")}];`, + ].join("\n"); + return files; +})(); + +/** What `resolved.ts` of `platformPackages` exports in a graph for bun, and in one for a browser. */ +export const platformFiles = { + bun: Object.entries(platformPackageShapes).map(([name, [, bun]]) => `${name}/${bun}`), + browser: Object.entries(platformPackageShapes).map(([name, [, , browser]]) => `${name}/${browser}`), +}; + +/** The files of `platformPackages` whose source is in the text of a bundle. */ +export function platformFilesIn(bundle: string): string[] { + return Object.keys(platformPackageShapes).flatMap(name => + [...new Set(bundle.match(new RegExp(`(? require("../addon"); + return new Response(typeof load); + } + `, + }, + async test(dev) { + await dev.fetch("/").equals("function"); + }, +}); devTest("importing a file before it is created", { files: { "index.html": emptyHtmlFile({ @@ -133,6 +150,32 @@ devTest("importing a file before it is created", { await c.expectMessage("value: 456"); }, }); +// The dev server tries a failed import again when its directory changes, with the resolver of +// the graph that has the importer. Only a resolver for a browser reads a "browser" map. +devTest('importing a file that a "browser" map names before it is created', { + files: { + "package.json": JSON.stringify({ name: "app", browser: { "./second.ts": "./second-browser.ts" } }), + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.ts"], + }), + "index.ts": ` + import { abc } from './second'; + console.log('value: ' + abc); + `, + }, + async test(dev) { + await using c = await dev.client("/", { + errors: [`index.ts:1:21: error: Could not resolve: "./second"`], + }); + + await c.expectReload(async () => { + await dev.write("second-browser.ts", `export const abc = "browser";`); + }); + + await c.expectMessage("value: browser"); + }, +}); // After a failed resolution, the dev server evicts the specifier's path and its parent from the // resolver's directory cache. Here one of those two paths ends in a slash. devTest("recovers from an unresolved import of a path that ends in a slash", { @@ -1934,6 +1977,23 @@ devTest("inlined env var survives a runtime process.env write to a proxy variabl }, }); +// Without `env = "inline"`, no environment variable is a define of the server graph. +devTest("a server route reads process.env when it runs", { + framework: minimalFramework, + files: { + "routes/index.ts": ` + export default function (req, meta) { + Reflect.set(process.env, "BAKE_ENV_PROBE", "set when the route ran"); + return new Response(process.env.BAKE_ENV_PROBE); + } + `, + }, + env: { BAKE_ENV_PROBE: "set when the server started" }, + async test(dev) { + await dev.fetch("/").equals("set when the route ran"); + }, +}); + // An unresolved CSS url() is passed to the directory watcher. The HTML scanner joins a rooted src to the project root. devTest("recovers from an unresolved css url() and html script src longer than the path buffer", { files: { diff --git a/test/bake/dev/esm.test.ts b/test/bake/dev/esm.test.ts index 48c920eaba74..6f6577c346a2 100644 --- a/test/bake/dev/esm.test.ts +++ b/test/bake/dev/esm.test.ts @@ -1,7 +1,14 @@ // ESM tests are about various esm features in development mode. import { expect } from "bun:test"; import { isWindows } from "harness"; -import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness"; +import { + devTest, + emptyHtmlFile, + minimalFramework, + platformFiles, + platformFilesIn, + platformPackages, +} from "../bake-harness"; const liveBindingTest = devTest("live bindings with `var`", { framework: minimalFramework, @@ -487,6 +494,176 @@ devTest("browser field is used when it is a string", { }, }); +// The server and SSR graphs run in Bun, so a package's "browser" field and +// "browser" map are not for them. The client graph keeps both. Every graph +// imports the same packages here. +const withClientEntryPoint = { + ...minimalFramework, + fileSystemRouterTypes: [{ ...minimalFramework.fileSystemRouterTypes![0], clientEntryPoint: "./client-entry.ts" }], +}; +for (const [firstReader, bunfig] of [ + ["the client graph", ""], + ["the runtime", `preload = ["./resolved.ts"]`], +]) { + devTest(`each graph resolves packages for the platform that runs it, when ${firstReader} reads them first`, { + framework: { + ...withClientEntryPoint, + serverComponents: { ...minimalFramework.serverComponents!, separateSSRGraph: true }, + }, + files: { + "bunfig.toml": bunfig, + ...platformPackages, + "client-entry.ts": ` + import resolved from "./resolved"; + console.log(resolved); + `, + "ssr.ts": ` + export { default } from "./resolved"; + `, + "routes/client-only.ts": ` + export default function (req, meta) { + return Response.json(meta.modules); + } + `, + "routes/index.ts": ` + import server from "../resolved"; + import ssr from "../ssr" with { bunBakeGraph: "ssr" }; + export default function (req, meta) { + return Response.json({ server, ssr, clientBundles: meta.modules }); + } + `, + }, + async test(dev) { + // The bundle has the source of each package file that the client graph resolved. + const clientFiles = async ([bundle]: string[]) => platformFilesIn(await dev.fetch(bundle).text()); + + // No server module of this route imports the packages. + expect(await clientFiles(await dev.fetch("/client-only").json())).toEqual(platformFiles.browser); + + const { clientBundles, ...serverSide } = await dev.fetch("/").json(); + expect(serverSide).toEqual({ server: platformFiles.bun, ssr: platformFiles.bun }); + expect(await clientFiles(clientBundles)).toEqual(platformFiles.browser); + }, + }); +} + +// A client component is in the client graph, and in the SSR graph when there +// is one. Each of them resolves the imports of the component for itself. +for (const separateSSRGraph of [true, false]) { + devTest( + `a client component's packages resolve for each graph that bundles it (separateSSRGraph: ${separateSSRGraph})`, + { + framework: { + ...withClientEntryPoint, + serverComponents: { ...minimalFramework.serverComponents!, separateSSRGraph }, + }, + files: { + ...platformPackages, + "client-entry.ts": ` + console.log("client entry point"); + `, + "island.ts": ` + "use client"; + import resolved from "./resolved"; + export const island = resolved; + `, + "routes/index.ts": separateSSRGraph + ? ` + import { ssrManifest } from "bun:bake/server"; + import "../island"; + export default async function (req, meta) { + // The SSR copy of the component, loaded as a framework loads it. + const { specifier, name } = ssrManifest["island.ts"].island; + const ssr = (await import(specifier))[name]; + return Response.json({ ssr, clientBundles: meta.modules }); + } + ` + : ` + import "../island"; + export default function (req, meta) { + return Response.json({ clientBundles: meta.modules }); + } + `, + }, + async test(dev) { + const { clientBundles, ...serverSide } = await dev.fetch("/").json(); + expect(serverSide).toEqual(separateSSRGraph ? { ssr: platformFiles.bun } : {}); + expect(platformFilesIn(await dev.fetch(clientBundles[0]).text())).toEqual(platformFiles.browser); + }, + }, + ); +} + +// The server entry point and the server components runtime of a framework run +// in Bun. +devTest("a framework's server modules resolve for bun", { + framework: { + fileSystemRouterTypes: [{ root: "routes", style: "nextjs-pages", serverEntryPoint: "framework-server" }], + serverComponents: { + separateSSRGraph: false, + serverRuntimeImportSource: "framework-server", + serverRegisterClientReferenceExport: "registerClientReference", + }, + }, + files: { + // A "browser" condition and a "browser" map: each of them sends a resolver for a browser to browser.js. + "node_modules/framework-server/package.json": JSON.stringify({ + name: "framework-server", + version: "1.0.0", + main: "./main.js", + exports: { ".": { browser: "./browser.js", default: "./main.js" } }, + browser: { "./main.js": "./browser.js" }, + }), + ...Object.fromEntries( + ["main.js", "browser.js"].map(file => [ + `node_modules/framework-server/${file}`, + ` + export const render = (req, meta) => new Response("${file} renders a reference from " + meta.pageModule.default); + export const registerClientReference = () => "${file}"; + `, + ]), + ), + "component.ts": ` + "use client"; + export const component = 1; + `, + "routes/index.ts": ` + export { component as default } from "../component"; + `, + }, + async test(dev) { + await dev.fetch("/").equals("main.js renders a reference from main.js"); + }, +}); + +// A stylesheet is resolved by the graph that imports it, as every other file is. +devTest("a stylesheet that a server route imports resolves as the server graph does", { + framework: minimalFramework, + files: { + "package.json": JSON.stringify({ name: "app", browser: { "./theme.css": "./theme.browser.css" } }), + "theme.css": ` + body { + color: red; + } + `, + "theme.browser.css": ` + body { + color: blue; + } + `, + "routes/index.ts": ` + import "../theme.css"; + export default function (req, meta) { + return Response.json(meta.styles); + } + `, + }, + async test(dev) { + const [stylesheet] = await dev.fetch("/").json(); + expect(await dev.fetch(stylesheet).text()).toBe("/* theme.css */\nbody {\n color: red;\n}\n"); + }, +}); + devTest("browser console forwarding strips terminal control bytes", { files: { "index.html": emptyHtmlFile({ diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index 6771c0f96261..fc2e830917f8 100644 --- a/test/bake/dev/production.test.ts +++ b/test/bake/dev/production.test.ts @@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "fs"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import path from "path"; -import { minimalFramework, tempDirWithBakeDeps } from "../bake-harness"; +import { + minimalFramework, + platformFiles, + platformFilesIn, + platformPackages, + tempDirWithBakeDeps, +} from "../bake-harness"; const normalizePath = (path: string) => (process.platform === "win32" ? path.replaceAll("\\", "/") : path); const platformPath = (path: string) => (process.platform === "win32" ? path.replaceAll("/", "\\") : path); @@ -489,6 +495,160 @@ export default function () { expect(exitCode).toBe(0); }); + // The server and SSR graphs run in Bun, so a package's "browser" field and + // "browser" map are not for them. The client graph keeps both. Every graph + // imports the same packages, and the client component is in two of them. + test("each graph resolves packages for the platform that runs it", async () => { + using dir = tempDir("bake-production-platform-packages", { + ...platformPackages, + "client-entry.ts": ` + console.log("client entry point"); + `, + "server-entry.ts": ` + export function render(req, meta) { + return new Response(""); + } + export async function prerender(meta) { + return { files: { "index.html": JSON.stringify(await meta.pageModule.default()) } }; + } + export function registerClientReference(value, file, uid) { + return { value, file, uid }; + } + `, + "island.ts": ` + "use client"; + import resolved from "./resolved"; + export const island = resolved; + `, + "ssr.ts": ` + export { default } from "./resolved"; + `, + "routes/index.ts": ` + import { ssrManifest } from "bun:bake/server"; + import server from "../resolved"; + import ssr from "../ssr" with { bunBakeGraph: "ssr" }; + import "../island"; + export default async function () { + // The SSR copy of the component, loaded as a framework loads it. + const [{ specifier, name }] = Object.values(Object.values(ssrManifest)[0]); + return { server, ssr, island: (await import(specifier))[name] }; + } + `, + "app.ts": ` + const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server-entry.ts", clientEntryPoint: "./client-entry.ts" }; + const serverComponents = { + separateSSRGraph: true, + serverRuntimeImportSource: "./server-entry.ts", + serverRegisterClientReferenceExport: "registerClientReference", + }; + export default { app: { framework: { fileSystemRouterTypes: [fsr], serverComponents } } }; + `, + }); + + const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./app.ts` + .cwd(String(dir)) + .env(bunEnv) + .throws(false); + const dist = path.join(String(dir), "dist"); + const serverSide = await Bun.file(path.join(dist, "index.html")) + .json() + .catch(() => stderr.toString()); + expect(serverSide).toEqual({ server: platformFiles.bun, ssr: platformFiles.bun, island: platformFiles.bun }); + // The chunks of the client graph have the source of each package file that it resolved. + const clientChunks = await Promise.all( + [...new Bun.Glob("_bun/*.js").scanSync(dist)].map(chunk => Bun.file(path.join(dist, chunk)).text()), + ); + expect(platformFilesIn(clientChunks.join("\n"))).toEqual(platformFiles.browser); + expect(exitCode).toBe(0); + }); + + // The server entry point and the server components runtime of a framework + // run in Bun. + test("a framework's server modules resolve for bun", async () => { + using dir = tempDir("bake-production-framework-server", { + // A "browser" condition and a "browser" map: each of them sends a resolver for a browser to browser.js. + "node_modules/framework-server/package.json": JSON.stringify({ + name: "framework-server", + version: "1.0.0", + main: "./main.js", + exports: { ".": { browser: "./browser.js", default: "./main.js" } }, + browser: { "./main.js": "./browser.js" }, + }), + ...Object.fromEntries( + ["main.js", "browser.js"].map(file => [ + `node_modules/framework-server/${file}`, + ` + export const render = (req, meta) => new Response(""); + export const prerender = async meta => ({ + files: { "index.html": "${file} renders a reference from " + meta.pageModule.default }, + }); + export const registerClientReference = () => "${file}"; + `, + ]), + ), + "component.ts": ` + "use client"; + export const component = 1; + `, + "routes/index.ts": ` + export { component as default } from "../component"; + `, + "app.ts": ` + const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "framework-server" }; + const serverComponents = { + separateSSRGraph: true, + serverRuntimeImportSource: "framework-server", + serverRegisterClientReferenceExport: "registerClientReference", + }; + export default { app: { framework: { fileSystemRouterTypes: [fsr], serverComponents } } }; + `, + }); + + const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./app.ts` + .cwd(String(dir)) + .env(bunEnv) + .throws(false); + const html = await Bun.file(path.join(String(dir), "dist", "index.html")) + .text() + .catch(() => stderr.toString()); + expect(html).toBe("main.js renders a reference from main.js"); + expect(exitCode).toBe(0); + }); + + // No environment variable is a define of the server graph. + test("a server route reads process.env when it is rendered", async () => { + using dir = tempDir("bake-production-env", { + "server-entry.ts": ` + export function render(req, meta) { + return new Response(meta.pageModule.default()); + } + export async function prerender(meta) { + return { files: { "index.html": meta.pageModule.default() } }; + } + `, + "routes/index.ts": ` + export default function () { + Reflect.set(process.env, "BAKE_ENV_PROBE", "set when the route ran"); + return process.env.BAKE_ENV_PROBE; + } + `, + "app.ts": ` + const fsr = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server-entry.ts" }; + export default { app: { framework: { fileSystemRouterTypes: [fsr] } } }; + `, + }); + + const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./app.ts` + .cwd(String(dir)) + .env({ ...bunEnv, BAKE_ENV_PROBE: "set when the build started" }) + .throws(false); + const html = await Bun.file(path.join(String(dir), "dist", "index.html")) + .text() + .catch(() => stderr.toString()); + expect(html).toBe("set when the route ran"); + expect(exitCode).toBe(0); + }); + test("fails the build for a route file with an invalid name", async () => { using dir = tempDir("bake-production-invalid-route", { "app.ts": `export default { app: { framework: ${JSON.stringify(minimalFramework)} } };`, From 79a6f4d4cbda4a68667be75e31dceb2b086925f4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:57:19 +0000 Subject: [PATCH 7/7] test: check the server copy of a client component when there is no SSR graph With `separateSSRGraph: false` the route now returns the value inside the client reference, and the test compares it with the files that a resolver for bun takes. --- test/bake/dev/esm.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/bake/dev/esm.test.ts b/test/bake/dev/esm.test.ts index 6f6577c346a2..8da70a5c2aeb 100644 --- a/test/bake/dev/esm.test.ts +++ b/test/bake/dev/esm.test.ts @@ -548,7 +548,8 @@ for (const [firstReader, bunfig] of [ } // A client component is in the client graph, and in the SSR graph when there -// is one. Each of them resolves the imports of the component for itself. +// is one. Without an SSR graph the server graph has the component itself, in +// the client reference. Each graph resolves the imports of its copy. for (const separateSSRGraph of [true, false]) { devTest( `a client component's packages resolve for each graph that bundles it (separateSSRGraph: ${separateSSRGraph})`, @@ -579,15 +580,15 @@ for (const separateSSRGraph of [true, false]) { } ` : ` - import "../island"; + import { island } from "../island"; export default function (req, meta) { - return Response.json({ clientBundles: meta.modules }); + return Response.json({ server: island.value, clientBundles: meta.modules }); } `, }, async test(dev) { const { clientBundles, ...serverSide } = await dev.fetch("/").json(); - expect(serverSide).toEqual(separateSSRGraph ? { ssr: platformFiles.bun } : {}); + expect(serverSide).toEqual(separateSSRGraph ? { ssr: platformFiles.bun } : { server: platformFiles.bun }); expect(platformFilesIn(await dev.fetch(clientBundles[0]).text())).toEqual(platformFiles.browser); }, },