Skip to content
Open
73 changes: 67 additions & 6 deletions src/bundler/LinkerContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -934,13 +934,15 @@ impl<'a> LinkerContext<'a> {
Vec::with_capacity(parts_col.len());
for (i, parts) in parts_col.iter().enumerate() {
let mut bits = bun_collections::AutoBitSet::init_empty(parts.len())?;
// The HTML loader's `ParseTask` builds its synthetic part 1 already
// live (so the JS-chunk visitor follows every embedded import record).
// `mark_file_live_for_tree_shaking` short-circuits for HTML and never
// walks its parts, so seed the bit here to preserve the old
// `Part::is_live = true` initializer.
if loaders.get(i).is_some_and(|l| *l == Loader::Html) && parts.len() > 1 {
bits.set(1);
// walks its parts. The HTML loader's `ParseTask` builds one
// statement-less part per import record (so the JS-chunk visitor
// follows every embedded `<script src>` in document order), and all
// of them are live.
Comment thread
robobun marked this conversation as resolved.
Resolved
if loaders.get(i).is_some_and(|l| *l == Loader::Html) {
for part_index in 1..parts.len() {
bits.set(part_index);
}
}
parts_live.push(bits);
}
Expand Down Expand Up @@ -2313,6 +2315,65 @@ impl<'a> LinkerContext<'a> {
Ok(true)
}

/// The HTML loader gives each `<script src>` its own statement-less part
/// that holds only the import record. A script that resolved to a wrapped
/// module runs nothing until its wrapper is called, so print that call where
/// the tag was, like `should_remove_import_export_stmt` does for a bare
/// `import "./script"`: `require_foo()`, `init_foo()`, or `await init_foo()`.
/// Nothing observes the namespace, so the result is not passed to `__toESM`.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) fn append_html_script_wrapper_calls(
&self,
stmts: &mut StmtList,
part: &Part,
ast: &JSAst<'_>,
) -> Result<(), AllocError> {
for &import_record_index in part.import_record_indices.slice() {
let record = &ast.import_records[import_record_index as usize];
if record.kind != ImportKind::Stmt
|| !record.source_index.is_valid()
|| record.flags.contains(bun_ast::ImportRecordFlags::IS_UNUSED)
{
continue;
}
let other_source_index = record.source_index.get() as usize;
let other_flags = self.graph.meta.items_flags()[other_source_index];
match other_flags.wrap {
WrapKind::None => continue,
WrapKind::Cjs => {}
WrapKind::Esm => {
if !self.graph.files_live.is_set(other_source_index) {
continue;
Comment thread
robobun marked this conversation as resolved.
Resolved
}
}
}
let wrapper_ref = self.graph.ast.items_wrapper_ref()[other_source_index];
if wrapper_ref.is_empty() {
continue;
}

let mut call = Expr::init(
E::Call {
target: Expr::init_identifier(wrapper_ref, Loc::EMPTY),
..Default::default()
},
Loc::EMPTY,
);
if other_flags.wrap == WrapKind::Esm && other_flags.is_async_or_has_async_dependency {
call = Expr::init(E::Await { value: call }, Loc::EMPTY);
}
stmts
.inside_wrapper_prefix
.append_non_dependency(Stmt::alloc(
S::SExpr {
value: call,
..Default::default()
Comment thread
robobun marked this conversation as resolved.
Resolved
},
Loc::EMPTY,
))?;
}
Ok(())
}

pub(crate) fn print_code_for_file_in_chunk_js(
&mut self,
r: renamer::Renamer,
Expand Down
35 changes: 18 additions & 17 deletions src/bundler/ParseTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1231,23 +1231,24 @@ pub mod parse_worker {
// gave up on figuring out how to fix it so that
// this feature could ship.
ast.has_lazy_export = false;
// Liveness for this synthetic part is seeded in
// `tree_shaking_and_code_splitting` (the per-part bitset
// does not exist at parse time).
ast.parts.as_mut_slice()[1] = Part {
stmts: ast::StoreSlice::EMPTY,
import_record_indices: {
// Generate a single part that depends on all the import records.
// This is to ensure that we generate a JavaScript bundle containing all the user's code.
let mut import_record_indices = ast::PartImportRecordIndices::init_capacity(
import_records_len as usize,
);
import_record_indices
.extend(0..u32::try_from(import_records_len).expect("int cast"));
import_record_indices
},
..Default::default()
};
// One statement-less part per import record, in document order.
// The linker prints a part after the files it imports, so a
// `<script src>` that resolves to a wrapped module gets its
// `require_foo()` / `init_foo()` call at the tag's position
// (see `append_html_script_wrapper_calls`). Liveness for these
// parts is seeded in `tree_shaking_and_code_splitting` (the
// per-part bitset does not exist at parse time).
Comment thread
robobun marked this conversation as resolved.
Resolved
ast.parts.truncate(1);
ast.parts.reserve(import_records_len);
for import_record_index in 0..u32::try_from(import_records_len).expect("int cast") {
let mut import_record_indices = ast::PartImportRecordIndices::init_capacity(1);
import_record_indices.push(import_record_index);
ast.parts.push(Part {
stmts: ast::StoreSlice::EMPTY,
import_record_indices,
..Default::default()
});
}
Comment thread
robobun marked this conversation as resolved.
Resolved

// Try to avoid generating unnecessary ESM <> CJS wrapper code.
if output_format == js_parser::options::Format::Esm
Expand Down
11 changes: 10 additions & 1 deletion src/bundler/linker_context/generateCodeForFileInChunkJS.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use bun_js_printer::{self as js_printer, PrintResult, PrintResultSuccess};
use crate::analyze_transpiled_module::ModuleInfo;
use crate::generic_path_with_pretty_initialized;
use crate::linker_context_mod::{StmtList, StmtListWhich};
use crate::options::Format as OutputFormat;
use crate::options::{Format as OutputFormat, Loader};
use crate::{Chunk, Index, LinkerContext, Part, PartRange, WrapKind};

use bun_ast::StoreRef;
Expand Down Expand Up @@ -237,6 +237,8 @@ pub fn generate_code_for_file_in_chunk_js<'r, 'src>(

let namespace_export_part_index = bun_ast::NAMESPACE_EXPORT_PART_INDEX;

let is_html = c.parse_graph().input_files.items_loader()[source_index] == Loader::Html;

stmts.reset();

let part_index_for_lazy_default_export: u32 = 'brk: {
Expand Down Expand Up @@ -354,6 +356,13 @@ pub fn generate_code_for_file_in_chunk_js<'r, 'src>(
continue;
}

if is_html {
if let Err(err) = c.append_html_script_wrapper_calls(stmts, part, &ast) {
return PrintResult::Err(err.into());
}
continue;
}

let mut single_stmts_list: [Stmt; 1] = [Stmt::empty()];
let mut part_stmts: &[Stmt] = part.stmts.slice();

Expand Down
7 changes: 6 additions & 1 deletion src/bundler/linker_context/postProcessJSChunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,12 @@ pub(crate) fn post_process_js_chunk(
}

// TODO: metafile
newline_before_comment = !compile_result.code().is_empty();

// Put a newline before the next file path comment. An empty result (a
// part range that printed nothing) must not take it away again.
Comment thread
robobun marked this conversation as resolved.
Resolved
if !compile_result.code().is_empty() {
newline_before_comment = true;
}
}
// An entry chunk whose code all lives in shared chunks has no compile results of its own.
if !preload_registration.is_empty() {
Expand Down
4 changes: 4 additions & 0 deletions src/bundler/linker_context/scanImportsAndExports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,9 @@ pub(crate) fn scan_imports_and_exports(
let wrap = flag.wrap;
let export_kind = col_ref!(exports_kind)[id];
let source: &Source = &col_ref!(input_files)[id];
// An HTML file's `<script src>` records print as bare wrapper calls
// (`append_html_script_wrapper_calls`); nothing reads the namespace.
Comment thread
robobun marked this conversation as resolved.
Resolved
let is_html = col_ref!(loaders)[id] == Loader::Html;

let exports_ref = col_ref!(exports_refs)[id];
let module_ref = col_ref!(module_refs)[id];
Expand Down Expand Up @@ -1212,6 +1215,7 @@ pub(crate) fn scan_imports_and_exports(
// A same-chunk `import()` of a lifted CommonJS module needs it
// too, so that `default` is `module.exports` (the namespace).
if kind != ImportKind::Require
&& !is_html
&& (other_export_kind == ExportsKind::Cjs
|| (kind == ImportKind::Dynamic
&& other_flags.wrap == WrapKind::Esm
Expand Down
164 changes: 164 additions & 0 deletions test/bundler/bundler_html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,170 @@ export const padZero = (num) => String(num).padStart(2, '0');`,
},
});

// A `<script src>` that bun classifies as CommonJS (a `.cjs` file, a file
// that calls `require()`, top-level `this` in a UMD header, `module.exports`
// behind a `typeof module` guard) is wrapped in `__commonJS`. The page must
// still run it, in document order.
itBundled("html/script-src-commonjs", {
outdir: "out/",
files: {
"/index.html": `
<!DOCTYPE html>
<html>
<body>
<script src="./umd.js"></script>
<script src="./guarded.js"></script>
<script src="./ext.cjs"></script>
<script src="./requires.js"></script>
<script src="./plain.js"></script>
</body>
</html>`,
"/umd.js": `
(function (root, factory) {
root.LIB = factory();
})(this, function () {
return { v: 1 };
});
console.log("umd");`,
"/guarded.js": `
console.log("guarded");
if (typeof module === "object") module.exports = { guarded: true };`,
"/ext.cjs": `console.log("cjs extension");`,
"/requires.js": `
const dep = require("./dep.js");
console.log("requires " + dep);`,
"/dep.js": `module.exports = 7;`,
"/plain.js": `console.log("plain");`,
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
const js = api.readFile("out/index.html").match(/src="\.\/([^"]+\.js)"/)![1];
api.writeFile("out/run.mjs", `import "./${js}";`);
},
run: { file: "out/run.mjs", stdout: "umd\nguarded\ncjs extension\nrequires 7\nplain" },
});

// Every `.js` file of a `"type": "commonjs"` package (the `npm init` default)
// is classified as CommonJS, so even a plain `console.log` script is wrapped.
itBundled("html/script-src-type-commonjs-package", {
outdir: "out/",
files: {
"/package.json": `{ "name": "app", "version": "1.0.0", "type": "commonjs" }`,
"/index.html": `<!DOCTYPE html><html><body><script src="./first.js"></script><script src="./second.js"></script></body></html>`,
"/first.js": `console.log("first");`,
"/second.js": `console.log("second");`,
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
const js = api.readFile("out/index.html").match(/src="\.\/([^"]+\.js)"/)![1];
api.writeFile("out/run.mjs", `import "./${js}";`);
},
run: { file: "out/run.mjs", stdout: "first\nsecond" },
});

// The chunk prints wrapped files ahead of the flat ones, so the call for a
// CommonJS script that sits between two plain scripts must come from the
// tag's own part to keep document order, not from next to the wrapper.
itBundled("html/script-src-commonjs-between-plain-scripts", {
outdir: "out/",
files: {
"/index.html": `
<!DOCTYPE html>
<html>
<body>
<script src="./first.js"></script>
<script src="./umd-badge.js"></script>
<script src="./last.js"></script>
</body>
</html>`,
"/first.js": `console.log("first");`,
"/umd-badge.js": `
(function (root, factory) {
if (typeof module === "object" && module.exports) module.exports = factory();
else root.Badge = factory();
})(globalThis, function () {
console.log("umd badge");
return {};
});`,
"/last.js": `console.log("last");`,
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
const js = api.readFile("out/index.html").match(/src="\.\/([^"]+\.js)"/)![1];
api.expectFile("out/" + js).not.toContain("__toESM");
api.writeFile("out/run.mjs", `import "./${js}";`);
},
run: { file: "out/run.mjs", stdout: "first\numd badge\nlast" },
});

// Same, when the script resolved to a lazily-initialized ES module (here
// because another script also `import()`s it): the page calls `init_foo()`,
// with `await` when the module uses top-level await.
itBundled("html/script-src-lazy-esm", {
outdir: "out/",
files: {
"/index.html": `
<!DOCTYPE html>
<html>
<body>
<script src="./sync.js"></script>
<script src="./tla.js"></script>
<script src="./main.js"></script>
</body>
</html>`,
"/sync.js": `
export const sync = "sync";
console.log(sync);`,
"/tla.js": `
export const tla = await Promise.resolve("tla");
console.log(tla);`,
"/main.js": `
console.log("main");
const { sync } = await import("./sync.js");
const { tla } = await import("./tla.js");
console.log("lazy " + sync + " " + tla);`,
},
entryPoints: ["/index.html"],
onAfterBundle(api) {
const js = api.readFile("out/index.html").match(/src="\.\/([^"]+\.js)"/)![1];
api.expectFile("out/" + js).toMatch(/init_sync\(\);\s*await init_tla\(\);/);
api.writeFile("out/run.mjs", `import "./${js}";`);
},
run: { file: "out/run.mjs", stdout: "sync\ntla\nmain\nlazy sync tla" },
});

// With code splitting the CommonJS script shared by two pages moves to its
// own chunk. Each page imports `require_lib` from it and still calls it. The
// page itself never reads the namespace, so it does not import `__toESM`
// (two.js does, for its own `import * as`).
itBundled("html/script-src-commonjs-splitting", {
outdir: "out/",
splitting: true,
files: {
"/one.html": `<!DOCTYPE html><html><body><script src="./lib.js"></script><script src="./one.js"></script></body></html>`,
"/two.html": `<!DOCTYPE html><html><body><script src="./lib.js"></script><script src="./two.js"></script></body></html>`,
"/lib.js": `
console.log("lib");
module.exports = { lib: true };`,
"/one.js": `console.log("one");`,
"/two.js": `
import * as ns from "./lib.js";
console.log("two", ns.default.lib);`,
},
entryPoints: ["/one.html", "/two.html"],
onAfterBundle(api) {
for (const page of ["one", "two"]) {
const js = api.readFile(`out/${page}.html`).match(/src="\.\/([^"]+\.js)"/)![1];
if (page === "one") api.expectFile("out/" + js).not.toContain("__toESM");
api.writeFile(`out/run-${page}.mjs`, `import "./${js}";`);
}
},
run: [
{ file: "out/run-one.mjs", stdout: "lib\none" },
{ file: "out/run-two.mjs", stdout: "lib\ntwo true" },
],
});

Comment thread
claude[bot] marked this conversation as resolved.
Resolved
// Test CSS imports
itBundled("html/css-imports", {
outdir: "out/",
Expand Down
Loading
Loading