Skip to content

bundler: call the wrapper of a CommonJS or lazy-ESM <script src> at its tag in an HTML entry point - #42026

Open
robobun wants to merge 8 commits into
mainfrom
robobun/c7f21674/html-script-cjs-wrapper-call
Open

robobun wants to merge 8 commits into
mainfrom
robobun/c7f21674/html-script-cjs-wrapper-call

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • An HTML entry point (bun build ./index.html) never runs a <script src> that the linker wraps. A CommonJS file prints as var require_a = __commonJS(...) and nothing calls require_a(). An __esm-wrapped script is the same. Exit 0, no diagnostic. Triggers: "type": "commonjs", .cjs, require(), a UMD header.
  • Cause: the HTML loader puts all import records in one statement-less part (ParseTask.rs, Loader::Html). The linker prints a wrapper call only from an import statement.

Fix

  • call_wrapped_html_scripts (linker step 6) adds require_a(), init_a() or await init_a() to that part for each wrapped <script src>. HTMLScanner marks those records (HTML_SCRIPT_SRC), so a <link as="worker"> file gets no call.
  • A part prints after the files it imports, so the part ends at each call. The chunk walk reports each HTML record under its part.
  • Verified: test/bundler/bundler_html.test.ts (11 cases: iife, cjs, minify, source map, splitting), test/bundler/standalone.test.ts (1). Each runs the built chunk and fails on main.
  • Self-reviewed: 6 concerns raised, 3 addressed. Rejected: wraps decided after tree shaking (main has none), the pinned await init_x() order (the current contract), a call for a link[as=worker] file (not a script).

Background

