Skip to content

bundler: create the HTML import manifest on the plugin and in-memory resolution paths - #38605

Open
robobun wants to merge 5 commits into
mainfrom
farm/30313fcf/html-import-plugin-resolve
Open

bundler: create the HTML import manifest on the plugin and in-memory resolution paths#38605
robobun wants to merge 5 commits into
mainfrom
farm/30313fcf/html-import-plugin-resolve

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • import page from "./page.html" in a target: "bun" (or node) build works, but the same build fails with No matching export in "page.html" for import "default" as soon as any onResolve plugin matches the import, even one that returns undefined for everything. A plugin that returns the path of an .html file fails the same way.
  • require("./page.html") on those paths builds without an error but evaluates to an empty module, and an HTML file supplied through the files: option is never turned into a manifest either: the output keeps a literal import page from "/page.html" with { type: "html" } and the page's scripts are bundled for the server target.
  • Cause: the HTML-import handling (manifest module, HTML parsed as a browser entry point, record kind html_manifest) existed only on the bulk resolution path for files on disk, in resolve_import_records and process_resolve_queue (src/bundler/bundle_v2.rs). Every other place that binds a resolved import record to a module bundled the HTML file as an ordinary module of the server graph, which exports nothing:
    • on_resolve NoMatch -> run_resolver, both its disk branch and its files: branch,
    • on_resolve Success (plugin returned a path),
    • the files: branch of resolve_import_records, a copy of the disk branch's tail that had drifted from it (it also skipped the loader override that copies url assets referenced from an HTML file, so an in-memory page with <link rel="manifest" href="./manifest.json"> bundled the JSON instead of emitting the asset).

