Skip to content

bake: build the server and SSR transpilers for bun - #46702

Open
robobun wants to merge 2 commits into
robobun/076b1765/main-fields-shared-cachefrom
robobun/6e4cd3de/bake-server-graph-target-options
Open

robobun wants to merge 2 commits into
robobun/076b1765/main-fields-shared-cachefrom
robobun/6e4cd3de/bake-server-graph-target-options

Conversation

@robobun

@robobun robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A bake server route or SSR module applies the "browser" map of a package. axios 1.6.8 fails there: There is no suitable adapter to dispatch the request.
  • Framework::init_transpiler (src/runtime/bake/config/transpiler.rs:27) builds each transpiler for the browser, then assigns the target and the main fields. The extension order, the loaders and the resolver's "browser" map switch stay the browser's.

Fix

  • init_transpiler passes the target to Transpiler::init, which derives all four. Both assignments go. It sets environment inlining back to off.
  • serverEntryPoint resolves with the server resolver. The dev server retries a failed import with the importer graph's resolver.
  • Stacked on resolver: keep every main field of a package.json in the shared cache, select per resolver #46652, whose main-field assignment this PR removes.
  • Verified: 12 new tests in test/bake/dev (9 fail on the base). Self-reviewed: 39 concerns raised, 38 addressed. Not done: no issue filed.

Background

Downsides

  • A client component is also bundled for the server. There a "browser" map no longer disables a dependency: pdfjs-dist 3.11.174 fails the build with Could not resolve: "canvas".
  • A "use client" package entry reaches the client as the file the server resolved: now main where a "browser" map replaces it, as with an exports condition.
  • Per bake server transpiler: 28 more mallocs. Release .text: 256 bytes less.
Notes

Stacked. The base of this PR is the branch of #46652, so the diff here is only the bake change. When that branch is rebased, this one is rebased onto it. When #46652 merges, this branch gets a rebase onto main.

What #46652 does for bake. It assigns the main-field list of the graph's target after init. That corrects a package with main and a string "browser", and one with main and jsnext:main. Here the target goes into init, which derives that list with the rest, so the assignment goes. BundleOptions::main_fields is pub(crate) again, as on main: no other crate assigns it.

Repro (dev server, also bun build --app). A framework like test/bake/minimal.server.ts, then:

node_modules/dual-pkg/package.json   {"name":"dual-pkg","main":"./node.js","browser":{"./node.js":"./browser.js"}}
node_modules/dual-pkg/node.js        module.exports = "SERVER-SIDE";
node_modules/dual-pkg/browser.js     module.exports = "BROWSER-SIDE";
routes/index.ts                      import v from "dual-pkg";
                                     export default () => new Response("ROUTE:" + v);

fetch("/") prints 200 ROUTE:BROWSER-SIDE on the base and 200 ROUTE:SERVER-SIDE here (release builds of both). With axios 1.6.8 in place of dual-pkg, a route that calls axios.get() fails on the base with There is no suitable adapter to dispatch the request since : - adapter xhr is not supported by the environment - adapter http is not available in the build, and gets its response here. The "browser" map of axios replaces ./lib/adapters/http.js.

Nobody outside reported this. app in Bun.serve and bun build --app are gated (feature_flags::bake(): canary and debug builds, or BUN_FEATURE_FLAG_EXPERIMENTAL_BAKE). #44496 lists "serverEntryPoint is still resolved with the client's resolver" under what it left unchanged.

What each graph resolves (debug builds of the base and of this branch, dev server and bun build --app):

package.json server and SSR graph, base server and SSR graph, this PR client graph, both
main + "browser": "./browser.js" main.js main.js browser.js
main + "browser": {"./main.js": "./browser.js"} browser.js main.js browser.js
main + "browser": {"./node.js": false} node.js is empty node.js node.js is empty
main + jsnext:main main.js main.js the jsnext:main file

For the third row the dev server of the base answers 500: ReferenceError: import_node is not defined. That is the first defect under "Left as it is", reached in the server graph.

