From 4d37f7e802d10bb6063486eb2ee0a5390a1f02d5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:58:50 +0000 Subject: [PATCH 1/4] js_parser: make the server-components Response import a plain import item outside hot reloading The parser rewrites the global `Response` of a server-side file to an import from "bun:app". It attached a namespace alias to that import item for every output format. Only the dev server module format declares the namespace object. In ES module output each use site printed `import_bun_app.Response`, and nothing declared `import_bun_app`. Attach the alias only for hot reloading. In every other format, register the item in `is_import_item` before the visit, as the JSX runtime import does. The linker adds its own alias when it converts the import for CommonJS or IIFE output. A debug assertion in the import scanner checks that no import clause item has an alias when the bundler links. Co-authored-by: Zack Radisic <56137411+zackradisic@users.noreply.github.com> --- src/js_parser/p.rs | 29 +-- src/js_parser/scan/scan_imports.rs | 11 ++ test/bake/dev/production.test.ts | 38 ++++ .../dev/response-to-bake-response.test.ts | 182 ++++++++++++++++-- 4 files changed, 237 insertions(+), 23 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 2383804eb65e..fc4a45193b51 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -292,10 +292,14 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// So we'll need to add a `import { Response } from 'bun:app'` to the /// top of the file /// - /// We need to declare this `response_ref` upfront + /// We need to declare this `response_ref` upfront. Outside hot reloading + /// it is registered in `is_import_item` before the visit, like any other + /// import item. pub(crate) response_ref: Ref, - /// We also need to declare the namespace ref for `bun:app` and attach - /// it to the symbol so the code generated `e_import_identifier`'s + /// The namespace ref of the `bun:app` import. Only hot reloading attaches + /// it to `response_ref` as a `namespace_alias` before the visit: that + /// module format reads every import off its namespace object. The linker + /// owns the alias in every other format. pub(crate) bun_app_namespace_ref: Ref, /// Used to track the `feature` function from `import { feature } from "bun:bundle"`. @@ -2426,7 +2430,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O symbol.namespace_alias.as_mut().unwrap().import_record_index = import_record_i; } - self.is_import_item.insert(self.response_ref, ()); self.named_imports.put( self.response_ref, js_ast::NamedImport { @@ -3504,13 +3507,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.declare_common_js_symbol(js_ast::symbol::Kind::Import, b"Response")?; self.bun_app_namespace_ref = self.new_symbol(js_ast::symbol::Kind::Other, b"import_bun_app"); - let symbol = &mut self.symbols[self.response_ref.inner_index() as usize]; - symbol.namespace_alias = Some(bun_alloc::ast_box(js_ast::NamespaceAlias { - namespace_ref: self.bun_app_namespace_ref, - alias: js_ast::StoreStr::new(b"Response"), - was_originally_property_access: false, - import_record_index: u32::MAX, - })); + if self.options.features.hot_module_reloading { + let symbol = &mut self.symbols[self.response_ref.inner_index() as usize]; + symbol.namespace_alias = Some(bun_alloc::ast_box(js_ast::NamespaceAlias { + namespace_ref: self.bun_app_namespace_ref, + alias: js_ast::StoreStr::new(b"Response"), + was_originally_property_access: false, + import_record_index: u32::MAX, + })); + } else { + self.is_import_item.insert(self.response_ref, ()); + } } } } diff --git a/src/js_parser/scan/scan_imports.rs b/src/js_parser/scan/scan_imports.rs index c09ce48f911e..9236192adcd6 100644 --- a/src/js_parser/scan/scan_imports.rs +++ b/src/js_parser/scan/scan_imports.rs @@ -480,6 +480,17 @@ impl<'a> ImportScanner<'a> { let name: LocRef = item.name; let name_ref = name.ref_; + // Only the linker gives an import item a namespace alias + // here, because it also declares that namespace. An alias + // from the parser prints a read of an undeclared name. + debug_assert!( + HOT_MODULE_RELOADING_TRANSFORMATIONS + || p.symbols[name_ref.inner_index() as usize] + .namespace_alias + .is_none(), + "import clause item has a namespace alias before linking" + ); + p.named_imports.put_assume_capacity( name_ref, js_ast::NamedImport { diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index 76d04d2e876a..925c3a71e87d 100644 --- a/test/bake/dev/production.test.ts +++ b/test/bake/dev/production.test.ts @@ -691,4 +691,42 @@ export default function IndexPage() { expect(indexHtml).toContain("readyState=" + WebSocket.CONNECTING); expect(buildProc.exitCode).toBe(0); }); + + test("a page can read the global Response", async () => { + const dir = await tempDirWithBakeDeps("bake-production-response", { + "src/index.tsx": `export default { app: { framework: "react" } };`, + "pages/index.tsx": `export default function IndexPage() { + const response = new Response("x"); + const redirect = Response.redirect("/login", 302); + let render; + try { + Response.render("/404"); + } catch (error) { + render = error.message; + } + return
status={response.status} redirect={redirect.headers.get("location")} render={render}
; +}`, + "package.json": JSON.stringify({ + "name": "test-app", + "version": "1.0.0", + "devDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + }, + }), + }); + + const buildProc = await Bun.$`${bunExe()} build --app ./src/index.tsx --outdir ./dist` + .cwd(dir) + .env(bunEnv) + .throws(false); + + const indexHtml = await Bun.file(path.join(dir, "dist", "index.html")) + .text() + .catch(() => buildProc.stderr.toString()); + expect(indexHtml).toContain( + "status=200 redirect=/login render=Response.render() is only available in the Bun dev server", + ); + expect(buildProc.exitCode).toBe(0); + }); }); diff --git a/test/bake/dev/response-to-bake-response.test.ts b/test/bake/dev/response-to-bake-response.test.ts index 7e99be1236da..6a080aece167 100644 --- a/test/bake/dev/response-to-bake-response.test.ts +++ b/test/bake/dev/response-to-bake-response.test.ts @@ -45,10 +45,11 @@ test("Response -> import { Response } from 'bun:app' transform in server compone // Check that Response import was added from 'bun:app' expect(serverResult).toContain('import { Response } from "bun:app"'); - // Response is transformed to import_bun_app.Response - expect(serverResult).toContain("new import_bun_app.Response"); - expect(serverResult).toContain("import_bun_app.Response.redirect"); - expect(serverResult).toContain("import_bun_app.Response.render"); + // Each read of Response is a read of the imported binding + expect(serverResult).toContain('new Response("Hello"'); + expect(serverResult).toContain('Response.redirect("/login")'); + expect(serverResult).toContain('Response.render("/404")'); + expect(serverResult).not.toContain("import_bun_app"); // Build client component (should not have the transform) const clientResult = await Bun.$`${bunExe()} build ${path.join(dir, "client-component.js")} --target=browser` @@ -94,11 +95,12 @@ test("Response import is added for global Response in various contexts", async ( // Check that import was added expect(result).toContain('import { Response } from "bun:app"'); - // Response is transformed to import_bun_app.Response - expect(result).toContain("new import_bun_app.Response"); - expect(result).toContain("instanceof import_bun_app.Response"); - expect(result).toContain("import_bun_app.Response.prototype.status"); - expect(result).toContain("import_bun_app.Response.json"); + // Each read of Response is a read of the imported binding + expect(result).toContain("new Response"); + expect(result).toContain("instanceof Response"); + expect(result).toContain("Response.prototype.status"); + expect(result).toContain("Response.json("); + expect(result).not.toContain("import_bun_app"); }); test("Response import is not added when Response is already imported or shadowed", async () => { @@ -215,7 +217,8 @@ test("Response import is NOT added in client components", async () => { // Server component should have import from bun:app expect(serverResult).toContain('import { Response } from "bun:app"'); - expect(serverResult).toContain("new import_bun_app.Response"); + expect(serverResult).toContain('new Response("Server"'); + expect(serverResult).not.toContain("import_bun_app"); }); test("Response import is added when Response is global, but not when shadowed", async () => { @@ -242,8 +245,163 @@ test("Response import is added when Response is global, but not when shadowed", expect(serverResult).toContain('import { Response } from "bun:app"'); // Local shadowed Response should not be affected expect(serverResult).toContain('new "ooga booga!"'); - // Global Response is transformed to import_bun_app.Response - expect(serverResult).toContain("var lmao = new import_bun_app.Response"); + // The global Response is a read of the imported binding + expect(serverResult).toContain("var lmao = new Response"); + expect(serverResult).not.toContain("import_bun_app"); +}); + +describe.concurrent("the built output runs", () => { + // The Response of "bun:app" is not the global one. It extends it and adds `render`. + const reads = `Response === globalThis.Response, new Response("x") instanceof globalThis.Response, typeof Response.render`; + + async function buildAndRun(files: Record, { args = [] as string[], entries = ["./entry.js"] } = {}) { + using dir = tempDir("response-run", files); + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "--server-components", "--target=bun", ...args, ...entries, "--outdir", "out"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [buildStderr, buildExitCode] = await Promise.all([build.stderr.text(), build.exited]); + expect(buildStderr).toBe(""); + expect(buildExitCode).toBe(0); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "out/entry.js"], + 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 output = await Bun.file(path.join(dir, "out", "entry.js")).text(); + return { output, result: { stdout, stderr, exitCode } }; + } + + test("esm", async () => { + const { output, result } = await buildAndRun({ + "entry.js": ` + export default function Page() { + return new Response("x"); + } + console.log(${reads}, Page() instanceof Response); + `, + }); + expect(result).toEqual({ stdout: "false true function true\n", stderr: "", exitCode: 0 }); + expect(output).toContain('import { Response } from "bun:app"'); + }); + + test("Response.redirect() and Response.render()", async () => { + const { result } = await buildAndRun({ + "entry.js": ` + const response = Response.redirect("/login", 302); + console.log(response instanceof globalThis.Response, response.status, response.headers.get("location")); + try { + Response.render("/404"); + } catch (error) { + console.log(error.message); + } + `, + }); + expect(result).toEqual({ + stdout: "true 302 /login\nResponse.render() is only available in the Bun dev server\n", + stderr: "", + exitCode: 0, + }); + }); + + // The linker turns the read into `import_bun_app.Response` here, and declares `import_bun_app`. + // It does that only for a read that is an import item. + test("cjs", async () => { + const { output, result } = await buildAndRun({ "entry.js": `console.log(${reads});` }, { args: ["--format=cjs"] }); + expect(result).toEqual({ stdout: "false true function\n", stderr: "", exitCode: 0 }); + expect(output).toContain('var import_bun_app = require("bun:app")'); + }); + + test("two files that read Response, minified", async () => { + const { result } = await buildAndRun( + { + "entry.js": ` + import { make } from "./other.js"; + console.log(${reads}, make() instanceof Response); + `, + "other.js": ` + export function make() { + return new Response("x"); + } + `, + }, + { args: ["--minify"] }, + ); + expect(result).toEqual({ stdout: "false true function true\n", stderr: "", exitCode: 0 }); + }); + + test("a chunk shared by two entry points", async () => { + const { output, result } = await buildAndRun( + { + "entry.js": ` + import { make } from "./shared.js"; + console.log(${reads}, make() instanceof Response); + `, + "second.js": ` + import { make } from "./shared.js"; + console.log(make() instanceof Response); + `, + "shared.js": ` + export function make() { + return new Response("x"); + } + `, + }, + { args: ["--splitting"], entries: ["./entry.js", "./second.js"] }, + ); + expect(result).toEqual({ stdout: "false true function true\n", stderr: "", exitCode: 0 }); + expect(output).not.toContain("function make"); + }); + + test("a re-export of Response", async () => { + const { output, result } = await buildAndRun({ + "entry.js": ` + import { Response as Named } from "./lib.js"; + import * as ns from "./lib.js"; + console.log(Named === globalThis.Response, ns.Response === Named, typeof Named.render, Object.keys(ns)); + `, + "lib.js": `export { Response };`, + }); + expect(result).toEqual({ stdout: 'false true function [ "Response" ]\n', stderr: "", exitCode: 0 }); + expect(output).toContain('import { Response } from "bun:app"'); + }); + + test("a CommonJS file", async () => { + const { output, result } = await buildAndRun({ + "entry.js": ` + module.exports = function Page() { + return new Response("x"); + }; + console.log(${reads}, module.exports() instanceof Response); + `, + }); + expect(result).toEqual({ stdout: "false true function true\n", stderr: "", exitCode: 0 }); + expect(output).toContain("__commonJS"); + }); + + // The bundler keeps this statement, but the read is not counted as a use. + test("a read in dead code adds no import", async () => { + const { output, result } = await buildAndRun({ + "entry.js": ` + if (false) { + switch (new Response("x")) { + case 1: + } + } + console.log("ok"); + `, + }); + expect(output).toContain('switch (new Response("x"))'); + expect(output).not.toContain("bun:app"); + expect(result).toEqual({ stdout: "ok\n", stderr: "", exitCode: 0 }); + }); }); // Only the dev server sets the AsyncLocalStorage instance that these calls read. From b3c4049d34ef3cb0ae1fb38ebe55149ede68d771 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:44:53 +0000 Subject: [PATCH 2/4] test: anchor the Response transform assertions and run the iife output Four assertions looked for text that the fixture source has too, for example `Response.json(`. The old output `import_bun_app.Response.json(` contains that text, so they passed with or without the transform. Each assertion now includes the token before the read. Add a run case for `--format=iife`. The linker converts the import with `__require` there. --- .../dev/response-to-bake-response.test.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/test/bake/dev/response-to-bake-response.test.ts b/test/bake/dev/response-to-bake-response.test.ts index 6a080aece167..524f0c7853ed 100644 --- a/test/bake/dev/response-to-bake-response.test.ts +++ b/test/bake/dev/response-to-bake-response.test.ts @@ -46,9 +46,9 @@ test("Response -> import { Response } from 'bun:app' transform in server compone // Check that Response import was added from 'bun:app' expect(serverResult).toContain('import { Response } from "bun:app"'); // Each read of Response is a read of the imported binding - expect(serverResult).toContain('new Response("Hello"'); - expect(serverResult).toContain('Response.redirect("/login")'); - expect(serverResult).toContain('Response.render("/404")'); + expect(serverResult).toContain('= new Response("Hello"'); + expect(serverResult).toContain('return Response.redirect("/login")'); + expect(serverResult).toContain('return Response.render("/404")'); expect(serverResult).not.toContain("import_bun_app"); // Build client component (should not have the transform) @@ -96,10 +96,10 @@ test("Response import is added for global Response in various contexts", async ( // Check that import was added expect(result).toContain('import { Response } from "bun:app"'); // Each read of Response is a read of the imported binding - expect(result).toContain("new Response"); - expect(result).toContain("instanceof Response"); - expect(result).toContain("Response.prototype.status"); - expect(result).toContain("Response.json("); + expect(result).toContain("= new Response"); + expect(result).toContain("obj instanceof Response"); + expect(result).toContain("status = Response.prototype.status"); + expect(result).toContain("json = Response.json("); expect(result).not.toContain("import_bun_app"); }); @@ -217,7 +217,7 @@ test("Response import is NOT added in client components", async () => { // Server component should have import from bun:app expect(serverResult).toContain('import { Response } from "bun:app"'); - expect(serverResult).toContain('new Response("Server"'); + expect(serverResult).toContain('= new Response("Server"'); expect(serverResult).not.toContain("import_bun_app"); }); @@ -319,6 +319,13 @@ describe.concurrent("the built output runs", () => { expect(output).toContain('var import_bun_app = require("bun:app")'); }); + // The same conversion as cjs, with `__require` in place of `require`. + test("iife", async () => { + const { output, result } = await buildAndRun({ "entry.js": `console.log(${reads});` }, { args: ["--format=iife"] }); + expect(result).toEqual({ stdout: "false true function\n", stderr: "", exitCode: 0 }); + expect(output).toContain('var import_bun_app = __require("bun:app")'); + }); + test("two files that read Response, minified", async () => { const { result } = await buildAndRun( { From 3fb03848cc4bebaba9db908ed58d86342657deb8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:15:21 +0000 Subject: [PATCH 3/4] js_parser: shorten the comments of the Response import gate Keep the first field doc as it was. State the namespace ref in two lines and the assertion in one. --- src/js_parser/p.rs | 10 +++------- src/js_parser/scan/scan_imports.rs | 4 +--- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index fc4a45193b51..999dfa4ccfdd 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -292,14 +292,10 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// So we'll need to add a `import { Response } from 'bun:app'` to the /// top of the file /// - /// We need to declare this `response_ref` upfront. Outside hot reloading - /// it is registered in `is_import_item` before the visit, like any other - /// import item. + /// We need to declare this `response_ref` upfront pub(crate) response_ref: Ref, - /// The namespace ref of the `bun:app` import. Only hot reloading attaches - /// it to `response_ref` as a `namespace_alias` before the visit: that - /// module format reads every import off its namespace object. The linker - /// owns the alias in every other format. + /// The namespace ref for `bun:app`. Only hot reloading attaches it to + /// `response_ref` as a namespace alias. pub(crate) bun_app_namespace_ref: Ref, /// Used to track the `feature` function from `import { feature } from "bun:bundle"`. diff --git a/src/js_parser/scan/scan_imports.rs b/src/js_parser/scan/scan_imports.rs index 9236192adcd6..1a682b819765 100644 --- a/src/js_parser/scan/scan_imports.rs +++ b/src/js_parser/scan/scan_imports.rs @@ -480,9 +480,7 @@ impl<'a> ImportScanner<'a> { let name: LocRef = item.name; let name_ref = name.ref_; - // Only the linker gives an import item a namespace alias - // here, because it also declares that namespace. An alias - // from the parser prints a read of an undeclared name. + // Only the linker declares the namespace that an alias reads. debug_assert!( HOT_MODULE_RELOADING_TRANSFORMATIONS || p.symbols[name_ref.inner_index() as usize] From d118a9780cf4ec2cf8c8f15c9f40071a416ce592 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:34:03 +0000 Subject: [PATCH 4/4] js_parser: state the bun:app namespace ref doc in one line --- src/js_parser/p.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 999dfa4ccfdd..52f84320f52d 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -294,8 +294,7 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// /// We need to declare this `response_ref` upfront pub(crate) response_ref: Ref, - /// The namespace ref for `bun:app`. Only hot reloading attaches it to - /// `response_ref` as a namespace alias. + /// The namespace ref for `bun:app`. Only hot reloading attaches it to `response_ref`. pub(crate) bun_app_namespace_ref: Ref, /// Used to track the `feature` function from `import { feature } from "bun:bundle"`.