Skip to content

bundler: chain inline input sourcemaps through to output - #30539

Open
robobun wants to merge 31 commits into
mainfrom
farm/eb1afa62/chain-input-sourcemaps
Open

robobun wants to merge 31 commits into
mainfrom
farm/eb1afa62/chain-input-sourcemaps

Conversation

@robobun

@robobun robobun commented May 12, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #30536.
Also fixes #6173 — plugin onLoad returns with an inline sourcemap are fed through the same scanner, so the plugin case rides on the same pipeline.

Summary

When Bun.build({ sourcemap: 'inline' }) bundles an input .js file that carries an inline //# sourceMappingURL=data:application/json;base64,… comment (e.g. .vue/.svelte compilers emitting an intermediate .js with a chain back to the authored .ts/.vue), the final output's sourcemap chain now resolves through to the authored source. Before this change the deepest sources[] entry was the intermediate .js; after, it's the authored source, and sourcesContent[] holds the clean authored bytes without the trailing comment. Same fix covers onLoad plugin returns (#6173) because the scanner runs on source.contents regardless of where they came from.

Repro

// inner.ts — the "original" source
export const x = 5;

// Produce inner.js with an inline sourcemap → inner.ts
writeFileSync('inner.js', src + `\n//# sourceMappingURL=data:application/json;base64,${base64Map}\n`);

const result = await Bun.build({
  entrypoints: ['entry.ts'],
  outdir: 'out',
  sourcemap: 'inline',
});
// Decode out/entry.js's inline sourcemap.

Before: sources = ["../inner.js", "../entry.ts"]. sourcesContent[0] is the raw inner.js bytes including the literal //# sourceMappingURL=… comment. Chain stops at the intermediate.

After: sources = ["../inner.js", "../inner.ts", "../entry.ts"]. sourcesContent[1] is the clean inner.ts, no embedded comment. Mappings that live in the intermediate resolve through to the authored line/column.

Cause

The lexer already detects //# sourceMappingURL= but nothing reads it back — the bundler's linker emits the output map directly from source.contents + the intermediate's path, discarding any chain.

Fix