Also new for a server graph:

  • require("../addon") finds addon.node (base: Could not resolve: "../addon"), because a transpiler for bun has .node in its extension order. Loading an addon in a server graph does not work, on the base too: import addon from "../addon.node" bundles with the napi loader on both and fails at the request with Failed to resolve dynamic import. The import without extension now fails there too.
  • serverEntryPoint gets the server's conditions (bun, node, not browser).
  • A stylesheet that a server module imports resolves as the server does. A "browser" map no longer redirects it.

Why the target goes into init. BundleOptions::from_api and Resolver::init1 derive state from the target once: the main fields, the extension order, the loaders, env.behavior, and the resolver's care_about_browser_field. bake assigned the target after init and rebuilt the conditions, and since #46652 the main fields. 9 of the 11 callers of Transpiler::init already put the target in the options. The other one that did not, the bun test --changed scan, is converted by #46652.

The directory watcher. When an import fails, the dev server watches the directory and tries the import again on a change (hot_reload_event.rs). It did that with the server resolver for every graph. That was harmless while the server resolver read "browser" maps. Now Dep keeps the graph of the importer, and the retry uses the client transpiler for the client graph. Without this, a client import that only a "browser" map can resolve never recovers (Reload not called in the new test). This is the one part that reaches Bun.serve HTML routes outside bake.

Client components. A "use client" file is bundled for the client graph and for the server. Each graph resolves the imports of its copy. The tests load the SSR copy through ssrManifest, as a framework does. Without an SSR graph they read the server graph's copy from the client reference.

The server copy is code that runs. With separateSSRGraph: true it is the SSR copy, and the server graph has only a generated proxy (the first parse of the file uses the client resolver). With separateSSRGraph: false the server graph keeps the implementation inside registerClientReference: the module is evaluated once on the server, and island.value() returns what the component returns. So an import of the component that resolves only for a browser cannot be left out of either.

pdfjs-dist 3.11.174 is such a dependency. Its "browser" map has "canvas": false, its build/pdf.js has require("canvas"), and bun install does not install the optional canvas. bun build --app of a client component that imports it exits 0 on the base and 1 here: Could not resolve: "canvas". A client-only import behind import.meta.env.SSR or a dynamic import avoids it. konva 8 and 9 (a string "browser", and a main that needs canvas) already fail on the base, since it reads main.

A package whose entry file is itself a "use client" module: every copy of a client component has the path that the importing resolver returned, so the client bundle gets what the server resolved.

entry of the package client bundle, base client bundle, this PR
exports with a browser condition node.js node.js
main + "browser": "./browser.js" node.js node.js
main + "browser": {"./node.js": "./browser.js"} browser.js node.js

On the base the SSR copy of the last one is browser.js too. A fix resolves the import once per graph, and then one client component is two files: the boundary code in bundle_v2.rs, the generated proxy and the dev server's graphs all pair the copies by one path today.

Next.js, for comparison:

  • Its server compilers also build the server copy of a client component. They read main and module, and not browser (getMainField in packages/next/src/build/webpack-config-rules/resolve.ts). konva fails there too: Module not found: Can't resolve 'canvas' (react-konva issues 750, 785 and 832).
  • Its client compiler gets a client component by the path that the server compiler resolved. getModuleResource in flight-client-entry-plugin.ts gives the reason: "to make sure the server and client are using the same module path (required by RSC), as the server compiler and client compiler have different resolve configs". Not tested: what the client compiler then does with a "browser" map for that path.

Shape of this PR. The first version of this branch fixed the mechanism for every transpiler: the resolver read opts.target in place of the flag it computes once, and the projection to the resolver derived the default main fields and the polyfill switch. It also built the --changed scan for bun. A review found that #46652 carries the same scan change, that the resolver part decides what #46548 decides the other way (it keeps the flag and adds Resolver::for_target), and that the two do not compile together. So this PR keeps what no other PR has. The resolver part is offered on #46548 with its numbers: each of the 6 "browser" gates stays one byte compare and one branch, a warm resolve costs 0 to 3 more instructions, and Transpiler::init does 9 mallocs and 5 frees fewer.

