Skip to content

bundler: keep the import kind of server-side HTML imports so import() and require() of the manifest link - #38621

Open
robobun wants to merge 3 commits into
mainfrom
farm/2af2873b/html-import-dynamic-require-wrap
Open

bundler: keep the import kind of server-side HTML imports so import() and require() of the manifest link#38621
robobun wants to merge 3 commits into
mainfrom
farm/2af2873b/html-import-dynamic-require-wrap

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • In a target: "bun" (or node) build, await import("./page.html") builds successfully but the output calls a wrapper that is never defined, and fails at startup with ReferenceError: require_page is not defined. Same for require("./page.html") from an ESM file. Reproduces on 1.4.0, with and without splitting.
  • require("./page.html") from a CommonJS-style file evaluates to { default: manifest } instead of the manifest, and with splitting: true a manifest module shared by two server entry points gets its (server) chunk listed in the manifest as one of the page's browser files.
  • Cause: resolve_import_records() (src/bundler/bundle_v2.rs:6562) overwrote the importing record's kind with ImportKind::HtmlManifest when it bound the record to the generated manifest module. The linker and printer decide how to emit an import from the record's kind: Require/Dynamic records get the imported module wrapped (scanImportsAndExports.rs:225-248) or, with splitting, turned into a chunk of its own (bundle_v2.rs:1852), and are printed as a call to that wrapper, a Promise.resolve().then(...) around it, or an import() of the chunk (js_printer/lib.rs:2506-2624). HtmlManifest matches none of those arms, so the manifest module was never wrapped while the printer still emitted the wrapper call (require_or_import_meta_for_source returns the wrapper ref unconditionally).
  • Only the record that created the manifest module was rewritten; a second importer of the same HTML file went through the path_to_source_index_map early return and kept its real kind. So a second import() of the same page worked and the first did not, and the metafile reported html_manifest for one importer and dynamic-import for the other.
  • Second cause (the splitting symptom): generate_server_html_module() never set the manifest module's AST target, so it kept the to_ast default of Browser. computeChunks uses the per-file target to flag chunks of a server build as browser output, and HTMLImportManifest lists such chunks in the manifest.

Fix

  • Stop overwriting the record's kind. The only reader of the rewritten value was the known_target of the HTML file's parse task, which now uses the is_html_entrypoint predicate directly. Nothing sets ImportKind::HtmlManifest any more; the variant itself is left in place since bundler: label conditional CSS @import records as import-rule #38549 is realigning the kind tables around it.
  • Set the manifest module's AST target to the importing (server) target in generate_server_html_module().
  • Why this is right: the manifest module is an ordinary lazy-export module (the same shape a .json file parses to), evaluated by server code; what differs between a static import, import() and require() of it is exactly what the record kind tells the linker. With the kind intact it goes through the same paths as a .json import and produces the same shapes: a static import stays var page_default = __jsonParse(...) (output unchanged for the existing tests), import() yields a promise of a namespace whose default is the manifest, and require() yields the manifest itself. That also matches what the unbundled runtime does (require("./page.html") is the HTMLBundle, import() puts it under default), and it makes every importer of a page behave like the second importer already did.
  • Observable side effect: the metafile now reports the import's real kind (import-statement, dynamic-import, require-call) for HTML imports. html_manifest was never part of the declared ImportKind type, was only reported for the first importer, and predates the metafile (Introduce ahead of time bundling for HTML imports with bun build #20265 vs feat(bundler): add metafile support matching esbuild format #25842, which matches esbuild's format, where kind is the import syntax).
  • Verified with test/bundler/html-import-manifest.test.ts: dynamic-import (also pins the metafile kind), dynamic-import-with-splitting, require-from-esm, require-from-cjs, splitting-shared-manifest-chunk. All five fail on 1.4.0 (the first three with require_page is not defined or no import() emitted, the last two on the shape of the result), all pass with this change; the file's six existing tests still pass.
  • Also passing: bundler_html_server, bundler_html, metafile, regression/issue/28042, bun-serve-html-manifest, and the HTML import case of bundler_compile; a manual bun build --compile of a file that import()s a page runs.
  • Related open PRs: bundler: create the HTML import manifest on the plugin and in-memory resolution paths #38605 adds the same kind = HtmlManifest assignment to the plugin and files: resolution paths, which would carry this bug onto those paths; whichever of the two lands second should drop it (noted there). bundler: give the browser side of a server build its own copy of the runtime #38376 (browser runtime copy) contains the same one-line target fix; the hunk here is identical to it, so bundle_v2.rs merges cleanly in either order, and this PR adds the test for the symptom that line fixes on its own (splitting-shared-manifest-chunk). bundler: list the chunks and assets reached through dynamic imports in the HTML import manifest #38347 changes how the manifest picks chunks and would hide that symptom, but the chunk would still be named and sided as browser output, so the target fix stands on its own. The kind fix is in none of them.