Fix

  • is_server_html_import is the one definition of "this import binds to a manifest module" (HTML loader, server-side target, no dev server); the bulk path's two inline copies use it too.
  • generate_server_html_module now only creates the manifest module and returns its source index. Each call site registers that index in its graph's path map and binds the record the same way it does for any other module (put on the get-then-put sites, the reserved get_or_put slot on the other two), so the record can live inside graph.ast, as it does on the plugin paths.
  • enqueue_server_html_import is the one-at-a-time counterpart of the bulk path, used by run_resolver (both branches) and on_resolve's Success arm: create the manifest module, and parse the HTML file as a Target::Browser entry point unless the browser graph already has it (a second importer never gets here, it finds the manifest in the server graph's map; an HTML file that is also a user entry point is parsed once). The callers bind the record to it and leave the record's kind alone: rewriting it to HtmlManifest, as the bulk path currently does, is what breaks import() and require() of HTML files (the linker wraps a module and the printer emits the import based on the record's Dynamic / Require kind), and the manifest wiring does not depend on it. bundler: keep the import kind of server-side HTML imports so import() and require() of the manifest link #38621 removes that rewrite on the bulk path; this PR does not add it to the new ones. The Success arm skips all of this for EntryPointBuild records, which are entry points rather than imports (bundler: bundle HTML entry points for the browser on every entry path of a server build #38594 handles those).
  • In resolve_import_records, a files: hit now stands in for the resolver's result and goes through the shared tail instead of its own copy of it; the only virtual-file specifics left are that jsx comes from the transpiler (no tsconfig.json to consult) and that the display path is the map key as-is. This is what makes the in-memory HTML import a manifest and also fixes the url-asset case above. (Taken from a parallel attempt at the files: half of this bug, see the comment below.)
  • Why this is right: the linker (process_html_import_files, HTMLImportManifest) needs the manifest module in html_imports.server_source_indices and the HTML file under the same path in the browser graph's map, and nothing else; every path now produces exactly that. Building the same inputs with no plugin, with a declining plugin and with a path-returning plugin yields byte-identical outputs; the metafiles differ only in the import's kind label (html_manifest on the bulk path until bundler: keep the import kind of server-side HTML imports so import() and require() of the manifest link #38621 lands, the real kind on the new paths), and import() of an HTML file already works on the new paths.
  • Verified with test/bundler/html-import-manifest.test.ts (onresolve-fallthrough, -require, -returns-html-path, -html-is-also-an-entry-point, plus a guard that with { type: "file" } still wins on the fall-through path) and test/bundler/bundler_files.test.ts ("HTML imports": bulk path, importer on disk, one manifest per page however often it is imported, declining plugin, path-returning plugin, url asset; plus a guard for the jsx options of in-memory files). Everything except the two guards fails on bun 1.4.0 and passes with this branch. Existing html-import-manifest, bundler_files, bundler_html, bundler_html_server, bundler_plugin, css/doesnt_crash (the other user of files:), bun-serve-html-manifest, bake/dev/html and bake/dev/plugins tests pass.

Background

  • HTML import manifest: when server-side code imports an HTML file, the bundler bundles the page (with its scripts and styles) as a separate browser build and replaces the import with a JSON object listing the files that build produced (index, files[] with paths, loaders and headers); Bun.serve routes consume it. Internally the import binds to a generated "manifest module" whose AST is a lazy export of a placeholder string that the linker replaces once output paths are known.
  • Path maps: BundleV2 keeps one path -> source index map per target graph. For an HTML import, the server graph's map holds the manifest module and the browser graph's map holds the real HTML file under the same path; process_html_import_files joins the two by that path.
  • Resolution paths: import records are normally resolved in bulk right after their file is parsed (resolve_import_records, then process_resolve_queue creates the parse tasks). A record that matches an onResolve filter is handed to the plugin instead and comes back later through on_resolve, which either uses the plugin's answer (Success) or runs the regular resolver itself (NoMatch -> run_resolver); both create the module for the record directly. files: (FileMap) is the in-memory file map of Bun.build({ files }), consulted before the resolver on both kinds of path.
Probes and adjacent findings

Repro on 1.4.0 (server.ts imports ./page.html, target: "bun"):

no plugin                        true  []
onResolve -> undefined           false ["No matching export in \"page.html\" for import \"default\""]
onResolve -> {path}              false ["No matching export in \"page.html\" for import \"default\""]
target node + onResolve          false ["No matching export in \"page.html\" for import \"default\""]
onLoad only                      true  []     (onLoad does not change how the record is resolved)
files: virtual html, no plugin   true  []     but the output still contains `import page from "/virtual/page.html" with { type: "html" }`

With this branch all of the above build, and comparing the three disk builds (no plugin, declining plugin, path-returning plugin) gives identical output files (one manifest per import, the page's script chunk compiled for the browser, i.e. without the // @bun pragma); the metafiles differ only in the kind label described above.

Related, pre-existing on the plugin-less path as well, and handled elsewhere: HTML entry points (not imports) bundled under the server target when a plugin or files: is involved (#38594); files: entry points failing when a plugin declines them (#38600; the files: tests here use a filter that does not match the entry point for that reason); import("./page.html") from server code on the bulk path emitting a call to an undefined require_page wrapper (#38621, whose analysis is why the new paths here do not rewrite the kind); the import attribute loader being ignored when a plugin returns a path (#37476, which is also why the Success arm here still derives the loader from the extension); and the manifest's index reading ././page.html under an explicit [dir]/[name].[ext] entry naming, which is why the tests compare the basename of index.

…resolution paths

A server-side import of an HTML file only became a manifest module plus a
browser entry point when the import record was resolved by the bulk pass in
resolve_import_records / process_resolve_queue. Records resolved one at a
time (an onResolve plugin declining the import, an onResolve plugin returning
the HTML file's path) and imports of in-memory `files:` entries bundled the
HTML as an ordinary module of the server graph instead, so the import either
failed with 'No matching export in "page.html" for import "default"' or, for
require() and in-memory files, built into output that never had a manifest.

Factor the decision into is_server_html_import, make
generate_server_html_module return the manifest module's index, and add
enqueue_server_html_import for the one-at-a-time paths: it creates the
manifest module in the importing graph and parses the HTML file as a browser
entry point unless the browser graph already has it.
@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: 40 seconds

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: 80912cd7-88b4-49a6-acf9-ac0992fbd25a

📥 Commits

Reviewing files that changed from the base of the PR and between 5638c62 and aa0a705.

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

Status: fix and tests pushed (latest aa0a705), waiting on CI.

Reproduced on bun 1.4.0 with a target: "bun" build of a file importing ./page.html: it builds with no plugins, and fails with No matching export in "page.html" for import "default" with any onResolve plugin registered (declining or returning the HTML path). The require() and files: variants build but emit no manifest. The new tests in test/bundler/html-import-manifest.test.ts and test/bundler/bundler_files.test.ts fail the same way on 1.4.0 and pass with this branch; with the fix, plugin and plugin-less builds of the same inputs produce identical outputs.

Since the first push: the files: branch of resolve_import_records is replaced by the shared tail (70b34c6, from the parallel attempt linked below), the manifest module is registered by each call site (70af9ef), and the new paths leave the import record's kind alone so they compose with #38621 (aa0a705). Adjacent entry-point bugs found along the way are handled in #38594 and #38600.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

I was working on the files: branch of resolve_import_records for the same symptom (an in-memory .html import in a target: "bun" build keeps the raw import, and the page's scripts get bundled under the server target). This PR covers that, so I am not opening a separate one.

One thing that may be worth folding in here, or doing as a follow-up: the files: branch is a copy of the tail of the disk branch that has drifted, and the manifest lines are not the only thing it is missing. It also skips the loader override for url assets referenced from an HTML file, so an in-memory page with <link rel="manifest" href="./manifest.json"> bundles manifest.json with the json loader: no asset is emitted and the href is left as-is. On disk the file is copied to the output and the href rewritten.

Branch with an alternative shape of the resolve_import_records part: main...farm/7525473c/files-html-import-manifest (ce68741). A files: hit becomes the resolve_result and falls through the shared tail; the only virtual-file specifics kept are jsx from the transpiler and pretty being the map key. That removes the duplicated branch (net -49 lines) and fixes both the manifest case and the asset case. Tests for the manifest (bulk path, importer on disk, the same page imported twice) and for the asset are in bundler_files.test.ts there. It conflicts textually with this PR in bundle_v2.rs and bundler_files.test.ts, so whichever lands second needs a rebase; the run_resolver / on_resolve parts of this PR are not affected either way.

Comment thread src/bundler/bundle_v2.rs
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up from #38621, which fixes the import("./page.html") / require("./page.html") case this PR's description lists as found along the way: the cause of that one is the import_record.kind = ImportKind::HtmlManifest assignment itself. The linker wraps a module (or, with splitting, gives it a chunk of its own) based on the Require / Dynamic kind of the records that import it, and the printer emits the import from the same kind, so once the kind is replaced the manifest module is never wrapped while the call to require_page() is still printed. #38621 removes the assignment on the bulk path and uses the is_html_entrypoint predicate for known_target directly (the only thing that read the rewritten kind).

This branch adds the same assignment to run_resolver, the on_resolve Success arm and the files: branch, which would carry that bug onto those paths. Whichever of the two lands second should drop the assignments; the manifest wiring (server_source_indices plus the browser graph's path map entry) does not depend on the kind. The metafile then reports the real kind (import-statement, dynamic-import, require-call) on every path, so the byte-identical comparison in this PR's description still holds, just with those labels.

…disk

Bun.build({ files }) resolved an import that hits the in-memory file map in
a separate branch of resolve_import_records that duplicated the tail of the
disk resolution and had drifted from it. Two things it did not do:

- importing an .html file into a server build did not turn the import into
  an HTML import manifest: the output kept a literal import of the virtual
  path and the page's scripts were bundled for the server target
- a url asset referenced from an in-memory .html file (for example
  <link rel="manifest" href="./manifest.json">) kept its parsed loader
  instead of being copied to the output as a file

A file map hit now only substitutes for the resolver's result and then goes
through the shared steps, keeping the two things that are specific to
virtual files: the jsx options come from the transpiler (there is no
tsconfig.json to consult) and the display path is the map key as-is.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:05 PM PT - Aug 14th, 2026

@robobun, your commit 70af9ef has some failures in Build #96369 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38605

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

bun-38605 --bun

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/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
…side generate_server_html_module

The two call sites that reserve their map slot with get_or_put now fill it
the same way their non-HTML siblings do, and the two that use get-then-put
call put; generate_server_html_module only creates the module.
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

#38635 now carries the restructure mentioned above (the files: branch of resolve_import_records is removed and a file map hit goes through the disk tail), as the fix for the url asset loader gap. It does not touch the run_resolver / on_resolve / process_resolve_queue parts of this PR; whichever of the two lands second only needs to drop or re-apply the hunk in that branch and re-merge the tests appended to bundler_files.test.ts.

Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
…nifest module

The linker wraps a module (or gives it its own chunk) based on the Require /
Dynamic kind of the records importing it, and the printer emits the import
from the same kind, so replacing the kind with HtmlManifest is what breaks
import() and require() of an HTML file. The manifest wiring does not depend
on it: the module is found through html_imports.server_source_indices and the
browser graph's path map. Do not introduce the rewrite on the paths added
here; the existing one on the bulk path is removed separately.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Re the files: branch: agreed that replacing the drifted copy is the better shape, so that change is folded in here as 70b34c6 (same commit, conflicts resolved against this branch), together with its tests; the two plugin-path cases for in-memory pages were added to the same describe block, and the three-line patch to the old branch is gone with the branch.

Re #38621: thanks, that matches what I saw when probing (import() of the page printed a call to a require_page that was never defined, on the bulk path and, with the rewrite copied over, on the plugin paths too). aa0a705 drops the kind assignment from the paths this PR adds, so the two changes compose in either landing order: this PR never introduces the rewrite, #38621 removes the existing one. With that commit import() of an HTML file already works on the plugin paths, and the outputs of the plugin and plugin-less builds are still byte-identical; until #38621 lands the metafiles differ only in the kind label. The description is updated accordingly.

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