From aacc9dfba03807b87784286e81094ab45ad40deb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:31:03 +0000 Subject: [PATCH 1/5] bun-types, docs: the string and object forms of the Bun.build metafile option Since #26441, Bun.build accepts `metafile: "meta.json"` and `metafile: { json, markdown }`, and returns an artifact of kind "metafile-json" or "metafile-markdown" for each path. The types had `metafile?: boolean` and no such kinds. BuildConfig.metafile is now boolean | string | { json?, markdown? }, and BuildArtifact.kind lists the two metafile kinds. The JSDoc and the bundler docs say that a relative path is relative to the outdir, and that a build with no outdir writes nothing and returns the metafile as an artifact. The docs example no longer repeats the outdir in the metafile path (it wrote dist/dist/meta.json). Co-authored-by: adamscybot --- docs/bundler/index.mdx | 61 ++++++++++++++------ packages/bun-types/bun.d.ts | 62 +++++++++++++++++---- test/integration/bun-types/fixture/build.ts | 24 ++++++++ 3 files changed, 119 insertions(+), 28 deletions(-) diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index dcbed53d79c0..7b8875035b10 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -1520,34 +1520,50 @@ bun build ./src/index.ts --outdir ./dist --metafile=./dist/meta.json --metafile- #### `metafile` option formats -In the JavaScript API, `metafile` accepts several forms: +In the JavaScript API, `metafile` accepts several forms. Each form includes the metafile in the result object as `result.metafile`: ```ts title="build.ts" icon="/icons/typescript.svg" -// Boolean — include metafile in the result object +// Boolean: include the metafile in the result object await Bun.build({ entrypoints: ["./src/index.ts"], outdir: "./dist", metafile: true, }); -// String — write JSON metafile to a specific path +// String: also write the JSON metafile to ./dist/meta.json await Bun.build({ entrypoints: ["./src/index.ts"], outdir: "./dist", - metafile: "./dist/meta.json", + metafile: "meta.json", }); -// Object — specify separate paths for JSON and markdown output +// Object: also write the JSON metafile, the markdown metafile, or both await Bun.build({ entrypoints: ["./src/index.ts"], outdir: "./dist", metafile: { - json: "./dist/meta.json", - markdown: "./dist/meta.md", + json: "meta.json", // ./dist/meta.json + markdown: "meta.md", // ./dist/meta.md }, }); ``` +A relative path is relative to `outdir`. This differs from the CLI flags, whose paths are relative to the working directory. + +Each path also adds an artifact to `result.outputs`, with a `kind` of `"metafile-json"` or `"metafile-markdown"`. A metafile is written only when there is an `outdir`. Without an `outdir`, read it from the artifact: + +```ts title="build.ts" icon="/icons/typescript.svg" +const result = await Bun.build({ + entrypoints: ["./src/index.ts"], + metafile: { markdown: "meta.md" }, +}); + +const report = result.outputs.find(output => output.kind === "metafile-markdown"); +console.log(await report?.text()); +``` + +A build that compiles an executable adds no metafile artifact. With an `outdir` it still writes the metafiles. Without one, read `result.metafile`: the markdown metafile is not available. + The metafile structure contains: ```ts @@ -1589,11 +1605,11 @@ interface BuildOutput { outputs: BuildArtifact[]; success: boolean; logs: Array; // see docs for details - metafile?: BuildMetafile; // only when metafile: true + metafile?: BuildMetafile; // only when the metafile option is set } interface BuildArtifact extends Blob { - kind: "entry-point" | "chunk" | "asset" | "sourcemap" | "bytecode"; + kind: "entry-point" | "chunk" | "asset" | "sourcemap" | "bytecode" | "metafile-json" | "metafile-markdown"; path: string; loader: Loader; hash: string | null; @@ -1615,13 +1631,13 @@ for (const output of build.outputs) { Each artifact also contains the following properties: -| Property | Description | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `kind` | What kind of build output this file is. A build generates bundled entrypoints, code-split "chunks", sourcemaps, bytecode, and copied assets (like images). | -| `path` | Absolute path to the file on disk | -| `loader` | The loader used to interpret the file. See [loaders](/bundler/loaders) for how Bun maps file extensions to built-in loaders. | -| `hash` | The hash of the file contents. Always defined for assets. | -| `sourcemap` | The sourcemap file corresponding to this file, if generated. Only defined for entrypoints and chunks. | +| Property | Description | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `kind` | What kind of build output this file is. A build generates bundled entrypoints, code-split "chunks", sourcemaps, bytecode, copied assets (like images), and the metafiles of a `metafile` path. | +| `path` | Absolute path to the file on disk | +| `loader` | The loader used to interpret the file. See [loaders](/bundler/loaders) for how Bun maps file extensions to built-in loaders. | +| `hash` | The hash of the file contents. Always defined for assets. | +| `sourcemap` | The sourcemap file corresponding to this file, if generated. Only defined for entrypoints and chunks. | Similar to `BunFile`, `BuildArtifact` objects can be passed directly into `new Response()`. @@ -1953,6 +1969,15 @@ interface BuildConfig { */ tsconfig?: string; + /** + * Generate a metafile that describes the build. `true` returns it as + * `BuildOutput.metafile`. A string or an object also gives the path of the + * JSON metafile and of the markdown metafile, relative to `outdir`. + * + * @default false + */ + metafile?: boolean | string | { json?: string; markdown?: string }; + outdir?: string; } @@ -1960,13 +1985,14 @@ interface BuildOutput { outputs: BuildArtifact[]; success: boolean; logs: Array; + metafile?: BuildMetafile; // only when the metafile option is set } interface BuildArtifact extends Blob { path: string; loader: Loader; hash: string | null; - kind: "entry-point" | "chunk" | "asset" | "sourcemap" | "bytecode"; + kind: "entry-point" | "chunk" | "asset" | "sourcemap" | "bytecode" | "metafile-json" | "metafile-markdown"; sourcemap: BuildArtifact | null; } @@ -1977,6 +2003,7 @@ interface BuildOutput { outputs: BuildArtifact[]; success: boolean; logs: Array; + metafile?: BuildMetafile; // only when the metafile option is set } declare class ResolveMessage { diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 2ded9f1ad305..23186f8cfcef 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3660,13 +3660,25 @@ declare module "bun" { files?: Record; /** - * Generate a JSON file containing metadata about the build. + * Generate metadata about the build. * * The metafile contains information about inputs, outputs, imports, and exports * which can be used for bundle analysis, visualization, or integration with * other tools. * - * When `true`, the metafile JSON string is included in the {@link BuildOutput.metafile} property. + * - `true`: the metafile is returned as the {@link BuildOutput.metafile} object + * - a string: also the path of the JSON metafile + * - an object: also the path of the JSON metafile, of a markdown report of + * the module graph, or of both + * + * A relative path is relative to {@link BuildConfig.outdir}. The build writes + * the file only when there is an `outdir`. Each path also adds a + * {@link BuildArtifact} of kind `"metafile-json"` or `"metafile-markdown"` to + * {@link BuildOutput.outputs} (except when `compile` makes an executable). + * Without an `outdir`, nothing is written and the artifact holds the content. + * + * The `bun build --metafile=` and `--metafile-md=` CLI flags are + * different: their paths are relative to the working directory. * * @default false * @@ -3678,18 +3690,42 @@ declare module "bun" { * metafile: true, * }); * - * // Write metafile to disk for analysis * if (result.metafile) { - * await Bun.write('./dist/meta.json', result.metafile); + * console.log('Input files:', Object.keys(result.metafile.inputs)); + * console.log('Output files:', Object.keys(result.metafile.outputs)); * } + * ``` + * + * @example + * ```ts + * // Writes ./dist/meta.json and ./dist/meta.md + * await Bun.build({ + * entrypoints: ['./src/index.ts'], + * outdir: './dist', + * metafile: { json: 'meta.json', markdown: 'meta.md' }, + * }); + * ``` * - * // Parse and analyze the metafile - * const meta = JSON.parse(result.metafile!); - * console.log('Input files:', Object.keys(meta.inputs)); - * console.log('Output files:', Object.keys(meta.outputs)); + * @example + * ```ts + * // No outdir: the build writes nothing, the report is an artifact + * const result = await Bun.build({ + * entrypoints: ['./src/index.ts'], + * metafile: { markdown: 'meta.md' }, + * }); + * const report = result.outputs.find(output => output.kind === 'metafile-markdown'); + * console.log(await report?.text()); * ``` */ - metafile?: boolean; + metafile?: + | boolean + | string + | { + /** Path of the JSON metafile. */ + json?: string; + /** Path of a markdown report of the module graph. */ + markdown?: string; + }; outdir?: string; @@ -4367,7 +4403,11 @@ declare module "bun" { path: string; loader: Loader; hash: string | null; - kind: "entry-point" | "chunk" | "asset" | "sourcemap" | "bytecode"; + /** + * `"metafile-json"` and `"metafile-markdown"` are the files of a + * {@link BuildConfig.metafile} path. + */ + kind: "entry-point" | "chunk" | "asset" | "sourcemap" | "bytecode" | "metafile-json" | "metafile-markdown"; sourcemap: BuildArtifact | null; } @@ -4386,7 +4426,7 @@ declare module "bun" { * - **outputs**: every generated file with its byte size, the inputs that * contributed to it, imports between chunks, and exports * - * Only present when {@link BuildConfig.metafile} is `true`. + * Only present when {@link BuildConfig.metafile} is `true`, a string, or an object. * * Use it for bundle size analysis, inspecting the dependency graph, or as * input to bundle analyzer tools. diff --git a/test/integration/bun-types/fixture/build.ts b/test/integration/bun-types/fixture/build.ts index c87e2459cabb..3b232bd61523 100644 --- a/test/integration/bun-types/fixture/build.ts +++ b/test/integration/bun-types/fixture/build.ts @@ -66,3 +66,27 @@ Bun.build({ }, ], }); + +// BuildConfig.metafile accepts every form the runtime accepts: a boolean, the +// path of the JSON metafile, or the paths of the JSON and markdown metafiles. +expectAssignable(true); +expectAssignable("meta.json"); +expectAssignable({ json: "meta.json" }); +expectAssignable({ markdown: "meta.md" }); +expectAssignable({ json: "meta.json", markdown: "meta.md" }); +// An empty object is the same as `true`. +expectAssignable({}); +// @ts-expect-error - the only metafiles are json and markdown +expectAssignable({ yaml: "meta.yaml" }); + +Bun.build({ + entrypoints: ["hey"], + metafile: { json: "meta.json", markdown: "meta.md" }, +}).then(result => { + expectType(result.metafile).is(); + for (const output of result.outputs) { + if (output.kind === "metafile-json" || output.kind === "metafile-markdown") { + expectType(output.text()).is>(); + } + } +}); From 5ed1628e3be6c417079514fee289dfa5bca8473b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:31:05 +0000 Subject: [PATCH 2/5] Bun.build: hold the metafile in its artifact when there is no outdir With no outdir the build writes no file, but write_metafile_output made a Saved output for each metafile path. The artifact of a Saved output opens its path at each read, so it gave the bytes of another file, or ENOENT. The output is now a Buffer that holds the metafile. A build with an outdir is not changed. --- src/bundler/bundle_v2.rs | 9 +- test/bundler/metafile.test.ts | 187 ++++++++++++++++++++++++++++++++-- 2 files changed, 189 insertions(+), 7 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index db84c84224ae..736166112486 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5835,7 +5835,14 @@ pub mod bv2_impl { b"metafile.md".as_slice() }), output_path: Box::<[u8]>::from(file_path), - data: crate::output_file::OptionsData::Saved(content.len()), + // With no outdir the build writes nothing, so the artifact holds the bytes. + data: if outdir.is_empty() { + crate::output_file::OptionsData::Buffer { + data: Box::<[u8]>::from(content), + } + } else { + crate::output_file::OptionsData::Saved(content.len()) + }, output_kind, is_executable: false, side: None, diff --git a/test/bundler/metafile.test.ts b/test/bundler/metafile.test.ts index 8953f2ac6bb9..69c30fe28196 100644 --- a/test/bundler/metafile.test.ts +++ b/test/bundler/metafile.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { tempDir } from "harness"; -import { statSync } from "node:fs"; +import { readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; // Type definitions for metafile structure interface MetafileImport { @@ -686,7 +687,7 @@ describe("Bun.build metafile option variants", () => { const result = await Bun.build({ entrypoints: [`${dir}/entry.js`], outdir: `${dir}/dist`, - metafile: "output-meta.json" as any, + metafile: "output-meta.json", }); expect(result.success).toBe(true); @@ -717,7 +718,7 @@ describe("Bun.build metafile option variants", () => { const result = await Bun.build({ entrypoints: [`${dir}/main.js`], outdir: `${dir}/dist`, - metafile: { json: "custom-meta.json" } as any, + metafile: { json: "custom-meta.json" }, }); expect(result.success).toBe(true); @@ -741,7 +742,7 @@ describe("Bun.build metafile option variants", () => { const result = await Bun.build({ entrypoints: [`${dir}/app.js`], outdir: `${dir}/dist`, - metafile: { markdown: "analysis.md" } as any, + metafile: { markdown: "analysis.md" }, }); expect(result.success).toBe(true); @@ -773,7 +774,7 @@ describe("Bun.build metafile option variants", () => { metafile: { json: "meta.json", markdown: "meta.md", - } as any, + }, }); expect(result.success).toBe(true); @@ -849,7 +850,7 @@ describe("Bun.build metafile option variants", () => { const result = await Bun.build({ entrypoints: [`${dir}/test.js`], outdir: `${dir}/dist`, - metafile: { markdown: "meta.md" } as any, + metafile: { markdown: "meta.md" }, }); expect(result.success).toBe(true); @@ -861,6 +862,180 @@ describe("Bun.build metafile option variants", () => { }); }); +describe("Bun.build metafile artifacts", () => { + const files = { + "entry.js": `import { foo } from "./foo.js"; console.log(foo);`, + "foo.js": `export const foo = "hello";`, + "index.html": `\n`, + "app.js": `console.log(JSON.stringify(Bun.embeddedFiles.map(file => file.name)));`, + // Not outputs of any build below. They have the paths of the metafiles, and an artifact must not read them. + "meta.json": `{ "not": "the metafile" }`, + "meta.md": "not the report", + }; + const both = (cwd: string) => ({ json: join(cwd, "meta.json"), markdown: join(cwd, "meta.md") }); + + /** The build did not write over the files that have the paths of the metafiles. */ + async function expectNotWritten(cwd: string) { + expect(await Bun.file(join(cwd, "meta.json")).text()).toBe(files["meta.json"]); + expect(await Bun.file(join(cwd, "meta.md")).text()).toBe(files["meta.md"]); + } + + // A build with no outdir writes no file, so each metafile artifact has to hold its metafile. + describe("without an outdir", () => { + const forms: [form: string, config: (cwd: string) => Partial, kinds: string[]][] = [ + ["a string", cwd => ({ metafile: join(cwd, "meta.json") }), ["metafile-json"]], + ["{ json }", cwd => ({ metafile: { json: join(cwd, "meta.json") } }), ["metafile-json"]], + ["{ markdown }", cwd => ({ metafile: { markdown: join(cwd, "meta.md") } }), ["metafile-markdown"]], + ["{ json, markdown }", cwd => ({ metafile: both(cwd) }), ["metafile-json", "metafile-markdown"]], + [ + "{ json, markdown } set by a plugin", + cwd => ({ plugins: [{ name: "config", setup: build => void (build.config.metafile = both(cwd)) }] }), + ["metafile-json", "metafile-markdown"], + ], + [ + // One HTML file that holds its script. That build has no other file to write. + "{ json, markdown } of a standalone HTML file", + cwd => ({ entrypoints: [join(cwd, "index.html")], compile: true, target: "browser", metafile: both(cwd) }), + ["metafile-json", "metafile-markdown"], + ], + ]; + + test.concurrent.each(forms)("hold the metafile: %s", async (_form, config, kinds) => { + using dir = tempDir("metafile-no-outdir", files); + const cwd = String(dir); + const result = await Bun.build({ entrypoints: [join(cwd, "entry.js")], ...config(cwd) }); + expect(Object.keys(result.metafile!)).toEqual(["inputs", "outputs"]); + + // By kind, not by position in `outputs`. + const artifacts = result.outputs + .filter(output => output.kind.startsWith("metafile-")) + .sort((a, b) => a.kind.localeCompare(b.kind)); + expect(artifacts.map(({ kind, path }) => ({ kind, path }))).toEqual( + kinds.map(kind => ({ kind, path: join(cwd, kind === "metafile-json" ? "meta.json" : "meta.md") })), + ); + for (const artifact of artifacts) { + const text = await artifact.text(); + expect(artifact.size).toBe(Buffer.byteLength(text)); + if (artifact.kind === "metafile-json") expect(JSON.parse(text)).toEqual(result.metafile); + else expect(text).toStartWith("# Bundle Analysis Report"); + } + + // The build wrote no file. + expect(readdirSync(cwd).sort()).toEqual(Object.keys(files).sort()); + await expectNotWritten(cwd); + }); + + // The working directory of the child has a "meta.json" that is not the metafile. Then the child moves to a + // directory that has another one. + test.concurrent("are not read from a relative path", async () => { + using dir = tempDir("metafile-no-outdir-relative", { + ...files, + "elsewhere/meta.json": `{ "also": "not the metafile" }`, + "build.js": /* js */ ` + const isMetafile = output => output.kind === "metafile-json"; + let onEnd; + const result = await Bun.build({ + entrypoints: ["./entry.js"], + metafile: "meta.json", + plugins: [{ name: "on-end", setup: build => void build.onEnd(async ({ outputs }) => void (onEnd = await outputs.find(isMetafile).text())) }], + }); + const artifact = result.outputs.find(isMetafile); + + process.chdir("elsewhere"); + await Bun.write("copy.json", artifact); + + console.log( + JSON.stringify({ + path: artifact.path, + metafile: result.metafile, + json: await artifact.json(), + // Every way to read a Blob. + texts: { + onEnd, + text: await artifact.text(), + arrayBuffer: new TextDecoder().decode(await artifact.arrayBuffer()), + slice: await artifact.slice().text(), + stream: await new Response(artifact.stream()).text(), + response: await new Response(artifact).text(), + write: await Bun.file("copy.json").text(), + }, + }), + ); + `, + }); + const cwd = String(dir); + await using proc = Bun.spawn({ cmd: [bunExe(), "build.js"], env: bunEnv, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const { path, metafile, json, texts } = JSON.parse(stdout); + + expect(path).toBe("meta.json"); + expect(Object.keys(metafile)).toEqual(["inputs", "outputs"]); + expect(json).toEqual(metafile); + expect(JSON.parse(texts.text)).toEqual(metafile); + expect(texts).toEqual({ + onEnd: texts.text, + text: texts.text, + arrayBuffer: texts.text, + slice: texts.text, + stream: texts.text, + response: texts.text, + write: texts.text, + }); + await expectNotWritten(cwd); + expect(exitCode).toBe(0); + }); + }); + + // A compile returns the executable alone, and the metafile as `result.metafile`. It puts the outputs that hold + // bytes in the executable, and a metafile must stay out of it. + // Not concurrent, one compile each, and with a timeout: a compile copies the whole bun binary (about 1 GB in a + // debug build) on the thread that runs the tests. + describe("a compile to an executable has no metafile artifact, and no metafile in the executable", () => { + test.each([ + ["without an outdir", false], + ["with an outdir", true], + ])( + "%s", + async (_name, hasOutdir) => { + using dir = tempDir("metafile-compile", files); + const cwd = String(dir); + const outdir = join(cwd, "out"); + const result = await Bun.build({ + entrypoints: [join(cwd, "app.js")], + ...(hasOutdir + ? { outdir, compile: { outfile: "app" }, metafile: { json: "meta.json", markdown: "meta.md" } } + : { compile: { outfile: join(cwd, "app") }, metafile: both(cwd) }), + }); + const executable = isWindows ? "app.exe" : "app"; + + expect( + result.outputs.map(({ kind, path }) => ({ kind, path: relative(cwd, path).replaceAll("\\", "/") })), + ).toEqual([{ kind: "entry-point", path: hasOutdir ? `out/${executable}` : executable }]); + expect(Object.keys(result.metafile!)).toEqual(["inputs", "outputs"]); + // Only a build with an outdir writes the metafiles, and it writes them in the outdir. + await expectNotWritten(cwd); + if (hasOutdir) { + expect(readdirSync(outdir).sort()).toEqual([executable, "meta.json", "meta.md"]); + expect(await Bun.file(join(outdir, "meta.json")).json()).toEqual(result.metafile); + expect(await Bun.file(join(outdir, "meta.md")).text()).toStartWith("# Bundle Analysis Report"); + } + + await using proc = Bun.spawn({ + cmd: [result.outputs[0].path], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "[]\n", stderr: "" }); + expect(exitCode).toBe(0); + }, + 60_000, + ); + }); +}); + // CLI tests for --metafile-md import { bunEnv, bunExe, isWindows } from "harness"; From bba9c6e02e2d94719a6655e893e54ad5b6279107 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:13:55 +0000 Subject: [PATCH 3/5] metafile test: no timeout on the two compile tests The tests that compile an executable do not pass a timeout. They are skipped on a debug or ASAN build, where the bun binary that a compile copies is so large that one compile can take longer than the default timeout. --- test/bundler/metafile.test.ts | 79 +++++++++++++++++------------------ 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/test/bundler/metafile.test.ts b/test/bundler/metafile.test.ts index 69c30fe28196..5ec25812dc04 100644 --- a/test/bundler/metafile.test.ts +++ b/test/bundler/metafile.test.ts @@ -989,55 +989,52 @@ describe("Bun.build metafile artifacts", () => { // A compile returns the executable alone, and the metafile as `result.metafile`. It puts the outputs that hold // bytes in the executable, and a metafile must stay out of it. - // Not concurrent, one compile each, and with a timeout: a compile copies the whole bun binary (about 1 GB in a - // debug build) on the thread that runs the tests. + // Not concurrent, and one compile each: a compile copies the whole bun binary on the thread that runs the tests. + // Not on a debug or ASAN build: that binary is so large (about 1 GB for a debug build) that one compile can take + // longer than the default timeout. describe("a compile to an executable has no metafile artifact, and no metafile in the executable", () => { - test.each([ + test.skipIf(isDebug || isASAN).each([ ["without an outdir", false], ["with an outdir", true], - ])( - "%s", - async (_name, hasOutdir) => { - using dir = tempDir("metafile-compile", files); - const cwd = String(dir); - const outdir = join(cwd, "out"); - const result = await Bun.build({ - entrypoints: [join(cwd, "app.js")], - ...(hasOutdir - ? { outdir, compile: { outfile: "app" }, metafile: { json: "meta.json", markdown: "meta.md" } } - : { compile: { outfile: join(cwd, "app") }, metafile: both(cwd) }), - }); - const executable = isWindows ? "app.exe" : "app"; - - expect( - result.outputs.map(({ kind, path }) => ({ kind, path: relative(cwd, path).replaceAll("\\", "/") })), - ).toEqual([{ kind: "entry-point", path: hasOutdir ? `out/${executable}` : executable }]); - expect(Object.keys(result.metafile!)).toEqual(["inputs", "outputs"]); - // Only a build with an outdir writes the metafiles, and it writes them in the outdir. - await expectNotWritten(cwd); - if (hasOutdir) { - expect(readdirSync(outdir).sort()).toEqual([executable, "meta.json", "meta.md"]); - expect(await Bun.file(join(outdir, "meta.json")).json()).toEqual(result.metafile); - expect(await Bun.file(join(outdir, "meta.md")).text()).toStartWith("# Bundle Analysis Report"); - } + ])("%s", async (_name, hasOutdir) => { + using dir = tempDir("metafile-compile", files); + const cwd = String(dir); + const outdir = join(cwd, "out"); + const result = await Bun.build({ + entrypoints: [join(cwd, "app.js")], + ...(hasOutdir + ? { outdir, compile: { outfile: "app" }, metafile: { json: "meta.json", markdown: "meta.md" } } + : { compile: { outfile: join(cwd, "app") }, metafile: both(cwd) }), + }); + const executable = isWindows ? "app.exe" : "app"; - await using proc = Bun.spawn({ - cmd: [result.outputs[0].path], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout, stderr }).toEqual({ stdout: "[]\n", stderr: "" }); - expect(exitCode).toBe(0); - }, - 60_000, - ); + expect( + result.outputs.map(({ kind, path }) => ({ kind, path: relative(cwd, path).replaceAll("\\", "/") })), + ).toEqual([{ kind: "entry-point", path: hasOutdir ? `out/${executable}` : executable }]); + expect(Object.keys(result.metafile!)).toEqual(["inputs", "outputs"]); + // Only a build with an outdir writes the metafiles, and it writes them in the outdir. + await expectNotWritten(cwd); + if (hasOutdir) { + expect(readdirSync(outdir).sort()).toEqual([executable, "meta.json", "meta.md"]); + expect(await Bun.file(join(outdir, "meta.json")).json()).toEqual(result.metafile); + expect(await Bun.file(join(outdir, "meta.md")).text()).toStartWith("# Bundle Analysis Report"); + } + + await using proc = Bun.spawn({ + cmd: [result.outputs[0].path], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "[]\n", stderr: "" }); + expect(exitCode).toBe(0); + }); }); }); // CLI tests for --metafile-md -import { bunEnv, bunExe, isWindows } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isWindows } from "harness"; describe("bun build --metafile-md", () => { test("generates markdown metafile with default name", async () => { From 753e59d00d2b65cb707b20c3d84f3f3b7e20318e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:13:51 +0000 Subject: [PATCH 4/5] bun-types: an empty metafile path is no path The runtime reads an empty metafile path as no path: `metafile: ""` is the same as `true`, and `{ json: "", markdown }` gives the markdown file and artifact only. The JSDoc says so, and two rows of the test cover it. --- packages/bun-types/bun.d.ts | 1 + test/bundler/metafile.test.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 9a916029ad1b..58063754da3d 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3676,6 +3676,7 @@ declare module "bun" { * {@link BuildArtifact} of kind `"metafile-json"` or `"metafile-markdown"` to * {@link BuildOutput.outputs} (except when `compile` makes an executable). * Without an `outdir`, nothing is written and the artifact holds the content. + * An empty path is the same as no path. * * The `bun build --metafile=` and `--metafile-md=` CLI flags are * different: their paths are relative to the working directory. diff --git a/test/bundler/metafile.test.ts b/test/bundler/metafile.test.ts index 5ec25812dc04..c108d50076aa 100644 --- a/test/bundler/metafile.test.ts +++ b/test/bundler/metafile.test.ts @@ -887,6 +887,13 @@ describe("Bun.build metafile artifacts", () => { ["{ json }", cwd => ({ metafile: { json: join(cwd, "meta.json") } }), ["metafile-json"]], ["{ markdown }", cwd => ({ metafile: { markdown: join(cwd, "meta.md") } }), ["metafile-markdown"]], ["{ json, markdown }", cwd => ({ metafile: both(cwd) }), ["metafile-json", "metafile-markdown"]], + // An empty path is the same as no path. + ['"" (no artifact)', () => ({ metafile: "" }), []], + [ + '{ json: "", markdown }', + cwd => ({ metafile: { json: "", markdown: join(cwd, "meta.md") } }), + ["metafile-markdown"], + ], [ "{ json, markdown } set by a plugin", cwd => ({ plugins: [{ name: "config", setup: build => void (build.config.metafile = both(cwd)) }] }), From 69835a04da3dbea90ba9b1eccb3faf4c5aee88b9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:00:15 +0000 Subject: [PATCH 5/5] aot: do not call the metafile writer when there is no outdir write_metafile_files calls write_metafile_output to write the files and drops the outputs. With no outdir there is no file to write, and each output now holds a copy of its metafile. It returns before the calls, so an --aot build makes no copy that nothing reads. --- src/bundler/aot/metafile.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bundler/aot/metafile.rs b/src/bundler/aot/metafile.rs index cac55ca26855..20617106e54b 100644 --- a/src/bundler/aot/metafile.rs +++ b/src/bundler/aot/metafile.rs @@ -84,9 +84,13 @@ pub fn write_metafile_files(json: &[u8], files: &MetafileFiles<'_>) -> Option