Skip to content

bundler: bundle HTML entry points for the browser on every entry path of a server build - #38594

Open
robobun wants to merge 1 commit into
mainfrom
farm/a51549c6/html-entry-target-plugins-files
Open

robobun wants to merge 1 commit into
mainfrom
farm/a51549c6/html-entry-target-plugins-files

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.build({ entrypoints: ["./page.html"], target: "bun" }) bundles the page's scripts for the browser, but the same build with any onResolve plugin whose filter matches the entry (even one that returns undefined), with a plugin that returns the path of an .html file for an entry, or with the entry supplied through files: bundles them as server code: the client chunk starts with // @bun, packages resolve through the server conditions instead of browser, node builtins are left as bare imports, and so on. Same with target: "node" minus the pragma.
  • Cause: the HTML -> Target::Browser switch lived only in the resolve-from-disk branch of enqueue_entry_points_normal (src/bundler/bundle_v2.rs). The files: branch of the same function, the EntryPointBuild fallback in on_resolve (plugin declined) and the EntryPointBuild success path in on_resolve (plugin returned a path) all registered the entry under the server target, so the HTML file and everything it references were parsed with the server transpiler.

Fix

  • entry_point_target(loader, target): an entry point with the html loader in a server-side build is bundled for Target::Browser (and the client transpiler is created); otherwise the requested target is kept. The dev server is exempt, as before.
  • enqueue_entry_item applies it, which covers the disk branch (the inline block there is removed), the files: branch and the plugin-declined fallback. on_resolve's success path applies it to EntryPointBuild records only; plugin-resolved imports keep their importer's target.
  • Why this is right: it is the rule the bundler already applies to HTML in a server build when the HTML is imported from server code (process_resolve_queue: loader == Html && target.is_server_side() && dev_server.is_none() -> browser graph) and to HTML entries resolved from disk. Applying it at the point where an entry point is registered makes the result independent of how the entry was resolved: in the probe below, both plugin variants now emit a chunk byte-identical to the plugin-free build (and to target: "browser"), and the files: variant differs only in the path comment.
  • Registering the entry in the browser graph also fixes the interaction with server code importing the same HTML file: previously a plugin-resolved HTML entry sat in the server graph under its real path, so import page from "./page.html" in the server entry linked to the HTML source itself and failed with No matching export in "page.html" for import "default" instead of getting the import manifest. Listing the same HTML file twice (once on disk, once through a plugin) now dedupes to one HTML output, as it already did for target: "browser".
  • Verified with test/bundler/bundler_html.test.ts ("html entry point in a server-side build is bundled for the browser"): for target: "bun" and "node", the client chunk of an HTML entry resolved from disk, through a declining onResolve plugin, through an onResolve plugin returning an .html path, and through files: picks the browser export condition and has no // @bun pragma. On the release build the six plugin/files: cases fail, the two disk cases pass; all pass with this change.
  • Also ran html-import-manifest, bundler_files, bun-build-api, bundler_plugin, bundler_html_server, standalone and the bake dev/html, dev/plugins, dev/production, dev-and-prod tests against the debug build: no failures related to this change.

Background

  • A server-side (bun/node) build that contains HTML bundles two module graphs: the server graph, and a browser graph for the HTML and its scripts/styles. Each graph has its own path -> source index map (path_to_source_index_map(target)) and its own transpiler; the browser one is created lazily by ensure_client_transpiler and must exist before a browser-target parse task is scheduled, because parse workers clone it from BundleV2::client_transpiler.
  • ParseTask::known_target selects which transpiler parses a file; the HTML file's target is inherited by every script and stylesheet it references, so the entry's target decides the target of the whole client chunk. // @bun is emitted for chunks whose entry was parsed for the bun target.
  • Entry points reach the bundler on three paths: enqueue_entry_points_normal (resolver or files:), and, when a plugin's onResolve filter matches the entry specifier, on_resolve with an ImportKind::EntryPointBuild record, which either falls back to the resolver (plugin returned nothing) or registers the path the plugin returned.
Probe on bun 1.4.0 (page.html with a script tag for app.js)
baseline:        // app.js\nconsole.log(typeof window, process.platform);\n
plugin-declines: // @bun\n// app.js\nconsole.log(typeof window, process.platform);\n
plugin-returns:  // @bun\n// app.js\nconsole.log(typeof window, process.platform);\n
files:           // @bun\n// /v/app.js\nconsole.log(typeof window, process.platform);\n

With a package exporting { browser: "./browser.js", default: "./server.js" }, the plugin and files: variants resolve server.js under both target: "bun" and target: "node"; the baseline resolves browser.js.

Two neighbouring bugs found while probing are reported separately and are not changed here: a files: entry is "ModuleNotFound" when a matching onResolve plugin declines it (the fallback never consults the file map), and a server file importing an .html file supplied through files: gets no import manifest.

… of a server build

An HTML entry point in a target bun/node build was only switched to the
browser target when it was resolved from disk without plugins. Entry
points that went through a declining onResolve plugin, an onResolve
plugin returning an .html path, or the files option were enqueued under
the server target, so their script chunk was compiled as server code.

Move the switch into enqueue_entry_item and apply it to plugin-resolved
entry points in on_resolve, so every entry point path uses the same
rule.
@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: 20 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: d1b23fc0-8d47-42fd-a1a4-39e300de7690