Left as it is.

  • The dev server emits a reference to an undefined binding for an ESM import of a file that a "browser" map disables (ReferenceError: nodeFile is not defined). It is on main too, in the client graph. The client assertions here read the bundle text and do not run it.
  • A "use client" module that a route imports and that a bunBakeGraph: "ssr" module imports too makes the dev server panic in about 1 of 4 runs: Server Incremental Graph is missing component for "". It is on the base too. The tests avoid that shape.
  • Macro mode (VirtualMachine::enable_macro_mode) and the page of a server build (initialize_client_transpiler) still assign the target. bundler: derive the options of a server build's page from the browser target #46548 owns the page.

Measurements (release builds of #46652 at 0c2ef95 and of this change on it, linux-x64, with gdb and size. perf, strace, valgrind and bloaty are not installed here. #46652 has since changed how it writes its main-field assignment. The numbers were not taken again for that)

What base this PR
init_transpiler, server graph: mallocs / frees 1251 / 235 1279 / 258
init_transpiler, SSR graph 1162 / 224 1190 / 247
init_transpiler, client graph 1153 / 213 1153 / 213
.env opens of a bake app with a .env file (gdb catch syscall) 1 1
release .text (size -A) 82,166,921 B 82,166,665 B

init_transpiler runs once per graph when a dev server starts or bun build --app runs. Outside src/runtime/bake the diff is one visibility keyword. Dep grows by one byte-sized field.

The first version of this branch, on main without #46652, gave the client bundle main for a shared string-"browser" package in 23 of 40 dev-server runs. With #46652 under it, both read orders are tests.

Each line that sets something back or swaps a resolver has a test. Debug builds with one line removed, made at the earlier head d501d86 (these lines are the same now): without the env.behavior line, both a server route reads process.env tests fail. With serverEntryPoint on the client resolver, both a framework's server modules resolve for bun tests fail. With the retry on the server resolver, importing a file that a "browser" map names before it is created fails.

Tests. 9 of the 12 new tests fail on a debug build of the base (#46652 at 0f1a0e9): the two read orders of each graph resolves packages, the two modes of a client component's packages, the framework test and its production twin, the .node test, the stylesheet test, and the production graph test. The other 3 pass on both and are the ones above.

Suites (debug build of this branch at 79a6f4d): all of test/bake, 26 files: 285 pass, 0 fail. One timing test of an HTML route, incremental graph handles edge deletion with next dependency, is not stable on this machine (Client sent 5 unread messages). Run alone 25 times, it fails 3 times on a build of the base and 4 times on a build of this branch. The React tests of production.test.ts need more than the default 5 s on a debug build here, with or without this change.

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:20 AM PT - Oct 24th, 2026

✅ @robobun, your commit 79a6f4d4cbda4a68667be75e31dceb2b086925f4 passed in Build #132017! 🎉


🧪   To try this PR locally:

bunx bun-pr 46702

That installs a local version of the PR into your bun-46702 executable, so you can run:

bun-46702 --bun

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on the base branch of this PR (#46652), with release and debug builds. A bake server route that imports a package with "main" and a "browser" map answers 200 ROUTE:BROWSER-SIDE. With axios 1.6.8, the route fails with There is no suitable adapter to dispatch the request. 9 of the 12 new tests in test/bake/dev fail on a debug build of the base (0f1a0e9) and pass on this branch.

#46652 now corrects the main fields of these graphs, so the string "browser" case is fixed there. This branch follows it: it is rebased onto 0f1a0e9 at 79a6f4d, with no change to its own files.

CI at 79a6f4d (build 132017): every lane that ran passes. That is every Linux lane, Windows 2019 x64, Windows 11 aarch64 and macOS aarch64. The two macOS x64 shards did not get an agent: the first pair expired after 88 minutes in the queue, and the retried pair is in the queue.

This PR merges after #46652.

PR: #46702

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, I also checked two things that turned out not to be issues: the directory-change retry busts the dir cache only through the server resolver, but Resolver::dir_cache is a process-global static (src/resolver/resolver.rs:804), so the new client-graph retry sees the same busted entries; and the other options Transpiler::init now derives from the bun target (inlining, import_path_format) are read only by the legacy non-bundle_v2 transpile path, so they do not change bake output.

Extended reasoning...

The change moves the bake server/SSR transpiler target into Transpiler::init, resolves the server entrypoint with the server resolver, and threads the importer's graph into the directory-watch retry; the three inline findings cover the resulting resolution regressions for "use client" modules and the new .node loader in the server graph. It touches no security-sensitive surface. The cache-bust and derived-option checks above were examined from the resolver and options source and ruled out.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/runtime/bake/config/transpiler.rs
Comment thread src/runtime/bake/config/transpiler.rs
Comment thread src/runtime/bake/config/transpiler.rs
Comment thread src/runtime/bake/config/transpiler.rs Outdated
Comment thread src/runtime/bake/config/transpiler.rs Outdated
Comment thread src/runtime/bake/dev_server/hot_reload_event.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (3):

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-up, at d501d86.

Changed:

  • cargo clippy failed on two unsafe blocks without a SAFETY comment in hot_reload_event.rs. The retry now picks the transpiler slot first and has one block with its comment.
  • The three code comments that the comment check flagged are removed or one line. The environment reset is now the else branch of the env option.

Not changed, with the measurements in each thread and in the PR body:

  • A native addon in a server route. An import with the .node extension fails at the request on the base too. Only the import without the extension moves from a build error to that same failure.
  • A client component's dependency that has no entry for a server. In both separateSSRGraph modes the server-side copy of the component is code that runs, so its imports cannot be left out.
  • The client copy of a package whose entry file is a "use client" module. It has the path that the server resolved. On the base that already holds for an exports condition. For a "browser" field or map it is new, and it is now under Downsides.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun
robobun force-pushed the robobun/076b1765/main-fields-shared-cache branch 2 times, most recently from facba87 to 0c2ef95 Compare October 24, 2026 08:58
@robobun
robobun force-pushed the robobun/6e4cd3de/bake-server-graph-target-options branch from d501d86 to 5b5d0c5 Compare October 24, 2026 10:46

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline nit, I audited the other state BundleOptions::from_api derives from the target now that the server/SSR transpilers are built for bun: inlining and import_path_format are only read on the non-bundle_v2 transpile path, out_extensions, tree_shaking and minify_syntax are overwritten later in init_transpiler, and polyfill_node_globals now correctly becomes false for server graphs. Also checked the new assume_init_mut on client_transpiler in hot_reload_event.rs: both slots are written in DevServer::init before the box is assumed initialized, and the Ssr arm mapping to server_transpiler avoids the aliased ssr_transpiler when separateSSRGraph is off.

Extended reasoning...

The change moves the bake target into Transpiler::init (src/runtime/bake/config/transpiler.rs), resolves serverEntryPoint with the server resolver, threads the importer graph through Dep so the directory-watch retry uses the matching transpiler, narrows BundleOptions::main_fields to pub(crate), and adds dev-server and bun build --app tests. No security-sensitive surface is touched. One nit finding is posted inline; the earlier rounds' findings about client-component resolution were self-resolved by the author, so a human look at that design choice remains worthwhile.

Comment thread test/bake/dev/esm.test.ts Outdated
@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-up, at 2116402.

  • Changed: the separateSSRGraph: false variant of a client component's packages resolve for each graph that bundles it now asserts the server graph's copy. The route returns island.value, and the test compares it with platformFiles.bun.
  • Not changed: how the packages of a client component resolve in each graph. The two first Downsides bullets state that behaviour. The Notes section "Client components" has the measurements and what Next.js does in the same two cases.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the latest push and found no bugs; the test-only commit addresses the separateSSRGraph: false gap raised on test/bake/dev/esm.test.ts. Because this changes how every bake server/SSR graph resolves packages, with build failures the description itself lists as downsides, a maintainer should still weigh the trade-off before merging.

What was reviewed:

  • Every side effect BundleOptions::from_api applies for target: bun beyond main fields: conditions are fully rebuilt at transpiler.rs:63, env.behavior is reset to disable before configure_defines, tree_shaking/minify_syntax are overwritten later, import_path_format and inlining are not read on the bundle_v2 path, and polyfill_node_globals now correctly turns off for the server resolver.
  • The dev-server retry now uses the client transpiler for client-graph deps; the resolver's dir_cache is a process-wide singleton, so busting it through the server resolver still covers the client retry.
  • Dep.graph is set at both constructor sites; the Default value is only used to clear freed slots.
Extended reasoning...

The change switches bake's server and SSR transpilers to the bun target inside Transpiler::init (transpiler.rs), resolves the framework server entry with the server resolver (framework.rs), records the importer graph on directory-watch deps so failed-import retries use the matching transpiler (directory_watch_store.rs, hot_reload_event.rs), narrows main_fields to pub(crate), and adds dev and production tests plus harness fixtures. It touches no auth, crypto, or injection surface. Deferring rather than approving because the PR is stacked on another unmerged branch, intentionally changes module-resolution behavior for every bake server graph with acknowledged regressions (browser-only deps of "use client" components now fail the build, .node loader in server graphs), and the earlier inline concerns on those points were resolved by the author rather than an independent reviewer.

@robobun
robobun force-pushed the robobun/076b1765/main-fields-shared-cache branch from 0c2ef95 to 0f1a0e9 Compare October 24, 2026 11:44
`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.
…R 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.
@robobun
robobun force-pushed the robobun/6e4cd3de/bake-server-graph-target-options branch from 2116402 to 79a6f4d Compare October 24, 2026 12:30

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the latest push and found no new bugs; the second commit closes the separateSSRGraph: false gap in the client-component test. Because this changes which file every bake server/SSR import resolves to, with the trade-offs the description already lists, a human look at that design choice is still worthwhile.

What was reviewed:

  • Everything else BundleOptions::from_api derives from the bun target besides main fields, extension order, loaders and env: tree_shaking/minify_syntax are overwritten by bake, inlining is only read by the single-file transpile path (bundle_v2 takes it from minify_syntax), import_path_format is only used by the linker's single-file path, polyfill_node_globals now false for server/SSR is the right direction, and init_external_modules treats bun and browser identically.
  • The directory-watch retry: bust_dir_cache still runs only on the server resolver, but the dir cache and FileSystem are process singletons shared by the client resolver, so a client-graph retry sees the busted entry; Dep::default()'s Graph::Client is only written into freed slots that are never read.
  • Dep.graph == Ssr retries with the server resolver, whose conditions differ from the SSR resolver's only by react-server; the retry only probes is_ok() and the real re-bundle re-resolves with the right graph, so this changes nothing in practice.
Extended reasoning...

The change moves the bake server/SSR transpilers from a browser-default Transpiler::init with post-hoc target patching to passing the bun target into init, restores env.behavior = disable when no env option is configured, resolves the framework's serverEntryPoint with the server resolver, and makes the dev server's failed-import retry use the importer graph's resolver via a new Dep.graph field; no security-sensitive surface is touched. Tests cover the dev server and bun build --app for the four package shapes, the .node extension lookup, the env non-inlining, and the browser-map retry. I did not approve because the PR knowingly changes resolution results for existing bake apps (a "use client" module's browser-only imports now fail the server-graph build; a package whose entry is a client component ships the server-resolved file to the browser), which is a product trade-off a maintainer should sign off on, and the earlier high-severity threads on those points were resolved by the author only.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant