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"`.