Background

  • HTML import manifest: when code built for a server target imports an .html file, the HTML (with its scripts and styles) is bundled as a separate browser build, and the import evaluates to a JSON object listing the files that build produced (Bun.serve routes consume it). Internally the import record is bound to a generated "manifest module": a lazy-export module whose body is __jsonParse("<placeholder>"); the linker splices the JSON in once output paths are known.
  • Lazy-export module: a module whose AST is a single expression (.json, .toml, .txt files parse to one). The linker finalizes its shape after it knows how the module is imported: export default <expr> for static imports, module.exports = <expr> inside a __commonJS wrapper when it is require()d or import()ed without splitting.
  • Wrapper: when a module has to be evaluated on demand (it is require()d, import()ed without splitting, or CommonJS), the bundler emits it as a function (require_<name> / init_<name>) and prints the importing expression as a call to it. Whether a module gets a wrapper is decided from the kinds of the records that import it, which is why the kind is load-bearing.
  • Per-file target: in a server build with HTML imports, server files carry the server target and the HTML's files carry Browser. computeChunks uses this to mark browser chunks of the server build, which get browser naming, side: "client", and an entry in the manifest.

… and require() of the manifest link

resolve_import_records() overwrote the importing record's kind with
ImportKind::HtmlManifest when binding a server-side HTML import to its
generated manifest module. The linker and printer decide how to emit an
import from that kind: import() and require() records get the imported
module wrapped (or, with splitting, turned into a chunk of its own) and
are printed as a call to that wrapper. With the kind replaced, the
manifest module was never wrapped, and import("./page.html") or
require("./page.html") printed a call to an undefined require_page().
Only the record that created the manifest module was rewritten, so the
result also depended on which importer was resolved first.

The kind is now left alone; the only thing that read the rewritten kind
was the known_target of the HTML file's parse task, which uses the same
predicate directly.

The manifest module's AST also gets the server target it is evaluated
under. It defaulted to browser, so with splitting enabled the chunk it
lands in (a chunk of its own for import(), or a chunk shared between
server entry points for static imports) was flagged as a browser chunk
of the server build and listed in the manifest as one of the page's
files.
@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: 55 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: 68b78789-6d7c-424e-aa2a-89c15c13a970

📥 Commits

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

📒 Files selected for processing (2)
  • src/bundler/bundle_v2.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

Status: reproduced on bun 1.4.0 with a target: "bun" build of a file containing await import("./page.html") (output calls an undefined require_page()), and with require("./page.html") from an ESM file; the five new cases in test/bundler/html-import-manifest.test.ts fail on 1.4.0 and pass with this branch. Fix is in this PR; waiting on CI.

@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 and didn't find any bugs. The fix is small and well-reasoned, but it changes load-bearing bundler linking behavior (import-record kind drives wrapping/chunking decisions) and has a user-observable side effect on metafile kind reporting, plus noted interactions with #38605 and #38347 — a maintainer familiar with the bundler should sign off.

Checked that the known_target rewrite from import_record.kind == HtmlManifest to is_html_entrypoint is equivalent (the removed assignment was the only setter, 20 lines above). Checked remaining HtmlManifest consumers: LinkerGraph.rs:947 (TLA propagation) now falls through for static-import manifest records, which is harmless — the manifest is a lazy-export with no TLA, same as a JSON import. Verified bunRun returns {stdout, stderr, exitCode} with trimmed strings, so the two-line stdout.split("\n") in the dynamic-import test is sound.

Extended reasoning...

Overview

Two changes in src/bundler/bundle_v2.rs: (1) stop overwriting import_record.kind with ImportKind::HtmlManifest in resolve_import_records() and use the local is_html_entrypoint predicate directly for known_target; (2) set the generated manifest module's AST target to the importing server target in generate_server_html_module() instead of leaving it at the Browser default. Five new tests in test/bundler/html-import-manifest.test.ts cover import(), import() + splitting, require() from ESM and CJS, and a shared manifest chunk under splitting.

Security risks

None. This is bundler output-shape logic; no untrusted input parsing, auth, or crypto is touched.

Level of scrutiny

