Skip to content

bundler: copy HTML url assets resolved through files: or an onResolve plugin - #38635

Open
robobun wants to merge 1 commit into
mainfrom
farm/06761c18/html-url-assets-files-and-plugins
Open

bundler: copy HTML url assets resolved through files: or an onResolve plugin#38635
robobun wants to merge 1 commit into
mainfrom
farm/06761c18/html-url-assets-files-and-plugins

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • An HTML document that references a url asset whose extension has a parsing loader (<link rel="manifest" href="./manifest.json">, json/toml/yaml/text/...) only gets the asset copied to the output when the reference is resolved by the resolver on the bulk resolution path. On every other resolution path the file is bundled as a module instead: no asset is emitted and the href is left as written. Affected:
    • the asset (or the HTML) comes from Bun.build({ files }) (the in-memory branch of resolve_import_records),
    • an onResolve callback matches the reference and declines it (run_resolver, both its disk and in-memory branch),
    • an onResolve callback returns the path (on_resolve, Success arm).
  • Cause: the rule from fix(bundler): copy non-JS/CSS files referenced as URL assets in HTML #27039 that forces the file loader for such references lives inline in the disk tail of resolve_import_records (src/bundler/bundle_v2.rs, the import_record_loader block). The three other places that pick the loader of an import record compute import attribute, else extension loader and stop there. The in-memory branch of resolve_import_records is a copy of the disk tail that predates fix(bundler): copy non-JS/CSS files referenced as URL assets in HTML #27039 and has been drifting since (it also lacks the HTML import manifest handling that bundler: create the HTML import manifest on the plugin and in-memory resolution paths #38605 is adding to it).

Fix

  • loader_for_import(importer_loader, kind, resolved_loader) is the one definition of the rule; resolve_import_records, run_resolver (both branches) and on_resolve apply it to the loader they computed. The plugin paths read the importer's loader back from the graph (loader_for_plugin_import); entry-point records have no importer and are passed through.
  • The in-memory branch of resolve_import_records is deleted. A file map hit now takes the place of the resolver's result and goes through the same tail as a file on disk, so everything the tail does (this rule, the HTML import manifest, dedup through the path map and the resolve queue) applies to in-memory files too. The two things that were specific to that branch are kept: jsx comes from the transpiler (a file map result has no tsconfig.json behind it), and the display path stays the map key. Everything else it set (known_target, tree_shaking, side_effects, the force_node_env override) is what the tail sets anyway.
  • Why this is right: whether a reference is an asset to copy is a property of the importing document and the kind of reference, not of how the path was found. The rule itself is unchanged (HTML importer, url reference, loader that neither copies nor is js/css/html); it is only applied consistently, so disk builds without plugins produce the same output as before.
  • Verified:
    • test/bundler/bundler_files.test.ts: asset from an in-memory HTML file, from an HTML file on disk with the asset in files, with a declining onResolve callback, and with a callback returning the path of an in-memory file; a server-side build importing an in-memory HTML file gets its manifest; jsx options and import attributes still apply to in-memory imports (guards for the removed branch). 5 of these fail on the current build, all pass with this one.
    • test/bundler/bundler_html.test.ts: html/manifest-json-onresolve-declines and html/manifest-json-onresolve-path (files on disk, resolved through a plugin), next to the existing html/manifest-json. Both fail on the current build.
    • bundler_html, bundler_html_server, html-import-manifest, bundler_plugin, bundler_plugin_chain, bundler_loader, metafile, bun-build-api, bun-serve-html-manifest, bake/dev/plugins, bake/dev/html, css/doesnt_crash pass with the debug build; cargo clippy -p bun_bundler is clean.

Background

  • Loaders: every file in the bundle graph is processed by a loader, chosen from an import attribute (with { type }) or the file's extension. Parsing loaders (json, toml, text, ...) turn the file into a JS module; Loader::should_copy_for_bundling() loaders (file, wasm, ...) register the file as an additional output that is copied to the output directory under a hashed name, and references to it are rewritten to that name. The HTML scanner records <script> as a statement import, stylesheets as @import-style imports, and everything else (manifest, img, video, ...) as ImportKind::Url.
  • Resolution paths: after a file is parsed, its import records are resolved in bulk by resolve_import_records, which fills a resolve queue that process_resolve_queue turns into parse tasks. A record that matches an onResolve filter is instead handed to the plugin and comes back later through on_resolve: either with a path from the callback (Success), or, if every callback declined (NoMatch), through run_resolver, which runs the resolver itself. These two create the parse task directly. Bun.build({ files }) puts its entries in a file map that is consulted before the resolver on each of these paths.
Probe on the current build, and interplay with open PRs

Same three inputs (page.html with <link rel="manifest" href="./manifest.json"> and <script src="./app.js">), outputs per resolution path on bun 1.4.0:

disk, no plugin                 chunk.js  page.html  manifest-3ve6wxv8.json   href -> ./manifest-3ve6wxv8.json
files:, no plugin               chunk.js  page.html                           href -> ./manifest.json
disk html, asset in files:      chunk.js  page.html                           href -> ./manifest.json
disk, onResolve declines        chunk.js  page.html                           href -> ./manifest.json
files:, onResolve declines      chunk.js  page.html                           href -> ./manifest.json
disk, onResolve returns path    chunk.js  page.html                           href -> ./manifest.json
files:, onResolve returns path  chunk.js  page.html                           href -> ./manifest.json

With this branch all seven emit the asset and rewrite the href.