📥 Commits

Reviewing files that changed from the base of the PR and between eabb96d and d0edb85.

📒 Files selected for processing (2)
  • src/bundler/bundle_v2.rs
  • test/bundler/bundler_html.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 9:45 AM PT - Aug 14th, 2026

@robobun, your commit d0edb85 is building: #96256

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.0 with Bun.build({ entrypoints: ["./page.html"], target: "bun" }) plus (a) an onResolve plugin that matches the entry and returns undefined, (b) an onResolve plugin returning the .html path for a virtual entry, and (c) the entry supplied through files:. In all three the client chunk starts with // @bun and resolves packages through the server export conditions; without plugins/files: it is built for the browser.

Fix and tests are in this PR (test/bundler/bundler_html.test.ts, "html entry point in a server-side build is bundled for the browser"): the six plugin/files: cases fail on the release build and pass with the change, for both target: "bun" and "node".

@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 this PR and didn't find any bugs. Because it changes how enqueue_entry_item picks the target for every one of its callers (not just enqueue_entry_points_normal), a human look at the bake-production and dev-server interactions would still be worthwhile.

What was reviewed:

  • Traced all six enqueue_entry_item callers: dev-server callers are guarded by self.dev_server.is_none() in entry_point_target, so no behavior change there; enqueue_entry_points_bake_production server-side entries now get the same HTML→Browser switch process_resolve_queue already applies.
  • Confirmed hoisting the loader lookup above the path_to_source_index_map dedup check is the point of the fix (dedup now happens against the browser map for HTML entries) and matches what the disk branch already did.
  • Checked the on_resolve Success path: the switch is gated on ImportKind::EntryPointBuild, so plugin-resolved non-entry imports keep their importer's target; the moved loader binding is used identically at both old sites.
  • Tests cover all four entry-resolution paths × both server targets and assert the browser export condition, not just absence of // @bun.
Extended reasoning...

Overview

The PR fixes an inconsistency in src/bundler/bundle_v2.rs: an HTML entry point in a target: "bun"/"node" build was only switched to the browser module graph when it was resolved directly from disk. The same entry supplied via files:, or matched by an onResolve plugin (whether the plugin declined or returned a path), stayed in the server graph, so its script chunk was built with server export conditions and the // @bun pragma. The fix extracts the existing rule into entry_point_target(loader, target) and applies it inside enqueue_entry_item (covering the disk, files:, and plugin-declined fallback paths) and in the on_resolve Success path for EntryPointBuild records only. Eight new tests in test/bundler/bundler_html.test.ts cover the four resolution paths × two server targets.

Security risks

None. This is target-selection logic in the bundler; no input validation, auth, crypto, or filesystem-traversal surface is touched.

Level of scrutiny

Medium-high. The bundler's entry-point registration decides which of two module graphs a file lands in, which transpiler parses it, and which path_to_source_index_map dedups it. enqueue_entry_item has six call sites (normal, dev-server ×3, bake-production, on_resolve fallback), and moving the target rewrite inside it means every caller now sees it. The dev-server callers are protected by the self.dev_server.is_none() guard (and enqueue_entry_points_dev_server asserts dev_server.is_some()), and enqueue_entry_points_bake_production gets the same rule process_resolve_queue already applies to imported HTML — but a maintainer who knows the bake framework's expectations for server-side HTML routes should confirm that's intended.

Other factors

  • The old inline block in enqueue_entry_points_normal did not check dev_server.is_none(), but that function is only reached in non-dev-server builds, so adding the guard in the helper is a no-op there and correctly exempts the dev-server callers.
  • The loader lookup was hoisted above the dedup check; the .unwrap_or(Loader::File) fallback is equivalent to the old if let Some(loader) gate since File != Html.
  • The on_resolve Success change threads the recomputed target through path_to_source_index_map, path_with_pretty_initialized, transpiler_for_target, and known_target, and the loader binding moved out of the !found_existing branch is otherwise used identically.
  • The PR description is unusually thorough, states the mechanism, cites the matching rule in process_resolve_queue, and lists the neighbouring test suites that were run. No prior human reviews or outstanding comments. Given the cross-cutting nature of the change to a core bundler path, deferring rather than shadow-approving.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the two callers the review flags:

  • Dev server: enqueue_entry_points_dev_server and enqueue_file_from_dev_server_incremental_graph_invalidation only run with dev_server set, and entry_point_target returns the requested target unchanged in that case, so those paths are byte-for-byte the old behaviour. The old inline block in enqueue_entry_points_normal was only reachable without a dev server, so the guard does not narrow anything there either.
  • Bake production: its entry points are the framework's server entry plus the router's route files, and route files are loaded as server modules after the build (bake:/... module keys in production.rs). An .html route file is not a working configuration on either side of this change (the built-in framework routes jsx/tsx; a custom framework would have to opt .html in), so nothing supported changes there. Keeping the rule unconditional matches process_resolve_queue, which already moves HTML imported from server code into the browser graph in bake production builds too; adding a framework check to the entry path only would make entries and imports disagree.

The bake dev/html, dev/plugins, dev/production and dev-and-prod suites were run against this branch (see the PR description).

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