Downsides

  • Not fixed: a classic script still runs as module code, so a UMD global set through this lands on exports (Failure to include js script when serving static html #18770 family).
  • Binary size: +3,584 bytes of text (release size, at 4fb8cf5).
  • Pages with no wrapped script: one pass over their import records, same bytes and chunk hashes (90 outputs compared).
Notes
  • Found by a fuzz corpus that compares built pages against Chromium: 0 of 114 pages with such a script ran it after bun build, 81 of 114 did unbundled. No linked user report.
  • Output for a page with tags b.js, a.js (UMD), m.js (guarded module.exports), b2.js:
    // b.js
    console.log("b");
    
    // index.html
    require_a();
    require_m();
    
    // b2.js
    console.log("b2");
  • Rework history. The first version gave the HTML AST one statement-less part per import record and printed the calls from a hook in generate_code_for_file_in_chunk_js. Main then rewrote the chunk walk (bundler: print a file where it is imported, not where its bindings are used #44508): for_each_edge reported every record of an HTML file under part 0, so the whole file was placed after its imports and the calls printed after every later script. That version also changed the part ranges of every HTML page: each HTML entry chunk got a new content hash with the same bytes (the snapshot churn in html-import-manifest.test.ts and bun-serve-html.test.ts), one 200-byte Part and one allocation per import record, and empty print tasks that needed a newline fix in post_process_js_chunk. Both designs were built and compared before this rework. ParseTask.rs, postProcessJSChunk.rs, generateCodeForFileInChunkJS.rs and both snapshot files are back to main.
  • Why the linker and not the parser: the wrap kind of a script is a link-time fact (scan_imports_and_exports steps 1 and 2). Step 6 is the first point where it is final, and it is where the per-part wrapper symbol use is registered, so the statement and its symbol use land on the same part.
  • Unchanged output, measured. A corpus of 7 pages with no wrapped script (plain and module scripts, top-level await, import(), stylesheets, images, an external script, a bare page, a CSS-only page) built six ways (default, --minify, --splitting, --sourcemap=linked, --format=iife, --compile --target=browser): 90 output files, same names and sha256 on the merge base and on this branch, debug and release builds.
  • Layer choice, for the owners of the chunk walk. bundler(splitting): wrap the files of an import cycle that two entry points run in different orders #44823 fixed this same bug in the HTML loader: one part per record and an import statement per bundled script, with a new name for the JS chunk of every page. It was closed in favor of bundler(splitting): give a shared chunk to an entry point whose order breaks no load-time read #44876, which "leaves every other build unchanged". This PR prints the same calls from the linker and keeps the chunk of every other page byte-identical, name included. If the parse-time shape is preferred, the tests here carry over unchanged.
  • A page whose only wrapped script is its last tag gets no split: the call prints after every file of the page.
  • A page that a script loads with import() is itself a lazily-initialized module. Its scripts never ran either. The calls now print inside the wrapper of the page, in document order (html/script-src-commonjs-in-imported-page).
  • A lazily-initialized module with no side effects has no init_x. The page makes no call for it (pure.js in html/script-src-lazy-esm).
  • --format=internal_bake_dev (the dev server's module format) is skipped: it loads a script through its module record and defines no require_x. Release output for a page with a CommonJS script is byte-identical on the merge base and on this branch. The dev server itself (bun ./index.html) never ran this linker step and was not affected by the bug.
  • scan_imports_and_exports step 6 no longer sets WRAP_WITH_TO_ESM for an HTML importer. It used to surface as an unused import { __toESM } in the page's entry chunk under --splitting.
  • Why not real import statements in the HTML AST: the generic path prints var import_a = __toESM(require_a()), which needs a namespace symbol per tag and the __toESM runtime helper, and tree shaking does not walk an HTML file's part dependencies, so the helper would not be included.
  • Why not print the call next to the wrapper definition: wrapped files print ahead of all flat code in the chunk, so the call would run before earlier plain scripts.
  • Still open, separate from this PR: an unwrapped <link as="worker"> file is merged into the page chunk and runs there (on main today). --format=internal_bake_dev with an HTML entry trips a debug assertion in print_dev_server_module on main.
  • Per-build cost outside HTML pages: one loader compare per reachable file in step 6 and one per file visit in for_each_edge. Instruction counts were not taken: perf, valgrind, bloaty and strace are not installed where this was measured. Release file size: 105,604,840 to 105,608,936 bytes (+4,096).
  • Binary size was measured on release builds of the merge base 9bcbf1e (text 105,434,471) and of 4fb8cf5 (text 105,438,055). The commits after 4fb8cf5 cut comments, remove a branch from call_wrapped_html_scripts, and add tests.
  • Also checked by hand: CSS order across a split, a duplicate tag, a CommonJS script that is also its own entry point, a lazily-initialized script with top-level await between two plain scripts.
  • Suites run locally: bundler_html, standalone, html-import-manifest, bun-serve-html, bun-serve-html-manifest, bundler_html_server, bundler_splitting, bundler_cjs, bundler_edgecase, esbuild/default, esbuild/splitting, esbuild/importstar.
  • Stacked work: bundler: start an HTML page's top-level-await scripts without holding back later scripts #42105 (top-level await scripts of a page) has this branch as its base, and bundler: run each HTML <script> as its own error boundary #41982 (per-script error boundary) was written against the first version. Both need a rebase onto this shape.
  • Self-review detail. Addressed: the layer choice is stated above with bundler(splitting): wrap the files of an import cycle that two entry points run in different orders #44823 cited; the body says fuzz-found with no user report and lists what stays broken; bundler: start an HTML page's top-level-await scripts without holding back later scripts #42105 and bundler: run each HTML <script> as its own error boundary #41982 are told about the new base. Rejected: "a call fixed at step 6 cannot see a file wrapped after tree shaking" (on main every write of wrap is in steps 1 and 2 of scan_imports_and_exports, before step 6); the lazy-ESM test pins await init_x() before the next script (that is what a merged module does today, and bundler: start an HTML page's top-level-await scripts without holding back later scripts #42105 is the place to change it); a wrapped link[as=worker] file keeps an uncalled wrapper (intended: the page must not run a worker file).

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bundler/standalone.test.ts

… point

A <script src> that the bundler classifies as CommonJS (top-level this in
a UMD header, module.exports behind a typeof guard) or wraps as a lazy ES
module was emitted as var require_x = __commonJS(...) / var init_x =
__esm(...) with no call, so its body never ran. The HTML AST kept every
import record in one statement-less part, so the linker had no import
statement to turn into the wrapper call.

Give each import record of an HTML file its own part and print the
wrapper call (require_x(), init_x(), await init_x()) for that part, at
the tag's position in the merged module. Stop registering a __toESM use
for HTML importers, and keep the blank line between files when an empty
part range sits between them.
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:26 AM PT - Oct 14th, 2026

❌ @robobun, your commit 01ec601 has 2 failures in Build #125363 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42026

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

bun-42026 --bun

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on 1.4.3 (f42e98025) and on main (177da66a67c):

d=$(mktemp -d); cd $d
printf '(function (root) { root.LIB = { v: 1 }; })(this);\nconsole.log("a");\n' > a.js
printf 'console.log("m"); if (typeof module === "object") module.exports = {};\n' > m.js
echo 'console.log("b");' > b.js
echo '<!doctype html><html><body><script src="./a.js"></script><script src="./m.js"></script><script src="./b.js"></script></body></html>' > index.html
bun build ./index.html --outdir=out && bun out/index-*.js   # prints only "b"

The chunk defines require_a / require_m and never calls them. The same happens for a plain console.log script in a "type": "commonjs" package, a .cjs script, and a script that calls require(). With this branch the chunk prints require_a(); require_m(); under // index.html, before b.js, and the run prints a, m, b.

@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: c405f5f8-b7d4-4f43-a12c-18dd09e2d908
📥 Commits

Reviewing files that changed from the base of the PR and between 1517649 and 1f07c29.

📒 Files selected for processing (4)
  • src/bundler/LinkerContext.rs
  • src/bundler/ParseTask.rs
  • src/bundler/linker_context/scanImportsAndExports.rs
  • test/bundler/bundler_html.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

HTML bundling now creates ordered synthetic parts for script imports. Live parts emit wrapper calls for wrapped modules, with asynchronous ESM wrappers awaited when required. Linker behavior, chunk formatting, and HTML execution tests were updated.

Changes

HTML script bundling

Layer / File(s) Summary
Ordered HTML import parts
src/bundler/ParseTask.rs, src/bundler/LinkerContext.rs
HTML imports use separate synthetic parts in document order. Every synthetic script part after the first is marked live.
Wrapper call emission
src/bundler/LinkerContext.rs, src/bundler/linker_context/generateCodeForFileInChunkJS.rs, src/bundler/linker_context/scanImportsAndExports.rs
HTML parts emit calls for eligible wrapped modules. CommonJS wrappers use direct calls, and asynchronous ESM wrappers are awaited when required. HTML imports skip same-chunk __toESM wrapping.
Chunk output and HTML validation
src/bundler/linker_context/postProcessJSChunk.rs, test/bundler/bundler_html.test.ts, test/bundler/standalone.test.ts, test/bundler/html-import-manifest.test.ts, test/js/bun/http/bun-serve-html.test.ts
Empty compile results preserve the pending newline state. Tests cover script order, wrapper behavior, asynchronous initialization, code splitting, standalone output, and updated asset metadata.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 1f07c

No actionable issue remains identified for this change; it is mergeable after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly identifies the main change: invoking wrapped CommonJS and lazy-ESM scripts from HTML entry points.
Description check Passed The description is detailed and covers the problem, implementation, verification, limitations, test coverage, and known CI status. It does not use the template headings exactly, but it provides the re…
Full details: Description check

Explanation

The description is detailed and covers the problem, implementation, verification, limitations, test coverage, and known CI status. It does not use the template headings exactly, but it provides the required information.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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 the linker structures and prints parts for HTML entry points — core bundler code-generation — a maintainer look would still be worthwhile.

What was reviewed:

  • append_html_script_wrapper_calls against should_remove_import_export_stmt — the files_live skip for ESM-only and the wrapper_ref.is_empty() guard match; the CJS arm intentionally calls the wrapper directly (no __toESM, no E::RequireString) since nothing binds the namespace, and async ESM uses an inline await via append_non_dependency (sequential, document-order) rather than append_async_dependency's Promise.all batching.
  • ParseTask now emits one part per import record and tree_shaking_and_code_splitting seeds bits 1..parts.len() — consistent with each other; the is_html gate in generate_code_for_file_in_chunk_js and scan_imports_and_exports keeps non-HTML paths untouched.
  • post_process_js_chunk now only sets newline_before_comment true (never clears it) — matches the esbuild behavior the PR cites; snapshot/etag churn traced to the part-range change feeding the chunk hash, not to changed emitted bytes for existing tests.
Extended reasoning...

Overview

This PR fixes a bug where <script src> tags in HTML entry points that resolve to wrapped modules (CommonJS or lazily-initialized ESM) were never executed in the bundled output. The fix restructures the HTML loader's synthetic AST from one part holding all import records to one part per record (src/bundler/ParseTask.rs), seeds all those parts as live in tree-shaking (src/bundler/LinkerContext.rs:934-946), and adds append_html_script_wrapper_calls (src/bundler/LinkerContext.rs:2318-2375) which is invoked from generate_code_for_file_in_chunk_js for HTML sources to print require_foo() / init_foo() / await init_foo() at each tag's document position. Two supporting one-liners: scan_imports_and_exports skips __toESM bookkeeping for HTML importers, and post_process_js_chunk no longer lets an empty compile result clear newline_before_comment. Tests add four itBundled cases covering all wrap kinds and splitting, one standalone compile: true test, and update etag/hash snapshots.

Security risks

None. This is bundler output-generation logic operating on already-parsed ASTs; no new input parsing, no auth/crypto/network surface. The changed code paths do not touch untrusted external data beyond what the bundler already handles.

Level of scrutiny

Moderate-to-high. The bundler linker is ported from esbuild and REVIEW.md treats the reference implementation as the spec. The new function deliberately diverges from should_remove_import_export_stmt in two ways: (1) the CJS arm calls the wrapper ref directly rather than emitting E::RequireString bound to a namespace symbol, and (2) async ESM uses append_non_dependency with an inline E::Await (sequential) rather than append_async_dependency (which batches into Promise.all). Both divergences are justified — nothing reads the namespace, and HTML scripts must run in document order — but they are the kind of "why this and not the alternative" call a maintainer should confirm. The per-tag part structure also interacts with find_imported_parts_in_js_order and chunk hashing; the PR author checked splitting, minify, format=cjs/iife, sourcemaps, and external scripts by hand, but those aren't all under automated test.

Other factors

Test coverage is strong for the fix itself: all CJS classification triggers (UMD this, guarded module.exports, .cjs, require(), "type": "commonjs"), sync and async lazy ESM, and code-splitting with a shared CJS script across two pages. Tests execute the emitted chunk and assert stdout in document order, so they fail on the unfixed build. The PR references two related drafts (#41999, #41982) touching the same Loader::Html block, which suggests ongoing design work here that a maintainer should be aware of. No CODEOWNERS entries cover the changed files. Exit reason was dry_streak, so the bug hunt ran to exhaustion without findings.

The chunk prints wrapped files ahead of flat code, so this is the case
where the wrapper call must come from the tag's own part to keep
document order. Adapted from the html/commonjs-script-runs-in-place
case in #41999.
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/ParseTask.rs Outdated
Comment thread src/bundler/linker_context/postProcessJSChunk.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Consolidation note: #41999 made the same per-tag part change for Loader::Html and is closed in favor of this PR. 02c7708 carries over its case of a CommonJS script between two plain scripts (html/script-src-commonjs-between-plain-scripts). That case fails on 1.4.3 and passes here. It is the one layout where the wrapper call must come from the tag's own part and not from next to the wrapper, since wrapped files print ahead of flat code.

Checked again with a debug build of this branch: test/bundler/bundler_html.test.ts, test/bundler/standalone.test.ts, test/bundler/html-import-manifest.test.ts and test/js/bun/http/bun-serve-html.test.ts all pass, and the a.js / m.js / b.js repro above prints a, m, b.

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

Code review found no issues

No high-confidence issues detected in this change.

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/bundler/ParseTask.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
The chunk walk on main no longer prints a file part by part for an HTML
file, so the wrapper calls printed after every later script.

The HTML AST is back to one part for all import records. Once wrap kinds
are final, call_wrapped_html_scripts gives that part a require_x() /
init_x() / await init_x() statement for each <script src> whose file is
wrapped, and ends the part at each call that another record follows. The
walk reports every record of an HTML file under its part, so a call
prints between the scripts around its tag.

HTMLScanner marks script[src] records with HTML_SCRIPT_SRC, so a
<link as=worker> file gets no call.

A page with no wrapped script keeps its part layout, so its chunk keeps
the same bytes and the same content hash: the hash snapshots are back to
the values on main.
@robobun robobun changed the title bundler: run CommonJS and lazily-initialized <script src> files of an HTML entry point bundler: call the wrapper of a CommonJS or lazy-ESM <script src> at its tag in an HTML entry point Oct 14, 2026

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

This revision moves the split into the linker, and the two points from my earlier review are addressed by it: for_each_edge now reports each HTML record under its real part index, and only the script[src] handler sets HTML_SCRIPT_SRC, so a link[as=worker] target is never called (both covered by the new tests). Beyond the inline note, I also checked that parts_live is seeded after scan_imports_and_exports (so the split parts are live), that step 6 reads parts_len after the split (so each new part gets the wrapper_ref symbol use needed for the cross-chunk import under --splitting), and that the detached arena ref follows the same pattern as bundle_v2.rs since add_part_to_file only pushes to a per-file part list.

Extended reasoning...

The change touches the bundler linker (part splitting in call_wrapped_html_scripts, the chunk visitor's HTML edge walk, step 6 of scan_imports_and_exports) plus a new ImportRecordFlags bit threaded through HTMLScanner; no security-sensitive surface. The inline nit on test coverage stands, and the linker ordering logic is intricate enough that a human familiar with findAllImportedPartsInJSOrder should still look, so this is a defer rather than an approve.

Comment thread test/bundler/bundler_html.test.ts
…y, source maps and an import()ed page

call_wrapped_html_scripts no longer treats a wrapped HTML file as a
special case: each call ends its part, and a wrapped file prints its
parts in order inside its wrapper anyway.

New cases run the built chunk for --format=iife, --format=cjs, the
minifier and an external source map, for a lazily-initialized module
that has no wrapper to call, and for a page that a script loads with
import().

@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 the latest push and found no bugs; the points raised on earlier versions (worker-link records getting a call, the dead guards, and the missing iife/cjs/minify/sourcemap coverage) are addressed in the current code and tests. Because this adds a part-splitting step inside the linker's step 6 plus a new chunk-walk branch, a human look from someone who knows the linker would still be worthwhile.

What was reviewed:

  • Traced call_wrapped_html_scripts end to end: it runs before the step-6 part loop recomputes parts_len, so the new parts get the wrapper_ref symbol use and wrapper-part dependency; every new part carries at least one import record, so the parts_live seeding marks all of them.
  • Checked the for_each_edge HTML branch against the walk in place: each part's records split a run at that part index, so the call part lands after the files it imports and before the next tag's script; the is_broken_by check cannot trip because wrapper parts stay Hoisted.
  • is_async_or_has_async_dependency is final before scan_imports_and_exports (validate_tla/propagate_async_dependencies run first), and the wrapper part defaults to can_be_removed_if_unused: false, so a lazy-ESM init_x is live whenever its page is.
  • All three HTMLProcessorHandler implementors take the new flags parameter; HTML_SCRIPT_SRC (1<<19) does not collide with existing bits.
Extended reasoning...

The change touches the bundler linker (src/bundler/LinkerContext.rs, scanImportsAndExports.rs, findAllImportedPartsInJSOrder.rs), the HTML scanner, and the import-record flag set, with about 140 lines of Rust and roughly 285 lines of new tests; it touches no security-sensitive surface. It adds a new linker step that splits an HTML file's synthetic part and appends parts via add_part_to_file, uses an unsafe lifetime detach on the bundle arena (a pattern already used elsewhere in the bundler), and changes how the chunk walk orders HTML files. The tests run the emitted chunk and cover CJS and lazy-ESM scripts, iife/cjs/minify/sourcemap, splitting, --compile, and an imported page, and the earlier inline feedback is addressed. The decision to defer rather than approve rests on the complexity of the part-splitting and chunk-order logic, which is not simple or mechanical; no CODEOWNERS entry covers src/bundler.

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.

2 participants