Skip to content

bundler: give the browser side of a server build its own copy of the runtime - #38376

Open
robobun wants to merge 4 commits into
mainfrom
farm/babb2e1d/browser-runtime-for-html-imports
Open

robobun wants to merge 4 commits into
mainfrom
farm/babb2e1d/browser-runtime-for-html-imports

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A --target=bun (or node) build that imports an HTML file bundles two module graphs, one per target, but parsed one copy of the bundler runtime (runtime.js), with the server flavor. It was the only source shared by both graphs.
  • Every browser chunk therefore got server helpers: var __require = import.meta.require; is emitted into the JS a page loads (the browser flavor is a shim around a global require), and __using/__callDispose come without their browser fallbacks. This happens with or without --splitting.
  • Runtime tree shaking is per file, so the server entry also carried the helpers only the browser used (__commonJS, ...) and the browser chunks carried the server's __jsonParse.
  • With --splitting, the runtime was reachable from the server entry and from the browser entries, so compute_chunks (src/bundler/linker_context/computeChunks.rs, the "which JS files are in which chunk" loop) keyed it by that combined bit set: one chunk, starting with // @bun, that server.js imports __jsonParse from, that every browser chunk imports __require from, and that is listed in the HTML import manifest, i.e. served to the browser.
  • Cause: enqueue_entry_points_common (src/bundler/bundle_v2.rs) parsed the runtime once for the main target, resolve_import_records bound every file's runtime imports to Index::RUNTIME, and the linker's helper lookups (runtime_function, cjs_runtime_ref, generateCompileResultForJSChunk.rs, postProcessJSChunk.rs, ...) all hardcoded Index::RUNTIME.

