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
31 changes: 20 additions & 11 deletions src/bundler/linker_context/MetafileBuilder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ pub(crate) fn generate_chunk_json(

/// Assembles the final metafile JSON from pre-built chunk fragments.
/// Called after all chunks have been generated in parallel.
/// Chunk references (unique_keys) are resolved to their final output paths.
/// Chunk references (unique_keys) left in the output are resolved to their final output paths.
/// The caller is responsible for freeing the returned slice.
pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Result<Box<[u8]>> {
// Use StringJoiner so we can use breakOutputIntoPieces to resolve chunk references
Expand All @@ -220,13 +220,19 @@ pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Re
let mut seen_sources = DynamicBitSet::init_empty(sources.len())?;
// defer seen_sources.deinit() — handled by Drop

let mut entry_point_by_chunk_key: StringHashMap<u32> = StringHashMap::default();

// Mark all files that appear in chunks
for chunk in chunks.iter() {
for &source_index in chunk.files_with_parts_in_chunk.keys() {
if (source_index as usize) < sources.len() {
seen_sources.set(source_index as usize);
}
}
if chunk.entry_point.is_entry_point() && !chunk.unique_key.is_empty() {
entry_point_by_chunk_key
.put_static_key(chunk.unique_key, chunk.entry_point.source_index())?;
}
}

// Write inputs
Expand Down Expand Up @@ -292,17 +298,21 @@ pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Re
}
first_import = false;

let target_source_index: Option<u32> = if record.source_index.is_valid() {
Some(record.source_index.get())
} else {
// A chunk's unique key, see `compute_cross_chunk_dependencies`.
entry_point_by_chunk_key.get(record.path.text).copied()
};

j.push_static(b"\n {\n \"path\": ");
// Bundled imports use the target source's pretty path (same string as the
// "inputs" key). `record.path.text` is unreliable here: dedup can set
// `source_index` without rewriting the path. Externals/chunk refs fall through.
let import_path: &[u8] = 'path: {
if record.source_index.is_valid()
&& record.source_index.get() != Index::RUNTIME.get()
{
let idx = record.source_index.get() as usize;
if idx < sources.len() {
let pretty = sources[idx].path.pretty;
if let Some(idx) = target_source_index {
if idx != Index::RUNTIME.get() && (idx as usize) < sources.len() {
let pretty = sources[idx as usize].path.pretty;
if !pretty.is_empty() {
break 'path pretty;
}
Expand Down Expand Up @@ -339,16 +349,15 @@ pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Re
if record
.flags
.contains(ImportRecordFlags::IS_EXTERNAL_WITHOUT_SIDE_EFFECTS)
|| !record.source_index.is_valid()
|| target_source_index.is_none()
{
j.push_static(b",\n \"external\": true");
}

// Add "with" for import attributes (json, toml, text loaders)
if record.source_index.is_valid()
&& (record.source_index.get() as usize) < loaders.len()
if let Some(loader) =
target_source_index.and_then(|idx| loaders.get(idx as usize).copied())
{
let loader = loaders[record.source_index.get() as usize];
let with_type: Option<&'static [u8]> = match loader {
Loader::Json => Some(b"json"),
Loader::Toml => Some(b"toml"),
Expand Down
132 changes: 104 additions & 28 deletions test/bundler/metafile.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { tempDir } from "harness";
import { bunEnv, bunExe, tempDir } from "harness";

// Type definitions for metafile structure
interface MetafileImport {
Expand Down Expand Up @@ -416,10 +416,11 @@ describe("bundler metafile", () => {
expect(requireImport!.kind).toBe("require-call");
});

test("metafile tracks dynamic-import imports", async () => {
test("metafile tracks dynamic-import imports with code splitting", async () => {
using dir = tempDir("metafile-dynamic-import-test", {
"entry.js": `import("./dynamic.js").then(m => console.log(m));`,
"entry.js": `import("./dynamic.js").then(m => console.log(m)); import("./styles.css");`,
"dynamic.js": `export const value = 123;`,
"styles.css": `.dynamic { color: red; }`,
});

const result = await Bun.build({
Expand All @@ -429,33 +430,85 @@ describe("bundler metafile", () => {
});

expect(result.success).toBe(true);
expect(result.metafile).toBeDefined();
const metafile = result.metafile as Metafile;
const inputKeys = Object.keys(metafile.inputs);
const entryKey = inputKeys.find(k => k.endsWith("entry.js"))!;
const dynamicKey = inputKeys.find(k => k.endsWith("dynamic.js"))!;
const stylesKey = entryKey.replace(/entry\.js$/, "styles.css");

// Splitting gives each import() target a chunk of its own (a JS chunk for dynamic.js,
// a CSS chunk for styles.css), but the inputs graph still links source files to
// source files: the import resolves to the "inputs" key of the imported file and is
// not external (esbuild reports it the same way).
expect(metafile.inputs[entryKey].imports).toEqual([
{ path: dynamicKey, kind: "dynamic-import", original: "./dynamic.js" },
{ path: stylesKey, kind: "dynamic-import", original: "./styles.css" },
]);

// The chunk itself is what the outputs graph links to.
const [dynamicChunkPath] = Object.entries(metafile.outputs).find(([, output]) => output.entryPoint === dynamicKey)!;
const entryOutput = Object.values(metafile.outputs).find(output => output.entryPoint === entryKey)!;
expect(entryOutput.imports).toContainEqual({ path: dynamicChunkPath, kind: "dynamic-import" });
});

// Find the entry file in inputs
const inputs = (result.metafile as Metafile).inputs as Record<string, MetafileInput>;
let dynamicImport: MetafileImport | null = null;
for (const [path, input] of Object.entries(inputs)) {
if (path.includes("entry.js")) {
for (const imp of input.imports) {
if (imp.kind === "dynamic-import" && imp.original === "./dynamic.js") {
dynamicImport = imp;
break;
}
}
break;
}
test("metafile inputs are the same with and without --splitting", async () => {
const files = {
"entry.js": `
import { shared } from "./shared.js";
console.log(shared, import("./data.json"), import("./other.js"), import("./lazy.js"));
`,
// A second user entry point that entry.js also import()s.
"other.js": `export const other = "other";`,
// Only reachable through import(), so splitting makes it an entry point of its own.
"lazy.js": `import { shared } from "./shared.js"; export const lazy = shared + "!";`,
"shared.js": `export const shared = "shared";`,
"data.json": `{ "answer": 42 }`,
};

async function buildMetafile(...extraArgs: string[]): Promise<Metafile> {
using dir = tempDir("metafile-splitting-inputs", files);
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "entry.js", "other.js", "--outdir=out", "--metafile=meta.json", ...extraArgs],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
return await Bun.file(`${dir}/meta.json`).json();
}

expect(dynamicImport).not.toBeNull();
expect(dynamicImport!.kind).toBe("dynamic-import");
expect(dynamicImport!.original).toBe("./dynamic.js");
// The path should be the final chunk path (e.g., "./chunk-xxx.js"), not the internal unique_key
expect(dynamicImport!.path).toMatch(/^\.\/chunk-[a-z0-9]+\.js$/);
const [withSplitting, withoutSplitting] = await Promise.all([buildMetafile("--splitting"), buildMetafile()]);

// Verify the path corresponds to an actual output chunk
const outputs = (result.metafile as Metafile).outputs as Record<string, MetafileOutput>;
const outputPaths = Object.keys(outputs);
expect(outputPaths).toContain(dynamicImport!.path);
expect(withSplitting.inputs["entry.js"].imports).toEqual([
{ path: "shared.js", kind: "import-statement", original: "./shared.js" },
{ path: "data.json", kind: "dynamic-import", original: "./data.json", with: { type: "json" } },
{ path: "other.js", kind: "dynamic-import", original: "./other.js" },
{ path: "lazy.js", kind: "dynamic-import", original: "./lazy.js" },
]);
expect(withSplitting.inputs).toEqual(withoutSplitting.inputs);

// Everything this fixture imports ends up in the bundle, so every import edge in the
// inputs graph points at another input, splitting or not.
const inputKeys = Object.keys(withSplitting.inputs);
for (const input of Object.values(withSplitting.inputs)) {
for (const imp of input.imports) {
expect(inputKeys).toContain(imp.path);
}
}

// The chunks the dynamic imports load are listed in the outputs graph.
const outputPathOf = (entryPoint: string) =>
Object.entries(withSplitting.outputs).find(([, output]) => output.entryPoint === entryPoint)![0];
expect(withSplitting.outputs[outputPathOf("entry.js")].imports).toEqual(
expect.arrayContaining([
{ path: outputPathOf("data.json"), kind: "dynamic-import" },
{ path: outputPathOf("other.js"), kind: "dynamic-import" },
{ path: outputPathOf("lazy.js"), kind: "dynamic-import" },
]),
);
});

test("metafile includes cssBundle for CSS outputs", async () => {
Expand Down Expand Up @@ -800,8 +853,6 @@ describe("Bun.build metafile option variants", () => {
});

// CLI tests for --metafile-md
import { bunEnv, bunExe } from "harness";

describe("bun build --metafile-md", () => {
test("generates markdown metafile with default name", async () => {
using dir = tempDir("metafile-md-test", {
Expand Down Expand Up @@ -1088,6 +1139,31 @@ describe("bun build --metafile-md", () => {
expect(content).toContain("require-call");
});

test("markdown links dynamically imported modules to their importers with --splitting", async () => {
using dir = tempDir("metafile-md-splitting-dynamic-import", {
"entry.js": `import("./dynamic.js").then(m => console.log(m));`,
"dynamic.js": `export const dynamic_value = 2;`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "build", "entry.js", "--metafile-md", "--outdir=dist", "--splitting"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});

const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);

const content = await Bun.file(`${dir}/meta.md`).text();
expect(content).toContain("[IMPORT: entry.js -> dynamic.js]");
expect(content).toContain("[IMPORTED_BY: dynamic.js <- entry.js]");
expect(content).not.toContain("[EXTERNAL: entry.js");
expect(content).not.toContain("| External imports |");
});

test("markdown shows commonly imported modules", async () => {
using dir = tempDir("metafile-md-common-imports", {
"a.js": `import { shared } from "./shared.js"; console.log("a", shared);`,
Expand Down
Loading