Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions bench/html-entry-points/gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Generates N HTML entry points of one size. Each page loads its own module
// script. --script-kb=<n> makes each script about n KB of code (two lines
// without it), --css gives each page its own stylesheet.
// Usage: bun gen.ts <outdir> <N> [--script-kb=<n>] [--css]
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";

const [outdir, nStr = "1000", ...flags] = process.argv.slice(2);
const N = /^\d+$/.test(nStr) ? +nStr : NaN;
let scriptKb = 0;
let css = false;
let usage = !outdir || !(N >= 1 && N <= 99999);
for (const flag of flags) {
if (flag === "--css") css = true;
else if (/^--script-kb=\d+(\.\d+)?$/.test(flag)) scriptKb = +flag.slice("--script-kb=".length);
else usage = true;
}
if (usage || scriptKb > 1024) {
console.error("usage: bun gen.ts <outdir> <N: 1 to 99999> [--script-kb=<n: 0 to 1024>] [--css]");
process.exit(1);
}

// A run with a smaller N must not keep the pages of an earlier run, and must not remove what it did not write.
const pagesDir = join(outdir, "pages");
if (existsSync(pagesDir) && readdirSync(pagesDir).some(name => !/^p\d{5}\.(html|ts|css)$/.test(name))) {
console.error(`${pagesDir} has files that this generator did not write. Use another directory.`);
process.exit(1);
}
rmSync(pagesDir, { recursive: true, force: true });
mkdirSync(pagesDir, { recursive: true });

function scriptSource(id: string): string {
let source = `const n: number = 1${id};\nconsole.log("page", n);\n`;
for (let j = 0; source.length < scriptKb * 1024; j++) {
source += `
export function op${j}(input: string) {
const parts = input.split(",").map((s, idx) => ({ s, idx, w: s.length * ${j + 1} }));
let acc = ${j * 7} + n;
for (const p of parts) {
acc = (acc * 31 + p.w + p.idx) % 1000003;
if (p.s.startsWith("x")) acc ^= ${j * 13 + 1};
}
return { op: ${j}, acc, tag: "p${id}-op${j}-" + acc.toString(36) };
}
console.log(op${j}("a,b,xc,d").tag);
`;
}
return source;
}

for (let i = 1; i <= N; i++) {
const id = String(i).padStart(5, "0");
const link = css ? `<link rel="stylesheet" href="./p${id}.css">` : "";
writeFileSync(
join(pagesDir, `p${id}.html`),
`<!doctype html><html><head><title>p${id}</title>${link}</head><body><script type="module" src="./p${id}.ts"></script></body></html>\n`,
);
writeFileSync(join(pagesDir, `p${id}.ts`), scriptSource(id));
if (css) writeFileSync(join(pagesDir, `p${id}.css`), `.p${id} { color: red; }\n`);
}
console.log(`generated ${N} pages in ${outdir}`);
97 changes: 97 additions & 0 deletions bench/html-entry-points/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Builds the generated pages with two bun binaries, three ways: standalone
// HTML with linked source maps, an ordinary multi-page build, and the same
// with --splitting. Runs are interleaved across the binaries so drift hits
// them equally. Prints the median wall time and, on Linux, the median count of
// user-mode instructions (pin to one core for a stable count:
// `taskset -c 0 bun run.ts ...`). Fails if the two binaries write different bytes.
// Usage: bun run.ts <bun-before> <bun-after> <appdir> [runs]
import { readdirSync, readFileSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { openInstructionCounter } from "../../test/aot/analysis-scaling/measure-here.ts";

const [before, after, appdirArg, runsStr = "9"] = process.argv.slice(2);
const runs = +runsStr;
if (!before || !after || !appdirArg || !(runs > 0)) {
console.error("usage: bun run.ts <bun-before> <bun-after> <appdir> [runs]");
process.exit(1);
}
const appdir = resolve(appdirArg);
const binaries = { before: resolve(before), after: resolve(after) };
const entries = readdirSync(join(appdir, "pages"))
.filter(file => file.endsWith(".html"))
.sort()
.map(file => `pages/${file}`);

const variants: [name: string, args: string[]][] = [
["standalone-linked", ["--compile", "--target=browser", "--sourcemap=linked"]],
["multi-page", []],
["multi-page-splitting", ["--splitting"]],
];

function median(xs: number[]) {
const s = [...xs].sort((a, b) => a - b);
return s.length % 2 ? s[s.length >> 1] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2;
}

function treeHash(dir: string): string {
const hasher = new Bun.CryptoHasher("sha256");
for (const file of [...new Bun.Glob("**/*").scanSync({ cwd: dir })].sort()) {
hasher.update(file);
hasher.update(readFileSync(join(dir, file)));
}
return hasher.digest("hex");
}

async function build(bun: string, args: string[]) {
// The shell waits for a line before it becomes the build, so the counter is open first.
await using child = Bun.spawn({
cmd: ["/bin/sh", "-c", 'read line; exec "$0" "$@"', bun, "build", ...args],
cwd: appdir,
env: { ...process.env, NO_COLOR: "1" },
stdin: "pipe",
stdout: "ignore",
stderr: "pipe",
});
const readInstructions = process.platform === "linux" ? openInstructionCounter(child.pid) : undefined;
const start = performance.now();
child.stdin.write("\n");
child.stdin.end();
const [stderr, exitCode] = await Promise.all([child.stderr.text(), child.exited]);
const wall = performance.now() - start;
if (exitCode !== 0) {
console.error("build failed:", bun, args.join(" "), "\n", stderr);
process.exit(1);
}
return { wall, instructions: readInstructions?.() };
}

for (const [variant, args] of variants) {
const wall = { before: [] as number[], after: [] as number[] };
const instructions = { before: [] as number[], after: [] as number[] };
const hashes = new Set<string>();
for (let i = 0; i < runs; i++) {
for (const side of ["before", "after"] as const) {
const outdir = join(appdir, "out", side);
rmSync(outdir, { recursive: true, force: true });
const result = await build(binaries[side], [...args, "--outdir", outdir, ...entries]);
wall[side].push(result.wall);
if (result.instructions !== undefined) instructions[side].push(result.instructions);
if (i === 0) hashes.add(treeHash(outdir));
}
}
if (hashes.size !== 1) {
console.error(variant, "the two binaries wrote different output");
process.exit(1);
}
const counted = instructions.before.length > 0 && instructions.after.length > 0;
console.log(
JSON.stringify({
variant,
pages: entries.length,
wall_ms_before: Math.round(median(wall.before)),
wall_ms_after: Math.round(median(wall.after)),
instructions_before: counted ? median(instructions.before) : undefined,
instructions_after: counted ? median(instructions.after) : undefined,
}),
);
}
114 changes: 75 additions & 39 deletions src/bundler/Chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,27 +301,22 @@ impl<'a> Chunk<'a> {
match self.content {
Content::Javascript(_) => b"</script",
Content::Css(_) => b"</style",
Content::Html => unreachable!(),
Content::Html { .. } => unreachable!(),
}
}

pub(crate) fn get_js_chunk_for_html<'c>(
&self,
chunks: &'c [Chunk<'a>],
) -> Option<&'c Chunk<'a>> {
self.get_js_chunk_index_for_html(chunks).map(|i| &chunks[i])
self.get_js_chunk_index_for_html().map(|i| &chunks[i])
}