Fix

  • ensure_client_transpiler (the point where a server build starts bundling browser code, for an HTML import or an HTML entry point) now also enqueues a second copy of the runtime parsed for Target::Browser, recorded in Graph::browser_runtime_source_index. enqueue_entry_points_common and this path share enqueue_runtime.
  • resolve_import_records binds a file's runtime import records (the parser's bun:wrap import and user-written import ... from "bun:wrap") to the copy for the file's target.
  • Linker: LinkerGraph::runtime_source_index_for(file) picks the copy from the file's ast.target; every place that used to look up a helper in Index::RUNTIME now goes through runtime_function_for(file, name) / generate_runtime_symbol_import_and_use, and dependencies on the helper's parts use the ref's own source index. The printer's interop refs (__toESM, __toCommonJS, __require) are taken per part range / per chunk (runtime_print_refs_for), since a chunk's code must name the symbols of its own side. The "is this the runtime" special cases (never wrap it, print its parts first, no path comment, no source map, not a metafile input, skip TLA validation) use is_runtime_source, and find_reachable_files visits the second copy like the first.
  • The server-side HTML manifest stub created by generate_server_html_module gets the importing side's target (the parser leaves target at the browser default), so runtime_source_index_for and the existing browser-chunk flagging in compute_chunks see it as a server file.
  • The parser gets ParserOptions::source_is_runtime (set from the new ParseTask::is_runtime) instead of inferring "this is the runtime" from source index 0; this is what keeps require untouched inside the runtime's own __require shim and disables helper imports into the runtime, so the second copy is parsed exactly like the first.
  • The printer had the same source-index-0 test for deciding what the dev server format (--format=internal_bake_dev, also used by the bake dev server) prints as a module; it now gets js_printer::Options::source_is_runtime from the linker instead. For the first copy this is the same decision as before (test/bake/dev/{esm,bundle}.test.ts and the internal_bake_dev tests in bundler_loader.test.ts pass); the second copy only shows up in that format together with an HTML file, which already trips a pre-existing debug_assert in print_dev_server_module on main, so that combination is not testable here and is reported separately.
  • Why this is the right shape: everything else in these builds is already duplicated per target through the per-target path_to_source_index_map; the runtime was the one exception. Once each side's files depend on their own copy, entry bits, chunk assignment, cross-chunk imports, the // @bun pragma, chunk naming and the manifest all come out right without special cases, and the browser copy is the one bun build --target=browser would have produced (the shared browser chunk in the repro is byte-identical to the runtime chunk of a plain browser build, same content hash). With no browser side, browser_runtime_source_index is invalid and every helper resolves to Index::RUNTIME as before.
  • Bake is unaffected: it passes its client transpiler in up front, so ensure_client_transpiler never takes the branch, and it keeps the single shared runtime chunk that bake::production writes out for the client itself.
  • The [name] of the shared browser chunk is still the first entry point's (server-<hash>.js from the CLI, chunk-<hash>.js from the API / --compile); that is how every shared split chunk is named today and is tracked separately in bundler: stop naming shared split chunks after the first entrypoint #36326.
  • Verified with test/bundler/html-import-manifest.test.ts: three new tests (no splitting: browser chunk has the browser __require and no __jsonParse, server entry has no __commonJS; splitting: the only shared chunk is browser-flavored and server.js imports nothing the manifest lists; two HTML imports with splitting: the shared runtime chunk is in both manifests and has no // @bun). They fail on the release build with var __require = import.meta.require; in the browser chunk and // @bun on the shared chunk, on Linux and on Windows (checked against a Windows x64 build of this branch as well; the tests select manifest entries by loader/isEntry and compare file names, since the manifest's input field is not a plain relative name on Windows today). The existing manifest-with-metadata inline snapshot changes because the browser chunk no longer contains the server's __jsonParse, which moves its content hash and the HTML etag.
  • Also run, all passing: test/bundler/{bundler_html,bundler_html_server,bundler_splitting,bundler_compile_splitting,bundler_bun,bundler_browser,bundler_cjs,bundler_cjs2esm,bundler_naming,metafile,bundler_edgecase,bundler_regressions,bun-build-api,bundler_plugin,standalone,compile-asset-bunfs}.test.ts, test/bundler/esbuild/{splitting,default}.test.ts, test/bundler/transpiler/transpiler.test.js, test/js/bun/http/bun-serve-html{,-manifest}.test.ts, the HTML-import regression tests in test/regression/issue, test/bake/dev-and-prod.test.ts and test/bake/dev/{esm,bundle,html,production}.test.ts; cargo clippy on bun_bundler, bun_js_parser and bun_js_printer and cargo fmt --check are clean.
  • Manually checked --target=node (server gets the createRequire flavor, browser its own), an HTML file passed directly as an entry point next to the server entry, --minify --splitting (both sides still run), --compile --splitting (the compiled server serves the browser entry and the runtime chunk it imports, now a browser chunk), and the metafile.
  • Related: bundler: pull the runtime into chunks that print a CSS file's namespace object #38286 makes CSS namespace imports pull the runtime into a chunk; with this change a browser-side stylesheet pulls in the browser copy. The two diffs do not touch the same functions.

Background

  • Runtime: a generated module (src/runtime.js plus a per-target tail in ParseTask.rs) holding the helpers bundled code calls, such as __commonJS, __toESM, __require, __jsonParse. It is parsed as source index 0 (Index::RUNTIME). Files reach it in two ways: the parser emits an internal import {...} from "bun:wrap" for the helpers it uses, and the linker adds part dependencies for the helpers it introduces itself (wrappers, __export, the HTML manifest's __jsonParse).
  • HTML import in a server build: import page from "./index.html" in --target=bun code makes the bundler build the HTML file and everything it references as a browser-target graph in the same build, and replaces the import with a manifest (the list of output files, paths and etags) that Bun.serve uses to serve them. Each source file is parsed once per target (build_graphs[target]), so the two graphs share no files.
  • Entry bits and chunks (--splitting): every file is marked with the entry points that reach it; files with the same set of entry points form one chunk, so a file reached by several entries becomes a shared chunk those entries import. Whether a chunk is server or browser code (pragma, naming, public path, manifest listing) is decided from the target of the file the chunk was created for.
Repro output before / after
printf '<script type="module" src="./client.js"></script>' > client.html
printf 'import("./lazy.js").then(m => console.log(m.default));\n' > client.js
printf 'export default 1;\n' > lazy.js
printf 'import html from "./client.html";\nconsole.log(html.files.map(f => f.path));\n' > server.js
bun build server.js --target=bun --outdir out --splitting

Before, out/server-08y1dfjd.js (imported by server.js, client-*.js and lazy-*.js, listed in the manifest):

// @bun
var __jsonParse = (a) => JSON.parse(a);
var __require = import.meta.require;

export { __jsonParse, __require };

After, server.js starts with // @bun\nvar __jsonParse = ... and imports nothing; the chunk the browser files import is

var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
  ...
export { __require };

which is byte-for-byte the runtime chunk bun build client.js --target=browser --splitting emits (same c3taa3cg hash).

Without --splitting, the browser chunk of a client that imports a CommonJS file and calls require() went from containing var __jsonParse = ...; var __require = import.meta.require; to the browser __require shim and no __jsonParse; the server entry went from 1.81 KB (every helper the browser used) to 0.61 KB.

…runtime

A server-side build that imports HTML files bundles two module graphs, one
per target, but parsed a single copy of the bundler runtime (runtime.js),
with the server flavor. It was the only source shared by both graphs, so
every browser chunk took its helpers from a runtime built for Bun
(`__require = import.meta.require`), the server entry carried the helpers
only the browser used and vice versa, and with --splitting the runtime
landed in one chunk that both the server entry and the browser chunks
imported: a `// @bun` file listed in the HTML import manifest and loaded
by the browser.

The build now parses a second copy of the runtime for Target::Browser when
it creates its client transpiler, and browser-target files bind their
runtime imports to it. On the linker side every place that handed out a
runtime helper or special-cased the runtime source index goes through
LinkerGraph::runtime_source_index_for / runtime_function_for /
is_runtime_source, so each side's files depend on, print and import the
helpers of their own copy; chunk assignment then keeps the two copies apart
on its own. The parser learns which source is a runtime copy from an
explicit option instead of assuming source index 0.

Bake builds bring their own client transpiler and are unchanged.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 763605cb-aad2-4a83-abee-8cd90f818e4f

📥 Commits

Reviewing files that changed from the base of the PR and between abe3007 and 3a50878.

📒 Files selected for processing (18)
  • src/bundler/Graph.rs
  • src/bundler/LinkerContext.rs
  • src/bundler/LinkerGraph.rs
  • src/bundler/ParseTask.rs
  • src/bundler/bundle_v2.rs
  • src/bundler/linker_context/MetafileBuilder.rs
  • src/bundler/linker_context/convertStmtsForChunk.rs
  • src/bundler/linker_context/doStep5.rs
  • src/bundler/linker_context/findAllImportedPartsInJSOrder.rs
  • src/bundler/linker_context/generateCodeForFileInChunkJS.rs
  • src/bundler/linker_context/generateCompileResultForJSChunk.rs
  • src/bundler/linker_context/postProcessJSChunk.rs
  • src/bundler/linker_context/scanImportsAndExports.rs
  • src/bundler/transpiler.rs
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_printer/lib.rs
  • test/bundler/html-import-manifest.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 AM PT - Aug 14th, 2026

@robobun, your commit 4dc4126 is building: #95643

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed; waiting on CI.

  • Reproduced on bun 1.4.0 and on a debug build of main with the repro in the PR description (bun build server.js --target=bun --outdir out --splitting with an HTML import): the runtime lands in one // @bun chunk imported by both server.js and the browser chunks, and without --splitting the browser chunk contains var __require = import.meta.require;.
  • With this branch the browser files import a browser-flavored chunk and server.js inlines its own helpers; the three new tests in test/bundler/html-import-manifest.test.ts fail on the release build and pass with the fix, on Linux and (checked on a Windows x64 build of the branch) on Windows.
  • Review follow-ups so far: the printer's own source-index-0 check for the dev server format now takes the same flag (7bd8c32); flagged comments cut down (3a50878); the new tests no longer read the manifest's input field, which is what failed on the Windows lanes of the first CI run (4dc4126).

Comment thread src/bundler/linker_context/generateCodeForFileInChunkJS.rs
… bundler

print_with_writer decided whether to print a file as a dev server module by
testing for source index 0. The browser copy of the runtime is not at index
0, so pass the bundler's notion of which sources are runtime copies in
through the print options, like the parser and linker already do.
Comment thread src/bundler/Graph.rs Outdated
Comment thread src/bundler/Graph.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerGraph.rs Outdated
Comment thread src/bundler/LinkerGraph.rs Outdated
Comment thread src/bundler/LinkerGraph.rs Outdated
Comment thread src/bundler/LinkerGraph.rs Outdated
Comment thread src/bundler/ParseTask.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/linker_context/postProcessJSChunk.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/parse/parse_entry.rs Outdated
Comment thread src/js_printer/lib.rs Outdated
Comment thread test/bundler/html-import-manifest.test.ts Outdated
The manifest's input field is not a plain relative name on Windows, so the
new runtime tests select entries by loader/isEntry and compare file names.

@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.

Re-reviewed after 4dc4126 — the test now matches manifest entries and output files by basename(path) and picks the client chunk via loader === "js" && isEntry, which addresses the Windows path-matching failure I flagged. No new findings this pass. Given the scope — a new browser_runtime_source_index invariant threaded through ~15 linker sites plus parser/printer option plumbing across 18 files — a human look at the linker changes is still worthwhile.

Extended reasoning...

Overview

This PR fixes server builds that import HTML files: previously both the server and browser module graphs shared a single copy of the bundler runtime parsed for the server target, so browser chunks got var __require = import.meta.require (and other server-flavored helpers) and, with --splitting, the runtime landed in one // @bun-tagged chunk imported by both sides. The fix enqueues a second runtime copy parsed for Target::Browser when ensure_client_transpiler first runs, records its source index on Graph/LinkerGraph, and routes every runtime-helper lookup through runtime_function_for(file, name) / runtime_source_index_for(file) / is_runtime_source(idx). The parser and printer gain source_is_runtime so the second copy is parsed/printed like the first instead of being inferred from index 0. Touches 18 files across src/bundler/, src/js_parser/, src/js_printer/, plus three new tests in test/bundler/html-import-manifest.test.ts.

Since my last review

Commit 4dc4126 reworked the new tests to key everything by basename(...) and to select the client chunk structurally (loader === "js" && isEntry) rather than by the manifest input string, which was the cross-platform fragility I raised. The outputs map is now keyed by basename(output.path) and manifest entries carry name: basename(f.path), so the Map lookups line up regardless of how the manifest spells its paths. This looks like it resolves the Windows failure; build #95643 is still running per robobun. My earlier printer nit (the source.index.0 != 0 check) was already addressed in 7bd8c32 and is reflected in the diff (js_printer::Options::source_is_runtime).

Security risks

None identified. This is bundler output-shaping logic with no auth/crypto/permission surface; inputs are the same source files the bundler already processes.

Level of scrutiny

High. The change is architecturally clean (removes a special case rather than adding one), but it replaces a hard-coded Index::RUNTIME at many independent linker sites — scanImportsAndExports, doStep5, convertStmtsForChunk, generateCodeForFileInChunkJS, generateCompileResultForJSChunk, postProcessJSChunk, findAllImportedPartsInJSOrder, MetafileBuilder, plus LinkerContext::create_wrapper_for_file and the HTML manifest __jsonParse binding. A missed site would produce wrong output only in the server-build-with-HTML-import combination, which is exactly the coverage gap that let the original bug ship. The PR description is thorough and lists an extensive set of passing test files, but this is core linker code where a maintainer should confirm the design (per-file runtime lookup vs. per-chunk, and the ast.target read in runtime_source_index_for) and eyeball the site enumeration.

Other factors

The bug-hunting pass found nothing new this run. The comment-cop lint comments are all resolved (3a50878). The one open item is CI #95643 confirming the Windows test fix; beyond that, nothing blocks from my side, but the size and location of the change put it outside what I'd auto-approve.

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