Skip to content

bundler: read the metafile size of a copied input from its asset file - #38341

Open
robobun wants to merge 2 commits into
mainfrom
farm/890ff4f2/metafile-css-entry-and-asset-bytes
Open

robobun wants to merge 2 commits into
mainfrom
farm/890ff4f2/metafile-css-entry-and-asset-bytes

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • inputs[..].bytes in the metafile is 0 for each input that the build copies: file, wasm, napi and embedded sqlite since 1.4.0, text under --compile since 1.4.1. Bun 1.3.14 and esbuild write the size.
  • process_files_to_copy (src/bundler/bundle_v2.rs:4787) empties source.contents when it makes the asset. generate (MetafileBuilder.rs:290) reads its length after that.
  • No user reported this. test/aot/cli/metafile.test.ts:109 has a guard for it.

Fix

  • LinkerContext::link reads the size of each asset file, only when the build asks for a metafile. generate writes it.
  • It reads before emit, because emit takes the asset files out of the graph.
  • Verified: test/bundler/metafile.test.ts (7 new tests, 0 pass on main) and test/aot/cli/metafile.test.ts without the guard. Also 13 suites (Notes).
  • Self-reviewed: 28 concerns raised, 24 addressed here or in bundler: write the metafile record of a CSS output from the compile results of its chunk #47453, 3 held, 1 rejected (Notes).

Background

  • The metafile (--metafile, Bun.build({ metafile: true })) is the JSON build report in esbuild's format.
  • A copied input is a file that its loader emits as an asset. The importing JS gets a stub with the asset's path.
  • Weighed: a size column for each input file (the first version), and keeping source.contents. The chunk fold weight and the printer's buffer reserve read that length and need the stub's size.

Downsides

Notes

What changed since the first push. The first version (2026-08-14) had three fixes in one diff: this size, through a new column on each input file, and two fixes for the records of CSS outputs. A design review rejected the column: it costs 16 bytes for each input file in each build, for a number that only a metafile reads. A self-review then asked for one concern in each PR. This PR is now the size only. The two CSS fixes are in #47453, which does not depend on this PR.

inputs[..].bytes by release

logo.png (5,000 bytes) mod.wasm (8 bytes) note.txt as text under --compile (7 bytes)
1.3.14 5000 8 7
1.4.0 0 0 7
1.4.1 0 0 0
main 9fe1cd6 0 0 0
this PR 5000 8 7
esbuild 0.21.5 5000 8