Open PRs touching the same lines, and what changes for whichever lands second:

… plugin

The rule that copies a url asset referenced from an HTML document to the
output even when its extension has a parsing loader (json, toml, ...) was
applied only when the reference was resolved by the resolver on the bulk
resolution path. A reference that hit the in-memory file map, that the
resolver handled after an onResolve callback declined it, or whose path came
from an onResolve callback kept the parsing loader, so the file was bundled
as a module and the reference in the HTML was left as written.

Move the rule into loader_for_import and apply it on every path that picks
the loader for an import record. The in-memory branch of
resolve_import_records, a copy of the disk tail that had drifted, is removed:
a file map hit now stands in for the resolver's result and goes through the
same tail, keeping the jsx options from the transpiler and the display path
of the map key.
@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: 35 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: 8f753c76-63a5-4a73-bb6e-d68bdb3c40d2

📥 Commits

Reviewing files that changed from the base of the PR and between 2f5c180 and 327a946.

📒 Files selected for processing (3)
  • src/bundler/bundle_v2.rs
  • test/bundler/bundler_files.test.ts
  • 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

Status: fix and tests pushed, waiting on CI.

Reproduced on bun 1.4.0 with a page referencing <link rel="manifest" href="./manifest.json">: with the files on disk and no plugins the build emits manifest-<hash>.json and rewrites the href; with the asset supplied through files:, or with an onResolve callback that declines the reference or returns its path, no asset is emitted and the href is left as ./manifest.json. The new tests in test/bundler/bundler_files.test.ts and test/bundler/bundler_html.test.ts fail the same way on the current build and pass with this branch.

The branch is based on d4ccab4 because main at 2f5c180 does not compile (report_active_exception_as_unhandled in PostgresSQLConnection.rs, tracked separately); nothing here depends on that commit.

@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 deletes the in-memory branch of resolve_import_records and routes those files through the disk tail — which newly exposes them to the dev-server cache check, is_html_entrypoint, and secondary_path_for_commonjs_interop — and because it overlaps with three open PRs on the same lines, a human look would be worthwhile before merging.

What was reviewed:

  • loader_for_plugin_import guards EntryPointBuild before indexing items_loader, so the u32::MAX sentinel is never dereferenced; the only other MiniImportRecord producer sets a valid parsed-file index.
  • FileMap::result_for_key returns Default (HasSideEffects, not external, no secondary path), so the tail's is_external / secondary-path handling is a no-op for in-memory results and ParseTask::init picks up the same side_effects the deleted branch set.
  • The if let Some(mut result) = in_memory_result inside 'inner: loop is fine — the Some arm always breaks, and the None arm doesn't move the option, so the continue 'inner retry path still sees it.
  • The rule in loader_for_import is byte-identical to the inline block it replaces; disk-no-plugin builds are unaffected.
Extended reasoning...

Overview

This PR extracts the "HTML url references get the file loader even if their extension has a parsing loader" rule from an inline block in the disk tail of resolve_import_records into loader_for_import(), and applies it at all four places that pick an import record's loader: the bulk resolver tail, both branches of run_resolver (onResolve declined), and on_resolve (onResolve returned a path). It also deletes the ~60-line in-memory (Bun.build({ files })) branch of resolve_import_records and instead lets a file-map hit stand in for the resolver's result at the top of the loop, so in-memory files flow through the same tail as disk files. Nine new tests cover each resolution path plus regression guards for jsx options and import attributes on the removed branch.

Security risks

None. This is bundler control flow deciding whether a referenced file is copied verbatim or parsed as a module; no untrusted input parsing, auth, crypto, or filesystem-boundary changes.

Level of scrutiny

High. resolve_import_records is the core of the bundler's module-graph construction, and collapsing a parallel branch into a shared tail means every side effect of that tail now applies to in-memory files. I traced each field the deleted branch set (known_target, jsx + force_node_env, loader, tree_shaking, side_effects) to its equivalent in the shared tail via ParseTask::init and FileMap::result_for_key's defaults — they match. I also checked what the tail does that the deleted branch didn't: the is_external() check and secondary_path_for_commonjs_interop are no-ops for the default Result; the dev-server cache block is gated on self.dev_server which is None for Bun.build; and is_html_entrypoint is the HTML-import-manifest behavior the PR intentionally enables and tests.

Other factors

The PR description explicitly maps out conflicts with three open PRs (#38605, #38620, #38609) that touch these same lines. That coordination, plus the fact that this is a non-mechanical refactor of a hot bundler path with CI still pending, is why I'm deferring rather than approving. The change itself reads correct and is exceptionally well-tested (each resolution path has its own test, plus guards for the two behaviors preserved from the removed branch), but the merge-order decision and confirmation that the newly-shared tail doesn't regress any dev-server / bake interaction with file_map warrants a maintainer's eyes.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the dev server question: BundleV2.file_map is set in exactly one place, src/runtime/api/js_bundle_completion_task.rs:1242, the Bun.build completion task, which constructs its BundleV2 with no bake options (BundleV2::init(transpiler, None, ...)). The dev server builds its own BundleV2 and never sets file_map, so dev_server and file_map are never both set and the dev-server cache block in the shared tail is unreachable for an in-memory result. The other two things the tail newly does for in-memory files are intentional (is_html_entrypoint, covered by the manifest test) or a no-op (secondary_path_for_commonjs_interop: FileMap::result_for_key never fills a secondary path). bake/dev/plugins and bake/dev/html pass with this build as well.

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