High. The line count is tiny (~8 native lines), but import_record.kind is load-bearing across the linker (scanImportsAndExports.rs), chunk computation (bundle_v2.rs:1852), and printer (js_printer/lib.rs) as the PR description itself lays out. The change also alters user-observable metafile output (html_manifestimport-statement/dynamic-import/require-call). I audited every remaining reader of ImportKind::HtmlManifest — only LinkerGraph.rs:947 (TLA-propagation continue) is behaviorally affected, and the fallthrough for a static-import record just visits a lazy-export module with no async dependency, matching how JSON imports are already handled. The known_target rewrite is trivially equivalent since the deleted assignment was its only setter.

Other factors

The tests are well-constructed: they run the bundled output with bunRun, assert stderr and manifest shape before exitCode, use tempDir with await using, and pin the metafile kind. The PR description explicitly flags interactions with two open PRs (#38605 would reintroduce the same assignment on other paths; #38347 overlaps the splitting symptom) — a maintainer should coordinate the landing order. Given the subsystem, the observable metafile change, and the cross-PR coordination, this warrants a human sign-off despite finding no defects.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. bundler: give the browser side of a server build its own copy of the runtime #38376 - Contains the same ast_for_html_entrypoint.target = target; fix in generate_server_html_module byte-for-byte at the identical location, for the same stated reason.
  2. bundler: create the HTML import manifest on the plugin and in-memory resolution paths #38605 - Rewrites the same generate_server_html_module function and factors out the same is_html_entrypoint predicate, but goes the opposite direction on the bug by adding two more import_record.kind = ImportKind::HtmlManifest assignments that this PR removes.
  3. bundler: list the chunks and assets reached through dynamic imports in the HTML import manifest #38347 - Replaces the manifest chunk-selection logic to address the same "wrong chunk listed in the manifest under splitting: true" symptom, via a different mechanism.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of any of the three; checked each:

The main change in this PR (not rewriting the record kind, which is what the require_page is not defined failures come from) is in none of them.

@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. The Rust change is small and the reasoning is thorough, but since it changes how HTML-manifest import records flow through the linker (with a user-visible metafile kind change) and overlaps with three open PRs that take different directions on the same code, a human look would still be worthwhile.

Checked: is_html_entrypoint is computed just above and is equivalent to the removed kind == HtmlManifest check; remaining ImportKind::HtmlManifest readers (LinkerGraph.rs:947 async-propagation walk, ResolveMessage.rs/ast/lib.rs label tables) are unaffected — the Stmt arm now visits the manifest module like any JSON import, which is harmless. No test in the tree asserts on "html_manifest" as a metafile kind. The new tests use bunRun/tempDir correctly and expectPageManifest asserts the exact object shape.

Extended reasoning...

Overview

The PR touches two files: a ~6-line net change in src/bundler/bundle_v2.rs (stop rewriting import_record.kind to ImportKind::HtmlManifest in resolve_import_records(), use the is_html_entrypoint local for known_target instead, and set the generated manifest module's AST target to the importing server target in generate_server_html_module()), plus five new tests in test/bundler/html-import-manifest.test.ts covering import(), require() (ESM/CJS), and splitting variants.

Security risks

None. This is bundler output-shape logic; no auth, crypto, untrusted-input parsing, or filesystem-escape surface is touched.

Level of scrutiny

Medium-high. The diff itself is tiny and mechanically sound — I traced is_html_entrypoint (defined at bundle_v2.rs:6548) and confirmed it's exactly the predicate the removed assignment encoded, and I audited every remaining reader of ImportKind::HtmlManifest (the async-dependency walk at LinkerGraph.rs:947 now falls through to Stmt for static imports, which just visits a synchronous lazy-export module — same as a JSON import — and still skips for Dynamic/Require; the other hits are label tables). But the change sits inside the bundler's link/print decision path, has a documented user-visible side effect (metafile kind for HTML imports changes from html_manifest to the real syntax kind), and the PR description itself notes it conflicts directionally with #38605 and overlaps with #38376/#38347/#38549. That coordination is a maintainer call.

Other factors

The PR description is unusually detailed with a proven mechanism (not just "crash goes away"), the five new tests each pin a distinct failure mode (wrapper-not-defined, chunk-not-emitted, wrong result shape, server chunk leaking into manifest), and existing tests in the same file are unchanged. bunRun trims stdout so stdout.split("\n") in the dynamic-import test yields the expected two lines. The duplicate-PR bot flagged three overlapping PRs, one of which (#38605) adds the very assignment this PR removes — a human should decide which direction wins before either lands.

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