Thread the inline map end-to-end:

  • src/sourcemap/InputSourceMap.rs (new, ~220 lines): owns the parsed inner ParsedSourceMap + per-source contents. parse_from_source scans for a trailing //# sourceMappingURL=data:... anchored on the last line (Source Map spec — in-body markers in template literals must not hijack the lookup). Supports both ;base64, and raw data:application/json,... payloads. Malformed input → None (silent fallback); OOM propagates via handle_oom.
  • src/bundler/ParseTask.rs: after parsing JS, scan the source bytes. Gated on source_map != .None && loader.can_have_source_map() && !source.contents.is_empty() so no-sourcemap builds pay nothing; external .map references are out of scope. The scan runs on source.contents regardless of where the contents came from (file read or plugin onLoad return), so this also covers the plugin case from Support sourcemaps in onLoad plugins #6173.
  • src/bundler/Graph.rs, bundle_v2.rs: new InputFile.input_source_map: Option<Box<InputSourceMap>> column; ownership moves from the parse result onto the file and drains in deinit_without_freeing_arena (MultiArrayList's Drop is slab-only).
  • src/sourcemap/Chunk.rs + src/js_printer/lib.rs: Builder + Options grow an optional input_source_map. In add_source_mapping, the (line, col) we'd have emitted against the intermediate is translated via map.find_mapping:
    • hit → source_index = 1 + inner.source_index, inner (line, col)
    • miss → slot 0 (the intermediate) so unmapped tokens land in a real file
  • src/bundler/LinkerContext.rs: each outer source in sources[] expands to [intermediate, inner_0 … inner_N-1] and sourcesContent[] matches slot-for-slot. Chunk stitching uses base + chunk.end_state.source_index as the absolute end so per-chunk mappings whose source_index varies across their length compose correctly. Gated on self.dev_server.is_none() because Bake's SourceMapStore::join_vlq stitcher hard-codes one sources[] slot per file and would corrupt on expansion.

Malformed or unrecognized inline maps fall back cleanly — the build still succeeds with the pre-fix behavior (tested).

Verification

bun bd test test/bundler/bun-build-api.test.ts → 53 pass / 0 fail (8 new + 45 existing). 8 new cases under describe("Bun.build chains inline input sourcemaps", …):

  • inline base64 data: URL — authored source surfaces in bundled map
  • inline raw data:application/json,… URL — authored source surfaces
  • inline map with multiple inner sources (e.g. .vue ?script + ?template) — all surface
  • malformed inline payload — build succeeds, silently falls back
  • inline VLQ source_index >= sources.len — rejected, no slot aliasing
  • //# sourceMappingURL= marker inside a template literal (but no trailing comment) — ignored, no hijack
  • external .map filename reference (non-data:) — unchanged behavior
  • plugin onLoad return carrying inline map — regression guard for Support sourcemaps in onLoad plugins #6173

Also ran the full bun-build-api.test.ts and adjacent bundler suites — no regressions.

Build script fix

scripts/build/bun.ts gets a small fix unrelated to the bundler but triggered by this PR's larger release-mode build: added strippedExe as an orderOnlyInputs ninja dep for the release smoke-test rule, so the test doesn't race the strip bun step. Without this, the gate hit an intermittent Permission denied on build/release/bun during the smoke test.

Rebase notes (latest)

Rebased across ~1250 commits of main. Three substantive adaptations:

  • The build-script smoke-test ordering fix this PR carried is gone: main adopted it upstream (emitPostLink centralizes the strip-before-smoke-test invariant, crediting bundler: chain inline input sourcemaps through to output #30539), so that commit was dropped and the PR no longer touches scripts/build/bun.ts.
  • The unsafe lifetime erasure in js_printer is gone: main parameterized Chunk::Builder over a lifetime, so input_source_map is now a plain Option<&'a InputSourceMap> borrow with no transmute.
  • Main replaced the JSON parser with a tape-based reader (EObjectJSON/EArrayJSON rows) and made mapping::parse return a Result; InputSourceMap::parse_internal now reads version/mappings/sources/sourcesContent through the tape accessors (ObjectJSON::get, JsonValue::as_str/as_array). Without this the container lookups silently failed and no chaining occurred.

Verified after the rebase: all 9 chain tests pass, and the 9 sourcemap tests in bun-build-api.test.ts (including main's new C0-control-chars and non-ASCII-columns cases) pass.


[review] gate passed · iteration 32 · 10 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bun-build-inline-sourcemap-chain.test.ts
bun test v1.4.1 (4448a2e21)

test/bundler/bun-build-inline-sourcemap-chain.test.ts:
48 |     expect(m).not.toBeNull();
49 |     const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8"));
50 | 
51 |     // The authored source name must appear somewhere in `sources[]`.
52 |     const sourcesJoined = parsed.sources.join("|");
53 |     expect(sourcesJoined).toMatch(/authored\.ts/);
                               ^
error: expect(received).toMatch(expected)

Expected substring or pattern: /authored\.ts/
Received: "../intermediate.js|../entry.ts"

      at <anonymous> (/workspace/bun/test/bundler/bun-build-inline-sourcemap-chain.test.ts:53:27)
(fail) Bun.build chains inline input sourcemaps > inline data: URL — authored source surfaces in bundled map [132.13ms]
 98 | 
 99 |     const text = await Bun.file(result.outputs[0].path).text();
100 |     const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/);
101 |     expect(m).not.
... (truncated)

release without fix: all passed
bun test v1.4.1-canary.1 (1daf82ff3)

test/bundler/bun-build-inline-sourcemap-chain.test.ts:
(pass) Bun.build chains inline input sourcemaps > inline data: URL without base64 — authored source surfaces [6.27ms]
(pass) Bun.build chains inline input sourcemaps > inline map with multiple inner sources — all surface [5.93ms]
(pass) Bun.build chains inline input sourcemaps > inline data: URL — authored source surfaces in bundled map [7.28ms]
(pass) Bun.build chains inline input sourcemaps > URL-schemed inner source name passes through verbatim [5.67ms]
(pass) Bun.build chains inline input sourcemaps > malformed inline map — build succeeds and falls back [6.32ms]
(pass) Bun.build chains inline input sourcemaps > inline map with out-of-range inner source_index is rejected [6.80ms]
(pass) Bun.build chains inline input sourcemaps > sourceMappingURL marker in body is ignored (only trailing line counts) [6.18ms]
(pass) Bun.build chains inline input sourcemaps > external .map reference — unchanged behavior [6.84ms]
(pass) Bun.build chains inline input sourcemaps > onLoad plugin returning JS with inline sourcemap — authored source surfaces [7.78ms]
(pass) Bun.build c
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bun-build-inline-sourcemap-chain.test.ts
bun test v1.4.1 (4448a2e21)

test/bundler/bun-build-inline-sourcemap-chain.test.ts:
(pass) Bun.build chains inline input sourcemaps > inline data: URL — authored source surfaces in bundled map [121.13ms]
(pass) Bun.build chains inline input sourcemaps > inline data: URL without base64 — authored source surfaces [112.12ms]
(pass) Bun.build chains inline input sourcemaps > inline map with multiple inner sources — all surface [140.37ms]
(pass) Bun.build chains inline input sourcemaps > URL-schemed inner source name passes through verbatim [149.83ms]
(pass) Bun.build chains inline input sourcemaps > malformed inline map — build succeeds and falls back [171.14ms]
(pass) Bun.build chains inline input sourcemaps > inline map with out-of-range inner source_index is rejected [173.52ms]
(pass) Bun.build chains inline input sourcemaps > sourceMappingURL marker in body is ignored (only trailing line counts) [119.26ms]
(pass) Bun.build chains inline input sourcemaps > external .map refer
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 630ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] cargo bun_runtime → libbun_runtime.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compiling�[0m bu
... (truncated)
diff hotspot
src/bundler/Graph.rs                               |   6 +
 src/bundler/LinkerContext.rs                       | 235 ++++++---
 src/bundler/ParseTask.rs                           |  26 +
 src/bundler/ServerComponentParseTask.rs            |   2 +
 src/bundler/bundle_v2.rs                           |  15 +
 src/js_printer/lib.rs                              |   7 +
 src/sourcemap/Chunk.rs                             |  48 +-
 src/sourcemap/InputSourceMap.rs                    | 244 ++++++++++
 src/sourcemap/lib.rs                               |   4 +
 .../bun-build-inline-sourcemap-chain.test.ts       | 530 +++++++++++++++++++++
 10 files changed, 1046 insertions(+), 71 deletions(-)

gate history · 3 passed · 1 rejected · iteration 32

evidence per changed file
file                                                   reads  edits  tests
src/bundler/Graph.rs                                       5      4     17
src/bundler/LinkerContext.rs                              18     18     17
src/bundler/ParseTask.rs                                  12     11     17
src/bundler/ServerComponentParseTask.rs                    1      1     15
src/bundler/bundle_v2.rs                                   6      4     16
src/js_printer/lib.rs                                      9     12     17
src/sourcemap/Chunk.rs                                    11      9     17
src/sourcemap/InputSourceMap.rs                           19     33     17
src/sourcemap/lib.rs                                       9      1     15
test/bundler/bun-build-inline-sourcemap-chain.test.ts      3      8     13

root cause · written by the author bot

The root cause was that the bundler read input files as raw bytes and never consumed an inline //# sourceMappingURL= comment, so the output map's sources chain stopped at the intermediate file and the comment itself leaked into sourcesContent as literal text. The fix parses the trailing inline data URL into an owned InputSourceMap stored on each input file, threads it through the printer and sourcemap chunk builder so mappings are remapped through the inner map's original coordinates, and has the linker expand the emitted sources and sourcesContent to include the authored inner sources. The…

@robobun

robobun commented May 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:20 AM PT - Aug 23rd, 2026

✅ @robobun, your commit a0c5ca3a78a1168abf046534abb8aa0975629ea6 passed in Build #104123! 🎉


🧪   To try this PR locally:

bunx bun-pr 30539

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

bun-30539 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Support sourcemaps in onLoad plugins #6173 - This PR chains inline //# sourceMappingURL=data:... comments through to the output sourcemap, which is exactly what onLoad plugins need when they return transformed JS with an embedded inline sourcemap.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #6173

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented May 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Parses trailing inline data:application/json //# sourceMappingURL= maps, stores owned InputSourceMap on inputs, threads them into the JS printer and SourceMap chunk builder, remaps mappings through inner maps, and expands emitted sources/sourcesContent so authored inner sources appear in final bundled maps.

Changes

Inline sourcemap chaining

Layer / File(s) Summary
InputSourceMap parser module
src/sourcemap/InputSourceMap.zig, src/sourcemap/sourcemap.zig
New InputSourceMap type owns a parsed sourcemap with sources, sourcesContent, and parsed mappings; parse(json_bytes) validates v3.0 structure and allocates owned copies; parseFromSource(source) extracts and decodes trailing inline data:application/json URLs with base64 and raw payload support.
Parse-phase detection and storage
src/bundler/Graph.zig, src/bundler/ParseTask.zig
Graph.InputFile and ParseTask.Result.Success gain optional input_source_map fields; parser conditionally calls InputSourceMap.parseFromSource when sourcemaps are enabled, the loader may supply maps, and input contents are non-empty; dev-server HMR error paths deinit transient maps.
Bundler ownership and lifecycle
src/bundler/bundle_v2.zig
On parse success result.input_source_map is moved into graph.input_files.items(.input_source_map) (deinit any prior map); on parse failure or overwrite transient maps are deinitialized; deinitWithoutFreeingArena deinitializes stored input maps before tearing down AST and input_files.
Sourcemap builder remapping
src/js_printer/js_printer.zig, src/sourcemap/Chunk.zig
js_printer.Options and SourceMap.Chunk.Builder gain input_source_map; addSourceMapping optionally looks up intermediate (line,column) in the inner map and emits source_index = 1 + inner_index plus the inner map's zero-based original coordinates when a covering mapping exists, otherwise falls back to the intermediate slot-0 coordinates.
Linker emission expansion
src/bundler/LinkerContext.zig
generateSourceMapForChunk expands sources/sourcesContent per input: emit the intermediate input first then any inner external_source_names and their sourcesContent (emit null for missing content); update mapping source-index stitching across appended chunks to include inner-source offsets; pass input_source_map into printer except for DevServer.
Test coverage
test/bundler/bun-build-api.test.ts
Adds a suite validating base64/raw inline maps, multiple inner sources, malformed inline payload graceful fallback, out-of-range inner indexes rejection, trailing-line-only detection (ignore in-body markers), non-chaining of external .map references, and a plugin regression test for onLoad-returned inline maps.
Build smoke-test ordering
scripts/build/bun.ts
Passes strippedExe to smoke-test rules and marks it as an order-only input so the smoke test runs after strip output is produced.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Out of Scope Changes check ❓ Inconclusive All changes are tightly scoped to inline sourcemap chaining. The build script changes (scripts/build/bun.ts) only add smoke-test ordering dependencies unrelated to this feature but appear to be a pre-existing CI infrastructure fix. One unrelated change present: scripts/build/bun.ts smoke test serialization. Clarify whether scripts/build/bun.ts smoke-test ordering changes are intentional or should be separated into a different PR for review clarity.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR fully addresses both linked issues: #30536 (detect inline sourceMappingURL comments in file inputs and compose chains through to output) and #6173 (support sourcemaps from plugin onLoad returns). All core requirements are implemented in the Zig reference implementation with comprehensive tests.
Title check ✅ Passed The title clearly and concisely describes the main change: chaining inline input sourcemaps through the bundler output.
Description check ✅ Passed The description explains the change, affected behavior, implementation, linked issues, and verification results in substantial detail.

Comment @coderabbitai help to get the list of available commands.

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bundler/bundle_v2.zig`:
- Around line 3675-3677: The assignment to
graph.input_files.items(.input_source_map)[result.source.index.get()] =
result.input_source_map leaks the previous ParsedSourceMap and can leak the
freshly parsed map on early failure; before overwriting the graph slot,
deinit/free the existing map (if any) stored in
graph.input_files.items(.input_source_map)[...], then move
result.input_source_map into that slot, and ensure you null out or mark
result.input_source_map as moved; additionally add matching cleanup in the error
path around runResolutionForParseTask() so that if result transitions from
.success to .err the newly allocated ParsedSourceMap and its sourcesContent are
deallocated (use defer or explicit deinit in the same scope where
result.input_source_map is allocated) to avoid leaks.

In `@src/bundler/ParseTask.zig`:
- Around line 1320-1325: The inline source-map parsing currently runs for
plugin/virtual sources too; update the conditional that computes
input_source_map (the if using transpiler.options.source_map,
loader.canHaveSourceMap(), and source.contents) to also require that the input
is file-backed by checking source.path.isFile() (or that source.path.namespace
== "file") so only file-backed inputs attempt
bun.SourceMap.InputSourceMap.parseFromSource; leave the other checks
(transpiler.options.source_map and loader.canHaveSourceMap and non-empty
source.contents) intact.

In `@src/sourcemap/InputSourceMap.zig`:
- Around line 37-145: The parse function currently treats allocator failures
(e.g., at allocator.alloc, allocator.dupe, and similar catch sites such as
source_paths_slice, sources_content_slice, and the dupes inside the loops) as
generic parse failures by using `catch return null`; update each such `catch` to
inspect the error and call `bun.handleOom()` for `error.OutOfMemory` and
otherwise `return null` — e.g. replace `... catch return null` with `... catch
|err| if (err == error.OutOfMemory) bun.handleOom() else return null` at every
allocation/dupe site in InputSourceMap.parse (including the other occurrences
around lines ~203-209) so OOMs trigger Bun’s fatal path while preserving null
for real decode/validation failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3d64f326-1bc4-4bfb-98df-db68570a30e3

📥 Commits

Reviewing files that changed from the base of the PR and between 2043f9c and a7ffbe3.

📒 Files selected for processing (9)
  • src/bundler/Graph.zig
  • src/bundler/LinkerContext.zig
  • src/bundler/ParseTask.zig
  • src/bundler/bundle_v2.zig
  • src/js_printer/js_printer.zig
  • src/sourcemap/Chunk.zig
  • src/sourcemap/InputSourceMap.zig
  • src/sourcemap/sourcemap.zig
  • test/bundler/bun-build-api.test.ts

Comment thread src/bundler/bundle_v2.zig Outdated
Comment thread src/bundler/ParseTask.zig Outdated
Comment thread src/sourcemap/InputSourceMap.zig Outdated
Comment thread src/sourcemap/InputSourceMap.zig Outdated

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/bundler/bun-build-api.test.ts`:
- Around line 1297-1299: The test decodes base64 sourcemap payloads using m![1]
without checking that the regex match succeeded; add an explicit guard before
decoding at each occurrence (the variable m in the bun-build-api.test cases) —
e.g., assert expect(m).toBeTruthy() or if (!m) fail with a clear message, then
use m[1] to Base64-decode and JSON.parse; apply the same fix to all four
locations where m![1] is used (the matches at lines matching the
sourceMappingURL extraction).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0d06802d-137b-46ff-afe3-f7d3c764486e

📥 Commits

Reviewing files that changed from the base of the PR and between a7ffbe3 and 192b355.

📒 Files selected for processing (1)
  • test/bundler/bun-build-api.test.ts

Comment thread test/bundler/bun-build-api.test.ts Outdated

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/sourcemap/InputSourceMap.zig`:
- Around line 197-212: The current findSourceMappingURL searches the whole file
for "\n//# sourceMappingURL=" which can miss a trailing inline comment or match
markers inside later content; change it to first trim trailing whitespace from
source, compute the start of the final line (use std.mem.lastIndexOfScalar to
find the last '\n' or 0), then only search for the needle inside that final-line
slice (e.g. search source[lineStart..] for "//# sourceMappingURL="). If found,
compute start/end relative to the original buffer and return the trimmed URL as
before; otherwise return null. Ensure you update uses of needle, found, start,
end and keep using bun.strings.trim for trimming.
- Around line 79-81: The parser currently treats the "version" field as
optional; in InputSourceMap.zig change the logic that handles
json.get("version") so that absence returns error.InvalidSourceMap and presence
is still validated (i.e., ensure version.data is .e_number and
version.data.e_number.value == 3.0); locate the block referencing
json.get("version") and replace the optional branch with an explicit existence
check that errors when missing and otherwise performs the same type/value
validation.

In `@test/bundler/bun-build-api.test.ts`:
- Around line 1357-1361: The test currently only asserts an inline source map
exists by checking text for "sourceMappingURL=data:...base64"; update the
assertion to decode and validate the actual fallback source used in the
malformed-map case: extract the base64 payload from the data URL in the variable
text (from Bun.file(result.outputs[0].path).text()), base64-decode and
JSON.parse it, then assert that the parsed source map's sources or
sourcesContent includes the expected intermediate/fallback source (e.g., the
intermediate filename or its source text) so the mapping truly targets the
documented fallback source instead of just existing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 292ecd2b-9daf-426c-9bc5-9fd0149be05d

📥 Commits

Reviewing files that changed from the base of the PR and between 192b355 and af3f78d.

📒 Files selected for processing (3)
  • src/bundler/bundle_v2.zig
  • src/sourcemap/InputSourceMap.zig
  • test/bundler/bun-build-api.test.ts

Comment thread src/sourcemap/InputSourceMap.zig Outdated
Comment thread src/sourcemap/InputSourceMap.zig Outdated
Comment thread test/bundler/bun-build-api.test.ts Outdated
Comment thread src/sourcemap/InputSourceMap.zig Outdated
Comment thread src/bundler/ParseTask.zig Outdated
Comment thread src/bundler/LinkerContext.zig Outdated

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/sourcemap/InputSourceMap.zig (1)

214-219: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Match trailing inline sourcemaps after code on the final line.

This only accepts a comment-only last line. Valid one-line outputs like code;//# sourceMappingURL=data:... still return null, so chaining is skipped for minified/plugin-generated intermediates.

Suggested fix
-    const needle = "//# sourceMappingURL=";
-    if (!bun.strings.hasPrefixComptime(last_line, needle)) return null;
-    return bun.strings.trim(last_line[needle.len..], " \r\t");
+    const needle = "//# sourceMappingURL=";
+    const found = std.mem.lastIndexOf(u8, last_line, needle) orelse return null;
+    return bun.strings.trim(last_line[found + needle.len ..], " \r\t");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sourcemap/InputSourceMap.zig` around lines 214 - 219, The current logic
in InputSourceMap.zig only accepts a sourceMappingURL if the final line starts
with the needle, so cases like "code;//# sourceMappingURL=..." are missed;
update the check to search for needle anywhere in last_line (use a
substring/index search instead of hasPrefixComptime) and, when found, slice
last_line at the found index (needle.len after the index) and then trim that
substring before returning; reference the existing variables last_line_start,
last_line, and needle to locate and replace the prefix-only hasPrefixComptime
logic with an index-based search (e.g., std.mem.indexOf or similar) and handle
the not-found case by returning null.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bundler/LinkerContext.zig`:
- Around line 744-755: The code currently only sets path.pretty for file-backed
inputs (in the branch checking path.isFile()) but always serializes path.pretty,
which loses non-file/virtual/plugin module names; update the serialization so
that when path.isFile() is false you use outer_path.text (or set path.pretty =
outer_path.text beforehand) before calling js_printer.quoteForJSON and pushing
to joiner (symbols: outer_path, path, path.isFile(), path.pretty,
js_printer.quoteForJSON, joiner, MutableString.init, leading_comma) so
plugin/virtual module source names are preserved in emitted sources[].

In `@test/bundler/bun-build-api.test.ts`:
- Around line 1250-1253: The test currently grabs the first sourceMappingURL
match from the file (via text.match and m) which can pick up in-body markers;
change the extraction to target only the final inline sourcemap line by either
using a regex anchored to the end of the string (e.g. match the sourceMappingURL
line followed by optional whitespace to string end with the /m flag) or by
finding all matches and using the last one before base64 decoding; update the
occurrences that use Bun.file(...).text(), text.match(...), m and parsed at the
noted spots (including the other listed locations) to use this trailing-line
approach so the parsed JSON always comes from the final inline sourcemap.

---

Duplicate comments:
In `@src/sourcemap/InputSourceMap.zig`:
- Around line 214-219: The current logic in InputSourceMap.zig only accepts a
sourceMappingURL if the final line starts with the needle, so cases like
"code;//# sourceMappingURL=..." are missed; update the check to search for
needle anywhere in last_line (use a substring/index search instead of
hasPrefixComptime) and, when found, slice last_line at the found index
(needle.len after the index) and then trim that substring before returning;
reference the existing variables last_line_start, last_line, and needle to
locate and replace the prefix-only hasPrefixComptime logic with an index-based
search (e.g., std.mem.indexOf or similar) and handle the not-found case by
returning null.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7927bb9d-82d1-4fb6-a98a-185ceb0904d3

📥 Commits

Reviewing files that changed from the base of the PR and between af3f78d and b93356c.

📒 Files selected for processing (4)
  • src/bundler/LinkerContext.zig
  • src/bundler/ParseTask.zig
  • src/sourcemap/InputSourceMap.zig
  • test/bundler/bun-build-api.test.ts

Comment thread src/bundler/LinkerContext.zig Outdated
Comment thread test/bundler/bun-build-api.test.ts Outdated
Comment thread src/sourcemap/InputSourceMap.zig Outdated
Comment thread src/sourcemap/InputSourceMap.zig Outdated
Comment thread src/sourcemap/InputSourceMap.zig Outdated
@robobun
robobun force-pushed the farm/eb1afa62/chain-input-sourcemaps branch from c4223fc to 03f82da Compare May 16, 2026 01:21
Comment thread test/bundler/bun-build-api.test.ts Outdated
Comment thread test/bundler/bun-build-api.test.ts Outdated
Comment thread src/sourcemap/InputSourceMap.rs Outdated
@robobun
robobun force-pushed the farm/eb1afa62/chain-input-sourcemaps branch from 06c646d to 528da29 Compare May 19, 2026 09:50

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

No new issues found, but this is a substantial bundler feature (~1k lines across parse/link/print + a new sourcemap parser handling untrusted input) with non-trivial slot-index arithmetic and ownership threading, plus an unrelated build-script change — worth a human pass before merge, and CI is still red on aarch64-musl build-rust.

Extended reasoning...

Overview

This PR threads inline //# sourceMappingURL=data:... input sourcemaps through Bun.build's output map. It adds a new parser module (InputSourceMap.{zig,rs}, ~270 lines each), a new SoA column on Graph::InputFile, ownership transfer in ParseTask/bundle_v2, per-mapping remapping in Chunk::Builder, and per-source slot expansion + chunk-stitching index arithmetic in LinkerContext::generate_source_map_for_chunk. 8 new tests in bun-build-api.test.ts. There's also an unrelated scripts/build/bun.ts change adding an order-only ninja dep to serialize strip against the smoke test.

Security risks

The new parser ingests untrusted bytes (any data: URL embedded in any bundled .js, including plugin onLoad returns). Failure modes are designed to fall back to None (silent no-chain), and earlier review rounds caught and fixed the source-index-aliasing case (out-of-range VLQ indices now hit .fail via the real sources_count). I don't see injection or auth surface, but the slot-expansion arithmetic in LinkerContext (base + chunk.end_state.source_index) and the dev-server gate are exactly the kind of subtle index bookkeeping that benefits from a second pair of eyes.

Level of scrutiny

High. This is production-critical bundler output (sourcemaps affect every debugging/error-reporting workflow), the change spans 19 files in two languages, and it carries several design decisions a maintainer should sign off on: keeping the intermediate as slot 0 alongside inner sources (vs. replacing it), gating the entire feature off for the Bake DevServer path, and deferring sourceRoot / external-.map / plugin-namespace path normalization to follow-ups.

Other factors

The PR went through several automated review rounds; all prior inline findings (dead errdefers, leak on Success→err overwrite, dev-server corruption, ban-words, port-fidelity trim) are marked resolved. The Rust port landed in 30171d1 and the tests are now active (not .todo). However, the most recent CI status (Build #56048, commit 528da29) still shows build-rust failing on aarch64-musl, and the scripts/build/bun.ts change is acknowledged as out-of-scope for the headline feature.

@robobun
robobun force-pushed the farm/eb1afa62/chain-input-sourcemaps branch from c74b504 to e21f069 Compare May 20, 2026 05:44
Comment thread src/bundler/Graph.rs
Comment thread src/sourcemap/InputSourceMap.rs Outdated
Comment thread src/bundler/ParseTask.rs Outdated

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

No issues found on this pass, but this is a substantial feature touching the bundler's sourcemap stitching across both the Zig reference and the Rust port (new InputSourceMap module, per-chunk source_index offset arithmetic in LinkerContext/Chunk, ownership threading through Graph/ParseTask/bundle_v2, plus an unsafe 'static lifetime erasure in get_source_map_builder) — worth a human pass on the index-stitching math and the Rust lifecycle before merge.

Extended reasoning...

Overview

This PR adds inline-input-sourcemap chaining to Bun.build: a new ~270-line InputSourceMap module (both .zig reference and .rs port) plus threading through Graph/ParseTask/bundle_v2/LinkerContext/Chunk/js_printer so that a trailing //# sourceMappingURL=data:... on an input file expands the output map's sources[]/sourcesContent[] and remaps VLQ mappings through the inner map. It also bundles an unrelated scripts/build/bun.ts ninja order-only-dep fix and 8 new tests in bun-build-api.test.ts.

Security risks

Low. The new parser ingests untrusted inline sourcemap payloads from input files / plugin returns, but malformed input falls through to None/null (silent fallback), OOM goes through handle_oom/bun.outOfMemory, and the out-of-range source_index aliasing concern was fixed by passing the real sources_count to Mapping.parse. No auth, crypto, network, or filesystem-write surface.

Level of scrutiny

High. This is core bundler output-correctness code: the per-chunk source_index expansion and the prev_end_state.source_index = mapping_source_index + chunk.end_state.source_index stitching in generateSourceMapForChunk are subtle — an off-by-one silently corrupts sourcemaps for every downstream user. The Rust port also includes an unsafe { &*(r as *const _) } lifetime erasure to 'static in get_source_map_builder, manual MultiArrayList column draining in deinit_without_freeing_arena, and a Stacked-Borrows-sensitive hoist in ParseTask.rs. The PR went through ~15 inline fixes over the week (dead errdefer, leak paths, DevServer gating, ban-words, E0063, UB), which speaks to its complexity.

Other factors

All prior inline comments (mine and CodeRabbit's) are resolved. Test coverage is good (8 targeted cases including malformed/edge inputs). However, several design decisions were explicitly deferred to follow-ups (DevServer slot expansion, sourceRoot, version strictness, external .map resolution, path.pretty for non-file namespaces), and the unrelated build-script change is bundled in. Given the cross-cutting scope and the index-arithmetic subtlety, a human reviewer should sign off on the stitching math and the Rust ownership/lifetime story.

@robobun
robobun force-pushed the farm/eb1afa62/chain-input-sourcemaps branch from 8e56af7 to f0a63f6 Compare June 2, 2026 01:43
Comment thread src/sourcemap/Chunk.rs

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

No new issues found, but this is a substantial feature touching bundler-core sourcemap stitching (slot-expansion index arithmetic in LinkerContext, per-token remapping in Chunk::Builder) plus an unsafe lifetime transmute in js_printer — worth a human pass before merge.

Extended reasoning...

Overview

This PR threads inline //# sourceMappingURL=data:... comments on bundler input files through to the output sourcemap, so authored sources (.vue/.svelte/.ts) surface in the final map instead of the intermediate .js. It adds a new ~270-line InputSourceMap module (both .rs and reference .zig), plumbs an input_source_map column through Graph::InputFile / ParseTask::Success / bundle_v2, adds remapping logic to Chunk::Builder::add_source_mapping, rewrites the sources[]/sourcesContent[] emission and chunk-stitching arithmetic in LinkerContext::generate_source_map_for_chunk, and adds a new input_source_map field to js_printer::Options with an unsafe transmute to erase its lifetime to 'static. It also carries an unrelated scripts/build/bun.ts fix (order-only ninja dep so the smoke test doesn't race strip). 19 files changed; 8 new tests in bun-build-api.test.ts.

Security risks

None apparent. The new code parses untrusted inline sourcemap JSON/base64 from input files, but malformed input falls back to None (no chain) rather than erroring; out-of-range VLQ source indices are bounded by sources_count so they can't alias neighboring slots; the URL scanner is anchored to the last line so in-body markers can't hijack. No filesystem reads of external .map files (explicitly out of scope). The unsafe transmute extends a borrow lifetime, not raw memory access.

Level of scrutiny

High. This is production bundler-core code that affects every Bun.build with sourcemap != none. The slot-expansion arithmetic (base + chunk.end_state.source_index, 1 + inner.source_index, expansion counts) is delicate — the review history shows it was wrong in multiple subtle ways (DevServer corruption, out-of-range aliasing, hint-coordinate-space mismatch) before being fixed. The unsafe lifetime erasure in js_printer/lib.rs:7995-8013 deserves a human eye on whether the SAFETY comment's invariant (graph slot outlives every add_source_mapping call) actually holds across all printer entry points. The DevServer gate (self.dev_server.is_none()) is a deliberate feature scope-down that a maintainer should sign off on.

Other factors

The PR went through ~10 rounds of bug fixes during review (dead-errdefer leaks, stacked-borrows UB, E0063 build break, ban-words violation, slot-overwrite leaks, DevServer corruption gate, hint-poisoning perf regression) — all now resolved, which speaks well of the final state but also confirms this is not mechanical code. All prior inline comments (mine and CodeRabbit's) are resolved. The unrelated scripts/build/bun.ts change is small and well-commented but bundling it here is a scope question for the maintainer. Test coverage is good (8 targeted cases including malformed/edge inputs).

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 3e0f8bb8 is green on every lane that exercises this diff — cargo clippy ✅, debian-13-x64-asan-test-bun ✅ (the 8 chain tests run under ASAN here), all four Windows test lanes ✅. The only red checks are three darwin *-test-bun lanes, all marked Expired (Buildkite agent reclaimed the job before it finished) — infra flake unrelated to the change. I've already spent my one retrigger earlier in this branch, so I'm not re-rolling again. Ready for a human merge pass.

@robobun

robobun commented Jun 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status after the rebase (head 8020783, build #61103): every test lane fails on exactly one test, test/cli/install/bunx.test.ts > should handle package that requires node 24, which runs bunx --bun @angular/cli@latest --help against the live npm registry and now gets exit 3. This is fleet-wide, not PR-specific: every recent Buildkite build on every branch (e.g. #61109, #61110, #61111, #61114) fails on the same file, so a registry-side @angular/cli release broke the test everywhere. That file is untouched by this PR, and this PR's own tests pass on their shards. I already used my one retrigger on this branch; re-running won't help until the bunx test is fixed on main. The diff itself is green: full bun-build-api.test.ts is 54 pass / 0 fail locally on this head.

@robobun
robobun force-pushed the farm/eb1afa62/chain-input-sourcemaps branch from 3e0f8bb to 8020783 Compare June 6, 2026 09:46
robobun and others added 16 commits August 23, 2026 10:25
…ows UB

topts is a shared borrow through a raw pointer into *transpiler. get_ast
reborrows the same location mutably (also through the raw pointer),
which pops topts's SharedReadOnly tag under Stacked Borrows — any
subsequent topts.source_map read becomes Miri-detectable UB.

Copy source_map out alongside module_type, before the `let _ = topts;`
tombstone the prior author left for exactly this invariant. Flagged
by claude[bot].
Main's e_string().string() takes &self now; the `mut` bindings at
InputSourceMap.rs:116/126 became unused, which -D unused-mut turns
into a hard error on the release build-rust lanes. mappings_e_string
keeps its mut (slice() still takes &mut self).
When input_source_map chaining is active, prev_state.original_line
holds the remapped *authored* line — wrong coordinate space for the
intermediate file's line-offset table, so the O(1) find_line_with_hint
fast path missed on every token and fell to binary search. Track the
un-remapped intermediate line in a dedicated prev_intermediate_line
field and hint from that. No behavior change (binary search was always
the sound fallback); restores the per-token fast path on the chained
feature path. Chunk.zig uses plain findLine (no hint), so it's
unaffected. Flagged by claude[bot].
- InputSourceMap: reject maps whose sources[i] exceeds MAX_PATH_BYTES so
  an adversarial inline map can't panic the fixed-size path buffers;
  the map falls back cleanly instead.
- write_sources_for: resolve inner names via join_abs_string_buf_checked
  and emit the raw name when the join overflows, rather than the
  panicking unchecked join.
- Move the inline-sourcemap chain suite to its own test file and add an
  oversized-name regression test.
Windows MAX_PATH_BYTES is ~96 KB, so a 5 KB name was under the cap there
and surfaced instead of being rejected. Use a 128 KB name so the map is
rejected on every platform.
…es through

The DevServer stitcher never consumes the parsed map, so the per-reparse
scan was dead work on the HMR path. URL-schemed inner sources[] entries
(webpack:///...) must not be path-joined; emit them verbatim per spec.
A plugin onResolve custom-namespace path has no on-disk directory, so
joining inner names against dirname(text) produced bogus labels.
@robobun
robobun force-pushed the farm/eb1afa62/chain-input-sourcemaps branch from 2d79d79 to 1e8554f Compare August 23, 2026 10:27
Comment thread src/bundler/LinkerContext.rs Outdated
Comment on lines +1116 to +1119
let content = quoted_source_map_contents[index as usize]
.as_deref()
.unwrap_or(b""),
);
.unwrap_or(b"null");
j.push_static(if content.is_empty() { b"null" } else { content });

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.

🟡 nit: Slot 0's sourcesContent (quoted_source_map_contents[index]) is quoted from raw source.contents, which still carries the trailing //# sourceMappingURL=data:...;base64,<payload> comment — the same bytes now also emitted decoded into slots 1..N, so the inner map appears twice in the output. Since find_source_mapping_url already knows the comment's byte offset, consider quoting only source.contents[..comment_start] for slot 0 (or emitting null) when input_source_map.is_some(). Size-only on the new-feature path, not blocking.

Extended reasoning...

What the observation is

When chaining is active for an input file, LinkerContext.rs:1116-1119 emits quoted_source_map_contents[index] as slot 0 of the output map's sourcesContent[]. That value is populated by compute_quoted_source_contents (LinkerContext.rs:1627), which reads let contents: &[u8] = &source.contents; — the raw input file bytes, including the trailing //# sourceMappingURL=data:application/json;base64,<payload> comment. The base64 payload is exactly the JSON whose sourcesContent[] array this PR now decodes and re-emits into slots 1..N (LinkerContext.rs:1123-1134). So the inner map's bytes appear twice in the output map: once base64-encoded inside slot 0's string literal, and once decoded across slots 1..N.

The specific code path

  1. ParseTask.rs:2698-2706 finds a trailing inline map on source.contents and stores an InputSourceMap on the input file.
  2. compute_quoted_source_contents (LinkerContext.rs:1594-1632) later JSON-quotes the full source.contents — it has no knowledge of input_source_map, so it does not truncate at the comment.
  3. In the sourcesContent emission loop, slot 0 pushes that quoted string verbatim (LinkerContext.rs:1116-1119), then slots 1..N iterate ism.sources_content and push each decoded inner content (LinkerContext.rs:1123-1134).

Why existing code doesn't prevent it

Nothing in the diff strips the trailing comment from slot 0. The PR description says "sourcesContent[] holds the clean authored bytes without the trailing comment" — that's true for the authored slot (slot 1+), and the tests only assert on that slot (parsed.sourcesContent[authoredIdx]). No test inspects slot 0 (the intermediate). find_source_mapping_url in InputSourceMap.rs computes the byte offset of the trailing comment during parse but doesn't expose it, and compute_quoted_source_contents runs independently against raw source.contents.

Step-by-step proof

Take a .vue-style intermediate: 2 KB of transpiled JS + a 100 KB inline base64 comment (typical — the inner map's sourcesContent dominates and base64 inflates by 4/3).

  • Pre-PR: output sourcesContent[0] = the ~102 KB intermediate string. Total for this file: ~102 KB.
  • Post-PR, slot 0: same ~102 KB string (unchanged — compute_quoted_source_contents still reads raw source.contents).
  • Post-PR, slots 1..N: the decoded sourcesContent[] from the inner map — roughly the ~75 KB that base64-decoded from the 100 KB payload's sourcesContent field.
  • Total: ~177 KB. The chained output map is larger than the pre-PR unchained one, and ~98% of slot 0 is redundant with slots 1..N.

esbuild's equivalent (printer.go addSourceMapping) does not emit the intermediate as a source at all when an input map is present — it emits only the inner sources — so it doesn't carry this duplication.

Impact and severity

Size-only, on the new-feature path (input_source_map.is_some()). Correctness is unaffected: slot 0 is a valid string, mappings that fall back to it still resolve, DevTools just shows the raw intermediate with its trailing base64 comment. Nothing that worked before regresses. So this is a nit, not blocking.

Suggested fix

find_source_mapping_url already computes last_line_start — the byte offset of the trailing comment. Store that on InputSourceMap (e.g. comment_start: usize) and, in compute_quoted_source_contents or at the slot-0 emission site, quote only source.contents[..comment_start] when input_source_map.is_some(). Alternatively, emit null for slot 0 (matching esbuild's behavior of not surfacing the intermediate at all), since the intermediate's contents are rarely useful once the chain resolves through to the authored source.

The inner map otherwise ships twice: base64 inside the intermediate's
content plus decoded in the inner slots. find_source_mapping_url already
knows the comment's byte offset; store it on InputSourceMap and quote
only the bytes before it for the chained file's own slot.
Comment on lines +1116 to +1118
// For a chained file, strip the trailing comment from
// slot 0: the inner map would otherwise ship twice
// (base64 here + decoded in slots 1..N).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/sourcemap/InputSourceMap.rs Outdated
Comment on lines +15 to +18
/// Byte offset of the trailing `//# sourceMappingURL=` line in the
/// original source (`0` when parsed from raw JSON). The linker strips
/// the comment from the emitted slot-0 `sourcesContent` so the inner
/// map isn't shipped twice.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1119 to +1127
let file_contents: &[u8] = &sources[index as usize].contents;
match input_source_maps.and_then(|m| m[index as usize].as_deref()) {
Some(ism)
if ism.comment_start > 0
&& ism.comment_start <= file_contents.len() =>
{
let truncated = &file_contents[..ism.comment_start];
let mut quote_buf = MutableString::init(truncated.len() + 2)?;
js_printer::quote_for_json(truncated, &mut quote_buf, false)?;

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.

🟡 The guard ism.comment_start > 0 conflates byte offset 0 with "unset": when the file has no newline before its final line (e.g. a plugin onLoad returns only //# sourceMappingURL=data:...), find_source_mapping_url returns comment_start = 0, so this falls through to the _ arm and ships the full base64 comment as slot-0 sourcesContent — the double-shipping this block exists to prevent. Since the bundler only populates input_source_map via parse_from_source (never raw parse), comment_start is always a real offset here; drop the > 0 check (keep <= file_contents.len()) or make the sentinel Option<usize>/usize::MAX.

Extended reasoning...

What the bug is

The match guard at LinkerContext.rs:1122 tests ism.comment_start > 0 && ism.comment_start <= file_contents.len() to decide whether to emit slot-0 sourcesContent truncated at the inline-map comment (the intent of commit 1daf82f, "strip the inline-map comment from slot-0 sourcesContent"). The > 0 half is meant to distinguish "set by parse_from_source" from "unset by raw parse()" (InputSourceMap.rs:150 initializes comment_start: 0 in parse_internal, and the field's doc comment says "0 when parsed from raw JSON"). But 0 is also a valid byte offset returned by find_source_mapping_url — so the guard gates on truthiness where it should gate on presence.

The specific code path that triggers it

In find_source_mapping_url (InputSourceMap.rs:174-184):

let last_line_start = match bun_core::strings::last_index_of_char(body, b'\n') {
    Some(i) => i + 1,
    None => 0,
};
...
let comment_start = last_line_start;

When the whitespace-trimmed body contains no \n, last_line_start = 0, so comment_start = 0. This is a legitimate byte offset meaning "the //# sourceMappingURL= comment starts at byte 0 of the file". A plugin onLoad returning { contents: "//# sourceMappingURL=data:application/json;base64,<...>", loader: "js" } (no leading code, no leading newline) — pathological but valid JS and reachable via the #6173 plugin path this PR targets — produces exactly this.

Why existing code doesn't prevent it

The only producer of input_source_map in the bundler graph is InputSourceMap::parse_from_source (ParseTask.rs:2700); ServerComponentParseTask.rs:205 sets None. Raw InputSourceMap::parse (which sets comment_start = 0 as a genuine "unset" sentinel) is never reached from the bundler, so every ism reaching this match arm has comment_start set to a real byte offset. The > 0 check therefore has no legitimate "unset" case to reject here — it only creates a false negative for the comment_start == 0 case.

None of the 11 tests in bun-build-inline-sourcemap-chain.test.ts exercise a single-line-comment-only intermediate; they all prefix the comment with at least one line of code plus a \n.

Step-by-step proof

Take a plugin onLoad returning contents = "//# sourceMappingURL=data:application/json;base64,eyJ2ZXJz..." (no leading newline, no trailing newline):

  1. ParseTask.rs:2700 — InputSourceMap::parse_from_source(&source.contents) is called (gates pass: !has_dev_server, source_map != None, loader.can_have_source_map(), !contents.is_empty()).
  2. InputSourceMap.rs:160-168 — trailing-whitespace trim: no trailing whitespace, so body = source.
  3. InputSourceMap.rs:174 — last_index_of_char(body, b'\n') → None (no newline in body) → last_line_start = 0.
  4. InputSourceMap.rs:178-184 — last_line = &body[0..] starts with //# sourceMappingURL= ✓ → comment_start = 0. The data URL parses successfully → map.comment_start = 0.
  5. bundle_v2.rs:7142 — moved into graph.input_files.items_input_source_map_mut()[idx].
  6. LinkerContext.rs:1120-1123 — input_source_maps.and_then(...) → Some(ism) with ism.comment_start == 0. Guard evaluates 0 > 0 → false.
  7. LinkerContext.rs:1130-1135 — falls through to the _ arm: emits quoted_source_map_contents[index], which is the JSON-quoted full file contents — i.e. the entire //# sourceMappingURL=data:...;base64,<payload> string.
  8. LinkerContext.rs:1140-1152 — slots 1..N then emit the decoded inner sourcesContent[i].

Result: the inner map ships twice in the output sourcesContent[] — once base64-encoded in slot 0, once decoded in slots 1..N — exactly the bloat the comment_start truncation exists to prevent. Mappings and source resolution are unaffected (they don't read sourcesContent), so correctness is fine; only output size regresses on this input shape.

How to fix it

This is REVIEW.md's "Treat empty, zero, and unset as three distinct states — gate on presence, not truthiness" rule verbatim. Two options:

  • Minimal: drop the > 0 half of the guard, keeping only ism.comment_start <= file_contents.len(). Since every ism reaching this code came from parse_from_source, comment_start is always a valid slice index (including 0), and &file_contents[..0] correctly yields b"" → slot 0 emits "".
  • Structural: change InputSourceMap.comment_start to Option<usize> (or use usize::MAX as the sentinel in parse_internal) so "unset" and "offset 0" are representationally distinct — then the guard becomes if let Some(start) = ism.comment_start and the ambiguity disappears at the type level.

Nit-level because the trigger requires a file whose entire non-whitespace content is the sourceMappingURL comment (only a plugin onLoad returning nothing but the comment would produce it), and the impact is output-size bloat only.

A module whose whole contents are the sourceMappingURL line has the
comment at byte 0; the > 0 sentinel check skipped truncation for exactly
that input and shipped the inline map twice.
Comment on lines +15 to +18
/// Byte offset of the trailing `//# sourceMappingURL=` line in the
/// original source (`None` when parsed from raw JSON). The linker
/// strips the comment from the emitted slot-0 `sourcesContent` so the
/// inner map isn't shipped twice.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

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

I reviewed this PR and the bug-hunting pass found no new issues; all prior findings from earlier rounds are resolved. Given the size (~1000 lines across 10 files) and the cross-file slot-layout invariant this introduces between Chunk::Builder and LinkerContext, a human look would still be worthwhile before merge.

What was reviewed

  • Slot-layout contract: Chunk::Builder emits source_index ∈ {0, 1+inner_idx} and LinkerContext reserves 1 + external_source_names.len slots per file; verified these agree and that prev_end_state.source_index = base + chunk.end_state.source_index is a no-op when input_source_map is None (chunk end-state stays 0).
  • The refactored sources[]/sourcesContent[] emission loops preserve pre-PR behavior when no inline map is present (leading-comma via chunk_i > 0 is correct because slot 0 is always unique; the unwrap_or(b"null") change only tightens a defensive fallback).
  • InputSourceMap::parse_from_source: last-line anchoring, adversarial length caps, out-of-range VLQ rejection, and DevServer gating at both producer and consumer sites — all covered by the 11 new tests.
  • Ownership: the new Option<Box<InputSourceMap>> SoA column is drained explicitly in deinit_without_freeing_arena and overwritten (not leaked) on incremental reparse.
Extended reasoning...

Overview

This PR threads inline //# sourceMappingURL=data:... sourcemaps carried by bundler input files through to the final output map, so authored sources (e.g. .vue/.svelte originals) surface in sources[]/sourcesContent[] instead of stopping at the intermediate .js. It touches 10 files: a new ~240-line parser (InputSourceMap.rs), the per-token remap in Chunk::Builder::add_source_mapping, a substantial refactor of LinkerContext's sources/sourcesContent/mapping-stitching emission, a new InputFile SoA column with explicit drain, plumbing through ParseTask/Options, and an 11-case test file. Net +996/-71.

Security risks

Low. The new parser ingests untrusted bytes (inline map payloads from arbitrary input files or plugin onLoad returns), but it validates before allocating: base64 decode length is bounded by the payload, JSON parse failures return None, sources[i] longer than MAX_PATH_BYTES rejects the whole map, out-of-range VLQ source_index is rejected via the real sources_count bound, and path resolution uses the checked join_abs_string_buf_checked with a verbatim fallback. All failure paths degrade to pre-PR behavior (build succeeds, no chain). No filesystem reads for external .map references.

Level of scrutiny

High. This is core bundler output-generation code, and the correctness of every Bun.build sourcemap now depends on a cross-file invariant: Chunk::Builder emits chunk-relative source_index values that LinkerContext must rebase and whose slot count must exactly match the sources[]/sourcesContent[] expansion. The prev_end_state.source_index stitching change and the sourcesContent loop rewrite both touch the non-chained path too — I verified they reduce to the old behavior when input_source_map is None, but a maintainer familiar with the linker's stitching semantics should confirm.

Other factors

The PR has been through 32 iterations and multiple review rounds; every prior finding (dead errdefer, DevServer stitcher corruption, Stacked Borrows tombstone violation, find_line_with_hint coordinate-space bug, source-lint violations, URL-schemed/virtual-namespace source names, wasted DevServer scan, comment_start == 0 edge case) has a corresponding fix commit and test, and all threads are resolved. Two nits (percent-decoding the non-base64 branch, and a monotonic cursor for find_mapping) were explicitly deferred to a coordinated follow-up alongside the pre-existing sourceRoot gap. CI on the last full build (#97866) passed 176 jobs with only darwin agent-expiry infrastructure noise; the latest push is currently building. Given the scope and the number of subtle invariants that surfaced during iteration, this warrants a human sign-off rather than bot-only approval.

@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Update on the relationship with #32473 (external .map chaining and dev server stitching, for #26713).

The earlier note above said that #30539 can be closed if #32473 lands. That is no longer the plan. #32473 has had no push since 2026-06-27, conflicts with main in 10 files, and forked from this branch 13 commits ago. This PR is mergeable and green on build #104123.

Direction: this PR lands first. #32473 will be rebased on top of it and carry only the external .map and dev server delta. I built this head (a0c5ca3) and ran the test/regression/issue/26713.test.ts file from #32473 against it: 5 of 7 cases fail, the 2 fallback cases pass. So #32473 is not redundant with this PR and stays open. Details are in #32473 (comment).

robobun added a commit that referenced this pull request Aug 26, 2026
The comment linter main gained flags every multi-line justification in
this diff (CLAUDE.md 13/14). Most were inherited from #30539's style.
Each is cut to what a reader could not infer from the code. Also route
two byte searches in InputSourceMap.rs through bun_core::strings
(last_index_of_char, index_of_any) per the byte-search source lint.

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.

Bun.build ignores inline //# sourceMappingURL= comments on input files (file-input sibling of #6173) Support sourcemaps in onLoad plugins

1 participant