pub(crate) fn get_js_chunk_index_for_html(&self, chunks: &[Chunk]) -> Option<usize> {
// Non-entry chunks created under code splitting carry a default
// entry_point_id of 0, so the id alone is ambiguous; require
// is_entry_point to find the actual entry chunk.
let entry_point_id = self.entry_point.entry_point_id();
chunks.iter().position(|other| {
matches!(other.content, Content::Javascript(_))
&& other.entry_point.is_entry_point()
&& other.entry_point.entry_point_id() == entry_point_id
})
pub(crate) fn get_js_chunk_index_for_html(&self) -> Option<usize> {
match self.content {
Content::Html { js_chunk_index } => js_chunk_index.map(|i| i as usize),
_ => None,
}
}

pub(crate) fn get_css_chunk_for_html<'c>(
Expand All @@ -331,29 +326,21 @@ impl<'a> Chunk<'a> {
// Look up the CSS chunk via the JS chunk's css_chunks indices.
// This correctly handles deduplicated CSS chunks that are shared
// across multiple HTML entry points (see issue #23668).
let entry_point_id = self.entry_point.entry_point_id();
for other in chunks.iter() {
if let Content::Javascript(js) = &other.content {
if other.entry_point.is_entry_point()
&& other.entry_point.entry_point_id() == entry_point_id
{
if let Some(&css_idx) = js.css_chunks.first() {
return Some(&chunks[css_idx as usize]);
}
break;
}
}
}
// Fallback: match by entry_point_id for cases without a JS chunk.
for other in chunks.iter() {
if matches!(other.content, Content::Css(_))
&& other.entry_point.is_entry_point()
&& other.entry_point.entry_point_id() == entry_point_id
{
return Some(other);
}
}
None
let js_chunk = self.get_js_chunk_for_html(chunks)?;
let css_idx = *js_chunk.content.javascript().css_chunks.first()?;
Some(&chunks[css_idx as usize])
}

/// The HTML chunk of the entry point whose JS chunk is `chunks[js_chunk_index]`.
pub(crate) fn get_html_chunk_for_js<'c>(
chunks: &'c [Chunk<'a>],
js_chunk_index: usize,
entry_point_chunk_index: &[u32],
) -> Option<&'c Chunk<'a>> {
let source_index = chunks[js_chunk_index].entry_point.source_index();
let slot = *entry_point_chunk_index.get(source_index as usize)?;
let html_chunk = chunks.get(slot as usize)?;
(html_chunk.get_js_chunk_index_for_html() == Some(js_chunk_index)).then_some(html_chunk)
}