1.4.1 copies a text import under --compile (#40177). That is the case of the guard in test/aot/cli/metafile.test.ts (#45631). This PR removes the guard, so that test compares bytes of each input with the --aot metafile. The other two filters of that loop do not change.

Why source.contents is empty. process_files_to_copy takes it (core::mem::take). It copies bytes that were read from disk into the asset file, and it moves bytes that an onLoad plugin returned. The length is read after that by generate, and also by the fold weight of a chunk (mergeSmallChunks.rs:1026), the reserve of the print buffer (js_printer/lib.rs:8049) and a source map hint (:7690). Those three want the size of the stub, so this PR does not put the bytes back.

Where the asset files are when generate runs

Destination Asset files
--outdir moved into the output list by the loop that writes them
in memory, --compile moved into the output list
standalone HTML (--compile --target=browser) still in the graph, none is emitted

So no one list after emit has each asset. link runs before emit, with all of them in the graph.

A build with two graphs. bun build ./p.html ./e.js --target=bun --metafile, where the stylesheet of the page and the server code both load a 13-byte image. The file has a record for each graph, under one key (#47083). The stylesheet inlines the image, so only the module of the server code is copied. On main that record says 0 and the other says 13, and the one that a JSON parser keeps changes from build to build. With this PR both say 13 (120 builds). With #47083 merged on an earlier head of this branch, the file is listed once in all 120.

What stays as on main

Numbers (release builds, main 9fe1cd6 against this diff on it)

main PR
.text bytes (size -A) 108,798,697 108,799,465
.rodata bytes 26,448,844 26,448,844
size_of::<LinkerContext>() 1,128 1,152
size_of::<InputFile>() 192 192
no metafile: times the new read in link runs 0
--metafile: times it runs 1
instructions, no metafile: 2,001 TS modules 188,487,125 188,476,782
instructions, no metafile: 5,000 copied files 441,985,480 442,058,502
instructions, --metafile: 2,001 TS modules 203,688,884 203,681,269
instructions, --metafile: 200 copied files 24,029,096 24,043,797
instructions, --metafile: 5,000 copied files 484,572,320 484,752,262
file syscalls of a build with 2 copied files and --metafile openat 21, read 13, write 10, close 23 the same

Instructions are user instructions of the process (perf_event_open), the median of 21 interleaved runs with 2 pool threads (11 runs for 5,000 files). The spread of the runs is 0.15% to 1.0%, so no row is a change. The run counts are gdb breakpoint hits. Syscalls are ptrace counts. strace, perf, valgrind and bloaty are not on the machine. A sorted list of pairs with a binary search was measured too: +0.85% instructions for the 5,000 files with --metafile, so the sizes are a list by source index.

Other open PRs on these lines (trial merges)

Tests. In test/bundler/metafile.test.ts: files of the file, wasm, napi and sqlite loaders, a file whose bytes an onLoad plugin returns (the file on disk has another size), and a standalone HTML file, each to disk and in memory. One CLI build with two graphs reads both records from the text of the metafile. test/aot/cli/metafile.test.ts covers a text import under --compile.

Suites run with the debug build. metafile 72 pass, aot/cli/metafile 4 pass. All pass: esbuild/metafile 12, esbuild/css 68, esbuild/loader 10, bundler_loader 62, bundler_plugin 68, bundler_html 26, html-import-manifest 10, bundler_html_server 4, bundler_bun 22, compile-asset-bunfs 10, bundler_splitting 209, esbuild/default 155, regression/issue/10139 2.

Self-review. A review of this diff together with the two CSS fixes and a refactor of the JS byte counters raised 28 concerns. Addressed (24): the split, "empties" where the text said "moves", the route of a standalone HTML build, the guard and the releases named above, and the lists above of what stays and of the overlaps. The rest are in #47453. Held (3): the ones about the refactor, which is not in a PR. Rejected (1): a metafile check in test/bundler/expectBundled.ts for each fixture that sets metafile. It changes the shared harness. The loader test here and the AOT test cover the same five loaders. Not done: a file import in the fixture of the AOT test, because its text import takes the same path.


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/metafile.test.ts, test/aot/cli/metafile.test.ts

@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 25674b9 and 999b571.

Files selected for processing: 1
  • test/bundler/metafile.test.ts

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

Walkthrough

The linker records copied-input sizes when metafile generation is enabled. Metafile generation uses those sizes to report input byte counts. Tests cover loaders, plugin-provided contents, HTML builds, CLI output, and AOT builds.

Changes

Metafile Input Byte Reporting

Layer / File(s) Summary
Capture copied input sizes
src/bundler/LinkerContext.rs
When metafile generation is enabled, the linker records copied asset sizes by source index.
Report and validate input sizes
src/bundler/linker_context/MetafileBuilder.rs, test/bundler/metafile.test.ts, test/aot/cli/metafile.test.ts
Metafile generation uses recorded sizes for copied inputs and falls back to source.contents.len() otherwise. Tests check reported sizes across loaders, plugin contents, HTML builds, CLI output, and AOT builds.

Suggested reviewers: alii, jarred-sumner

Priority: Normal

Merge Risk: Minimal · up to 999b5

No actionable merge-blocking issue is established for the copied-input byte reporting change. Merge remains subject to normal build and test checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: reading copied input sizes from asset files for metafile output.
Description check Passed The description explains the problem, implementation, scope, tradeoffs, and verification results. It does not use the template headings exactly, but it provides the required information in equivalent …
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.
Full details: Description check

Explanation

The description explains the problem, implementation, scope, tradeoffs, and verification results. It does not use the template headings exactly, but it provides the required information in equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:40 AM PT - Oct 30th, 2026

❌ @robobun, your commit 999b571 has 2 failures in Build #135719 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38341

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

bun-38341 --bun

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green on every lane that it touches. The one red test, test/aot/engine/ir-6.test.ts, is an AOT IR golden that fails on main too and is reported separately. The other failures passed on retry.

Reproduced on main (9fe1cd6) and on the releases 1.4.0 and 1.4.1: bun build ./entry.js --outdir=out --metafile=meta.json, where entry.js imports a 5,000-byte logo.png, writes inputs["logo.png"].bytes as 0. Bun 1.3.14 and esbuild write 5000. The seven new tests in test/bundler/metafile.test.ts fail on main and pass on this branch.

This PR is now the size of copied inputs only. The two fixes for the records of CSS outputs that its first version had are in #47453.

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

LGTM — metafile-only bugfixes bringing CSS entry points and copied-asset sizes in line with esbuild.

What was reviewed:

  • Verified the PR's claim that nothing else consumes files_with_parts_in_chunk for CSS chunks: find_all_imported_parts_in_js_order skips Content::Css, and compute_cross_chunk_dependencies::walk continues on CSS content — so seeding the map only affects the metafile and the CSS printer's fetch_add (which previously no-oped on the missing entry).
  • The extracted css_files_with_parts_in_chunk helper is byte-identical to the inline loop it replaces at the JS-entry-imports-CSS site.
  • The new mutable copied_contents_len column in the process_files_to_copy split-borrow is disjoint SoA storage, same invariant as the existing three mutable columns; the write reuses the already-computed contents_len right before mem::take.
  • Gating exports on Content::Javascript also empties HTML-chunk exports; bundler_html tests still pass per the description and [] is the correct value there.
Extended reasoning...

Overview

Three targeted metafile bugfixes: (1) CSS entry point chunks now seed files_with_parts_in_chunk from the CSS import order via a shared helper (previously left empty, so the metafile listed no inputs), (2) generate_chunk_json writes exports only for JS chunks so CSS/HTML outputs get exports: [] instead of leaking the JS entry's or the stylesheet's JS-side exports, (3) a new InputFile.copied_contents_len SoA column captures the source length before process_files_to_copy moves the bytes out, so copied assets no longer report bytes: 0. Four new test.concurrent cases cover both CSS chunk construction paths and both Cow arms of the asset move.

Security risks

None. This only shapes the metafile JSON (diagnostic build metadata); it does not touch bundle output, resolution, or any user-input parsing path.

Level of scrutiny

Moderate. The bundler linker is core code, but every change here is metafile-observable only — none of the three fixes alters emitted JS/CSS bytes. I traced each consumer of files_with_parts_in_chunk to confirm CSS chunks are skipped outside the metafile/printer paths (findAllImportedPartsInJSOrder.rs:34, computeCrossChunkDependencies.rs:145), so populating the map for CSS entry points cannot change chunk ordering or cross-chunk deps. The unsafe split-borrow extension in bundle_v2.rs adds one more disjoint MultiArrayList column under the same SAFETY invariant already covering three mutable columns; the comment count was updated to seven.

Other factors

The helper extraction deduplicates the two CSS-chunk sites per REVIEW.md's "second time a block appears, extract" rule. copied_contents_len reuses the contents_len local that already fed OutputFile::size, so it cannot diverge from the emitted asset's size. Tests use exact toEqual assertions, Buffer.alloc per repo convention, and the plugin test's on-disk file has a different size than the plugin bytes so the assertion can only pass by reading the plugin's length. The PR ran the full esbuild-compat and bundler test matrix on the debug build.

Jarred-Sumner pushed a commit that referenced this pull request Sep 15, 2026
…].bytes (#42760)

### Problem
- In the JSON metafile (`bun build --metafile`, `Bun.build({ metafile:
true })`), `outputs[path].bytes` is smaller than the emitted file when
the chunk references another output. On 1.4.3 `m0.js` is 130 bytes on
disk, 98 in the metafile. `bun.d.ts` documents the field as "Size of the
output file in bytes".
- The cause is `generate_chunk_json`
(`src/bundler/linker_context/MetafileBuilder.rs:75`). It ran before
`code()` and wrote `IntermediateOutput::get_size()`
(`src/bundler/Chunk.rs:610`), which adds only the literal pieces. It
leaves out every path and HTML manifest that `code()` writes, and the
`//# debugId` and `//# sourceMappingURL` comments.

### Fix
- `Chunk::final_output_size` records the length of the emitted buffer.
Each place that makes the final buffer sets it: two in
`generateChunksInParallel.rs` (standalone HTML, in-memory) and one in
`writeOutputFilesToDisk.rs`.
- `metafile_builder::generate` builds each chunk fragment. Both callers
run it after `generate_chunks_in_parallel`, so the size is known.
esbuild defers this fragment in the same way.
- Removed: `get_size()`, `Chunk::metafile_chunk_json`, and the `unsafe
detach_lifetime()` in `generate`. The fragment was its only borrow that
was not `'static`.
- Verified: `test/bundler/metafile.test.ts`, 15 new cases, all fail on
1.4.3. Also the `esbuild/metafile`, `bundler_compile_splitting`,
`standalone`, `bundler_html` and `html-import-manifest` suites.

### Background
- The bundler prints a chunk before its final path exists, because the
path holds a content hash. A reference to another output is a
placeholder (a unique key) in the printed text.
- `IntermediateOutput::Pieces` holds the printed text, split at each
placeholder. `code()` joins the pieces and writes the final path at each
split.
- The bundler then appends the source map comment and writes the buffer
to disk, or returns it as a `BuildArtifact`.

<details><summary>Notes</summary>

Repro (1.4.3 and main at 5fce36e):

```sh
mkdir -p src
printf 'import { v1 } from "./m1.ts";\nimport { v2 } from "./m2.ts";\nconsole.log(v1 + v2);\n' > src/m0.ts
printf 'import c from "./c3.cjs";\nexport const v1 = "V1" + c.v3;\nconsole.log(v1);\n' > src/m1.ts
printf 'import c from "./c3.cjs";\nexport const v2 = "V2" + c.v3;\nconsole.log(v2);\n' > src/m2.ts
printf 'module.exports = { v3: "V3" };\n' > src/c3.cjs
bun build --splitting --target=bun --outdir=out --metafile=meta.json src/m0.ts src/m1.ts src/m2.ts
```

| output | on disk | `bytes` before | `bytes` after |
| --- | --- | --- | --- |
| `./m0.js` (2 imports) | 130 | 98 | 130 |
| `./m0-5begjve3.js` (1 import) | 195 | 179 | 195 |
| `./m1.js` (1 import) | 74 | 58 | 74 |
| `./m0-8x1hvvzq.js` (no import) | 1400 | 1400 | 1400 |

Each import costs 16 bytes, the length of `./m0-xxxxxxxx.js`. Other
cases measured on 1.4.3:

- `--sourcemap=linked`: `m0.js` is 207 on disk, the metafile said 98.
- `--sourcemap=inline`: 611 on disk, 98 in the metafile.
- `--sourcemap=external`: 176 on disk (the `//# debugId` comment, which
`code()` writes), 98 in the metafile.
- `import logo from "./logo.svg"` (file loader): 98 on disk, 79 in the
metafile.
- An HTML entry point: `index.html` is 218 on disk, 160 in the metafile.
- A server build that imports an HTML file (the manifest placeholder):
`server.js` now matches, 889 bytes.

esbuild 0.25.1 on a split build with source maps reports the exact file
size (`a.js`: 146 on disk, 146 in the metafile). Its linker passes
`len(outputContents)` to `jsonMetadataChunkCallback(finalOutputSize)`
after it appends the `sourceMappingURL` comment.

The new tests compare `outputs[key].bytes` with the size of each emitted
file:

- Code splitting with each `sourcemap` mode (`none`, `linked`, `inline`,
`external`), with an `outdir` (the disk loop) and without (the in-memory
loop).
- An asset referenced from JS, and a 128 KB asset referenced from CSS. A
stylesheet inlines a smaller file as a `data:` URL.
- An HTML entry point.
- A standalone HTML file (`compile` with `target: "browser"`). This is
the only case that reaches the third assignment. The test compares the
`bytes` of each inlined chunk with the text between its tags in the
document.
- The CLI with `--metafile` and `--metafile-md`. The test reads the
`[ENTRY: ... (N bytes)]` lines of the report.

With any one of the three assignments removed, the chunks of that path
report 0 and its cases fail.

More builds checked by hand against the fixed binary, all equal to the
file on disk: `--banner` and `--footer`, a shebang entry with
`--target=bun`, `--minify` with `--sourcemap=linked`, `--format=cjs
--bytecode`, `--public-path`, `--entry-naming
'[dir]/[name]-[hash].[ext]'` with entry points in subdirectories, and a
CSS entry point with an `@import` and a 200 KB `url()` asset.

Why a field on the chunk and not `OutputFile.size`: a standalone HTML
build gives each inlined chunk an empty placeholder `OutputFile` and
then filters those out of the list. The index of a chunk and the index
of its output file match only by a `debug_assert`, and not in that mode.

What changes for a user:

- `bytes` grows by the paths, by the `debugId` comment, and by the
`sourceMappingURL` comment. With `sourcemap: "inline"` that comment
holds the whole map. The number now equals `BuildArtifact.size`.
- The `--metafile-md` report uses the same numbers for "Total output
size", "Bundle size", the chunk sizes and the "% of Total" column.
- `--compile --target=browser` inlines the JS and CSS chunks into the
document. `index.html` now reports its real size (403, was 203). An
inlined chunk keeps its `outputs` entry, as before. Its `bytes` is the
size of the inlined code.
- `--compile` embeds each chunk in the executable. `bytes` is the size
of the embedded module.
- An error from `generate_chunk_json` now fails `generate` with the
existing "Failed to generate metafile" warning. Before,
`unwrap_or_default()` left an empty fragment and the JSON was not valid.
The only fallible call is a write to a `Vec`.

Not changed here, tracked in #42758:

- `.map`, `.jsc` and copied asset files have no `outputs` entry. This is
a different defect (a missing key, not a wrong value) and it adds keys
that users see.
- `inputs[..].bytesInOutput` counts a placeholder at its own length. A
fix needs a count per compile result after the substitution, as in
esbuild's `accurateFinalByteCount`.
- A standalone HTML build lists the inlined chunks as outputs.

Self-reviewed: 5 concerns raised, 5 addressed. A test for the standalone
HTML assignment. The `unsafe` that this change made needless. Stricter
test assertions (`statSync`, exact key lists, one combined CLI
assertion). The `debugId` wording in the commit message. A tracker for
the gaps above.

Open PRs that touch `MetafileBuilder.rs` but not this field: #38341,
#38624, #36974, #38666. None reads state that is gone when `generate`
runs. #38666 has a small text conflict in `Chunk.rs` where this PR
deletes `get_size()`.

</details>

<!-- robobun:evidence:begin -->

---

**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/metafile.test.ts

<!-- robobun:evidence:end -->
`inputs[..].bytes` is 0 for each input that the build copies to an asset:
`file`, wasm, napi and embedded sqlite since 1.4.0, text under `--compile`
since 1.4.1 (#40177). Bun 1.3.14 wrote the size.

`process_files_to_copy` empties `source.contents` of such an input when it
makes the asset file: it copies the bytes that were read from disk and moves
the bytes that a plugin returned. The metafile read `source.contents.len()`
after that.

`LinkerContext::link` now reads the size of each asset file, and only when
the build asks for a metafile. It reads before the outputs are emitted: a
build to disk or in memory then takes the asset files out of the graph, and
a standalone HTML build emits none of them.

test/aot/cli/metafile.test.ts had an exemption for this ("The bundler says
0 for a file that it loads as text", #45631). It is gone, so that test
compares `bytes` of each input.
@robobun
robobun force-pushed the farm/890ff4f2/metafile-css-entry-and-asset-bytes branch from f659493 to 25674b9 Compare October 30, 2026 08:14
@robobun robobun changed the title bundler: list CSS entry points in the metafile and report copied asset sizes bundler: read the metafile size of a copied input from its asset file Oct 30, 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.

Beyond the inline findings, I also checked the new size snapshot in LinkerContext::link: asset.source_index.unwrap() is the IndexOptional -> Option conversion (bun_core/util.rs), not a panicking unwrap, and every index written into copied_input_sizes comes from reachable_files, so it is within input_files.len() after the resize. All three build paths (CLI, JS API, aot page bundles) reach metafile_builder::generate through link, so the snapshot is taken before generate_chunks_in_parallel drains the assets in each of them; the hoisted destinations/outdirFor test helpers are byte-identical to the removed ones.

Extended reasoning...

The new commit adds a per-source-index size vector to LinkerContext filled right after load() when the metafile is enabled, and MetafileBuilder reads it in place of the emptied source.contents; no security-sensitive surface is touched. The two inline findings are a pre-existing duplicate-key issue and a test that pins it, so the fix itself looks correct, but the inline comments already signal a human look.

Comment thread test/bundler/metafile.test.ts Resolved

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

LGTM — the latest push addresses the one nit from my earlier review (the two-graphs test now asserts every image.png record is 13 rather than pinning exactly two records), and I found nothing else.

What was reviewed:

  • asset.source_index.unwrap() in link is GenericIndexOptional::unwrap, which returns Option (no panic), and the index comes from reachable_files in process_files_to_copy, so sizes[..] is within input_files.len().
  • All four link call sites run after process_files_to_copy, and OutputFile::init sets size from contents_len taken before mem::take, so the recorded size is the pre-emptied length; the debug_assert in generate compiles out and the release fallback is the old source.contents.len().
  • Tests cover disk and in-memory for file/wasm/napi/sqlite, an onLoad-returned buffer whose size differs from disk, standalone HTML with no emitted assets, and the AOT text-import guard removal.
Extended reasoning...

The change adds a metafile-gated Vec<Option<usize>> of asset sizes to LinkerContext, filled once in link from parse_graph().additional_output_files, and reads it in MetafileBuilder::generate with a fallback to the existing source.contents.len(); about 30 Rust lines plus tests in test/bundler/metafile.test.ts and test/aot/cli/metafile.test.ts. It touches no security-sensitive surface (no user-path handling, no auth, no network). The bug hunt ran dry, no CODEOWNERS entry covers src/bundler/, no third-party CHANGES_REQUESTED review is present, and the only prior inline nit from this bot was addressed in commit 999b571, which decided approve.

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