#[inline]
Expand Down Expand Up @@ -1616,7 +1603,10 @@ impl CrossChunkImport {
pub enum Content<'a> {
Javascript(JavaScriptChunk),
Css(CssChunk<'a>),
Html,
Html {
/// The entry point's JS chunk in the list `compute_chunks` returns; the dev server's list has none.
js_chunk_index: Option<u32>,
},
}

impl<'a> Content<'a> {
Expand All @@ -1635,23 +1625,23 @@ impl<'a> Content<'a> {
match self {
Content::Javascript(_) => default,
Content::Css(_) => options::SourceMapOption::None, // TODO: css source maps
Content::Html => options::SourceMapOption::None,
Content::Html { .. } => options::SourceMapOption::None,
}
}

pub(crate) fn loader(&self) -> Loader {
match self {
Content::Javascript(_) => Loader::Js,
Content::Css(_) => Loader::Css,
Content::Html => Loader::Html,
Content::Html { .. } => Loader::Html,
}
}

pub(crate) fn ext(&self) -> &'static [u8] {
match self {
Content::Javascript(_) => b"js",
Content::Css(_) => b"css",
Content::Html => b"html",
Content::Html { .. } => b"html",
}
}
}
Expand All @@ -1677,3 +1667,49 @@ pub mod bun_renamer {
}
}
}

#[cfg(test)]
#[test]
fn get_html_chunk_for_js_returns_only_the_chunk_that_names_the_js_chunk() {
fn chunk(source_index: u32, content: Content<'static>) -> Chunk<'static> {
Chunk {
entry_point: EntryPoint::entry_point(source_index, 0),
content,
..Default::default()
}
}
let html = |source_index, js_chunk_index| {
chunk(
source_index,
Content::Html {
js_chunk_index: Some(js_chunk_index),
},
)
};
// Two pages (files 5 and 2), then a file (7) that has a JS chunk and no HTML chunk.
let chunks = [
chunk(5, Content::default()),
html(5, 0),
chunk(2, Content::default()),
html(2, 2),
chunk(7, Content::default()),
];
let found = |js_chunk_index, entry_point_chunk_index: &[u32]| {
Chunk::get_html_chunk_for_js(&chunks, js_chunk_index, entry_point_chunk_index)
.and_then(|html| chunks.iter().position(|chunk| core::ptr::eq(chunk, html)))
};

let mut slots = [u32::MAX; 8];
(slots[5], slots[2], slots[7]) = (1, 3, 4);
assert_eq!(found(0, &slots), Some(1));
assert_eq!(found(2, &slots), Some(3));
// The slot names the JS chunk itself.
assert_eq!(found(4, &slots), None);
// The slot names the HTML chunk of another page.
slots[5] = 3;
assert_eq!(found(0, &slots), None);
// The slot is not set, or the file has no slot.
slots[5] = u32::MAX;
assert_eq!(found(0, &slots), None);
assert_eq!(found(0, &slots[..5]), None);
}
2 changes: 1 addition & 1 deletion src/bundler/HTMLImportManifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ pub(crate) fn write_with_assets<W: Write + ?Sized>(
entry_point_bits.set(ch.entry_point.entry_point_id() as usize);
chunks_to_visit.push(chunk_index as u32);

if matches!(ch.content, Content::Html) {
if matches!(ch.content, Content::Html { .. }) {
writer.write_all(b"\"index\":")?;
if inject_compiler_filesystem_prefix {
temp_buffer.clear();
Expand Down
2 changes: 1 addition & 1 deletion src/bundler/LinkerContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1127,7 +1127,7 @@ impl<'a> LinkerContext<'a> {
post_process_js_chunk(*ctx, worker, chunk, chunk_index)
}
crate::chunk::Content::Css(_) => post_process_css_chunk(*ctx, worker, chunk),
crate::chunk::Content::Html => post_process_html_chunk(*ctx, worker, chunk),
crate::chunk::Content::Html { .. } => post_process_html_chunk(*ctx, worker, chunk),
};
if let Err(err) = result {
Output::panic(format_args!("TODO: handle error: {}", err.name()));
Expand Down
4 changes: 3 additions & 1 deletion src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6125,7 +6125,9 @@ pub mod bv2_impl {
source_index.get(),
source_index.get(),
),
content: chunk::Content::Html,
content: chunk::Content::Html {
js_chunk_index: None,
},
output_source_map: SourceMap::SourceMapPieces::init(),
..Chunk::default()
});
Expand Down
Loading
Loading