From eb5c2ff6be7593386c3b18409a20f4cbb194905d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 06:05:04 +0000 Subject: [PATCH 01/32] bundler: chain inline input sourcemaps through to output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #30536. When Bun.build reads a source file that carries a trailing `//# sourceMappingURL=data:application/json;...` comment (typically produced by an upstream compile step like .vue/.svelte/.mdx/ts-plugin → .js), the bundler already detected the URL at the lexer but dropped it on the floor. The output sourcemap's deepest `sources[]` entry was the intermediate .js file and `sourcesContent[]` carried the intermediate's bytes verbatim — including the literal `sourceMappingURL=` comment — so stack traces surfaced one hop short of the authored source. - `src/sourcemap/InputSourceMap.zig` (new): owns the parsed inner map + per-source contents, cleans up after itself. - `src/bundler/ParseTask.zig`: after parsing the JS, scan the source for a trailing `//# sourceMappingURL=data:...`. Inline data URLs are parsed (base64 and raw); external `.map` references are left for a follow-up. Gated on `source_map != .none` and `loader.canHaveSourceMap()`. - `src/bundler/Graph.zig`, `src/bundler/bundle_v2.zig`: new `InputFile.input_source_map` field; ownership moves from the parse result onto the file on consumption, freed at bundler teardown. - `src/js_printer/js_printer.zig`, `src/sourcemap/Chunk.zig`: threads the parsed map into `Chunk.Builder`. During `addSourceMapping`, translate each (line, column) through the inner map — on a hit, emit `source_index = 1 + inner.source_index` and the inner original (line, column); on a miss, fall back to slot 0 (the intermediate) so unmapped tokens still land in a real file. - `src/bundler/LinkerContext.zig`: each outer source in the output map's `sources[]` expands to `[intermediate, inner_0 .. inner_N-1]`. `sourcesContent[]` matches slot-for-slot: the intermediate's contents first, then each inner source's contents (drawn from the inner map). Chunk stitching uses `base + chunk.end_state.source_index` as the absolute end state so per-chunk mappings that vary source_index across their length compose correctly. - Repro from the issue (entry.ts → inner.js-with-inline-map → inner.ts): output `sources` now contains `../inner.ts`; `sourcesContent[authored_slot]` is the clean authored bytes with no sourceMappingURL comment. - Multi-inner-source maps (e.g. .vue compilers that split template/script) surface all inner sources. - Malformed or unrecognized inline maps fall back gracefully — build succeeds with the old behavior. - External `.map` references unchanged (out of scope here). - `sourcemap = none` builds skip the scan entirely. Five new cases in `test/bundler/bun-build-api.test.ts` covering: the base64 chain, the raw data-URL chain, multi-inner-source surfacing, malformed payload fallback, and external `.map` reference unchanged. --- src/bundler/Graph.zig | 10 ++ src/bundler/LinkerContext.zig | 153 ++++++++++++++------ src/bundler/ParseTask.zig | 25 ++++ src/bundler/bundle_v2.zig | 11 ++ src/js_printer/js_printer.zig | 8 ++ src/sourcemap/Chunk.zig | 45 +++++- src/sourcemap/InputSourceMap.zig | 218 +++++++++++++++++++++++++++++ src/sourcemap/sourcemap.zig | 1 + test/bundler/bun-build-api.test.ts | 175 +++++++++++++++++++++++ 9 files changed, 602 insertions(+), 44 deletions(-) create mode 100644 src/sourcemap/InputSourceMap.zig diff --git a/src/bundler/Graph.zig b/src/bundler/Graph.zig index a063f146f0b0..64c3b866c650 100644 --- a/src/bundler/Graph.zig +++ b/src/bundler/Graph.zig @@ -78,6 +78,16 @@ pub const InputFile = struct { content_hash_for_additional_file: u64 = 0, flags: Flags = .{}, + /// Populated when the input file carried a trailing + /// `//# sourceMappingURL=data:application/json;...` comment that we + /// were able to parse. Lets the linker chain the map through — sources + /// and mappings reference the authored origin (`.vue`, `.svelte`, `.ts` + /// that an upstream step compiled away) instead of the intermediate + /// `.js` the bundler ingested. + /// + /// Owned — freed on bundler teardown. + input_source_map: ?*bun.SourceMap.InputSourceMap = null, + pub const Flags = packed struct(u8) { is_plugin_file: bool = false, /// Set when a barrel-eligible file has `export * from` this file. diff --git a/src/bundler/LinkerContext.zig b/src/bundler/LinkerContext.zig index 633aeaf78da4..5a3fac60f9f3 100644 --- a/src/bundler/LinkerContext.zig +++ b/src/bundler/LinkerContext.zig @@ -702,6 +702,7 @@ pub const LinkerContext = struct { const sources = c.parse_graph.input_files.items(.source); const quoted_source_map_contents = c.graph.files.items(.quoted_source_contents); + const input_source_maps = c.parse_graph.input_files.items(.input_source_map); // Entries in `results` do not 1:1 map to source files, the mapping // is actually many to one, where a source file can have multiple chunks @@ -710,53 +711,101 @@ pub const LinkerContext = struct { // This hashmap is going to map: // `source_index` (per compilation) in a chunk // --> - // Which source index in the generated sourcemap, referred to - // as the "mapping source index" within this function to be distinct. + // Base source index in the generated sourcemap (inclusive). When + // the input file did not carry an inline sourcemap, the chunk's + // mappings all use that base. When the input file carried an + // inline `//# sourceMappingURL=`, the chunk's mappings were + // remapped through that inner map at print time and now span + // `base .. base + inner.external_source_names.len - 1`. var source_id_map = std.AutoArrayHashMap(u32, i32).init(worker.allocator); defer source_id_map.deinit(); const source_indices = results.items(.source_index); - j.pushStatic( - \\{ - \\ "version": 3, - \\ "sources": [ - ); - if (source_indices.len > 0) { - { - const index = source_indices[0]; - var path = sources[index].path; - try source_id_map.putNoClobber(index, 0); - + // Helper — emit the outer source's quoted path, plus any inner + // source paths contributed by its `//# sourceMappingURL=` (one + // slot per inner source, in `external_source_names` order). + // `leading_comma` controls whether the first emitted path is + // prefixed with ", " (needed only when we've already emitted at + // least one path for this sources[] array). + // + // Layout matches the one the Builder assumes in `Chunk.zig`: + // slot 0 → the intermediate input (this outer file) + // slot 1..N → inner `sources[i]` (chained) + const writeSourcesFor = struct { + fn call( + joiner: *StringJoiner, + alloc: std.mem.Allocator, + dir: []const u8, + outer_path: bun.fs.Path, + input_map: ?*bun.SourceMap.InputSourceMap, + leading_comma: bool, + ) !void { + // 1) the intermediate input. + var path = outer_path; if (path.isFile()) { - const rel_path = try bun.path.relativeAlloc(worker.allocator, chunk_abs_dir, path.text); + const rel_path = try bun.path.relativeAlloc(alloc, dir, path.text); path.pretty = rel_path; } + { + var quote_buf = try MutableString.init(alloc, path.pretty.len + ", ".len + 2); + if (leading_comma) quote_buf.appendAssumeCapacity(", "); + try js_printer.quoteForJSON(path.pretty, "e_buf, false); + joiner.pushStatic(quote_buf.slice()); + } + + // 2) inner sources, if any. + if (input_map) |ism| { + const base_dir = bun.path.dirname(outer_path.text, .auto); + for (ism.map.external_source_names) |name| { + // Resolve inner `sources[i]` relative to the dir + // of the intermediate file it came from, then + // make it relative to `dir` (the chunk's output + // dir) for the emitted JSON. Absolute inner paths + // stay absolute before relativization. + const abs_path = if (bun.path.Platform.auto.isAbsolute(name)) + name + else + bun.path.joinAbsString(base_dir, &[_][]const u8{name}, .auto); + const rel_path = try bun.path.relativeAlloc(alloc, dir, abs_path); - var quote_buf = try MutableString.init(worker.allocator, path.pretty.len + 2); - try js_printer.quoteForJSON(path.pretty, "e_buf, false); - j.pushStatic(quote_buf.slice()); // freed by arena + var quote_buf = try MutableString.init(alloc, rel_path.len + ", ".len + 2); + quote_buf.appendAssumeCapacity(", "); + try js_printer.quoteForJSON(rel_path, "e_buf, false); + joiner.pushStatic(quote_buf.slice()); + } + } } + }.call; - var next_mapping_source_index: i32 = 1; - for (source_indices[1..]) |index| { + j.pushStatic( + \\{ + \\ "version": 3, + \\ "sources": [ + ); + var next_mapping_source_index: i32 = 0; + if (source_indices.len > 0) { + for (source_indices, 0..) |index, chunk_i| { const gop = try source_id_map.getOrPut(index); if (gop.found_existing) continue; gop.value_ptr.* = next_mapping_source_index; - next_mapping_source_index += 1; - - var path = sources[index].path; - - if (path.isFile()) { - const rel_path = try bun.path.relativeAlloc(worker.allocator, chunk_abs_dir, path.text); - path.pretty = rel_path; - } - - var quote_buf = try MutableString.init(worker.allocator, path.pretty.len + ", ".len + 2); - quote_buf.appendAssumeCapacity(", "); - try js_printer.quoteForJSON(path.pretty, "e_buf, false); - j.pushStatic(quote_buf.slice()); // freed by arena + // `1` for the intermediate input, plus one slot per inner + // source listed in its `sourceMappingURL`. + const expansion: i32 = 1 + if (input_source_maps[index]) |ism| + @as(i32, @intCast(ism.map.external_source_names.len)) + else + 0; + next_mapping_source_index += expansion; + + try writeSourcesFor( + &j, + worker.allocator, + chunk_abs_dir, + sources[index].path, + input_source_maps[index], + chunk_i > 0, + ); } } @@ -767,14 +816,30 @@ pub const LinkerContext = struct { const source_indices_for_contents = source_id_map.keys(); if (source_indices_for_contents.len > 0) { - j.pushStatic("\n "); - j.pushStatic( - quoted_source_map_contents[source_indices_for_contents[0]].get() orelse "", - ); - - for (source_indices_for_contents[1..]) |index| { - j.pushStatic(",\n "); - j.pushStatic(quoted_source_map_contents[index].get() orelse ""); + var emitted_contents: usize = 0; + for (source_indices_for_contents) |index| { + // Slot 0: the intermediate input file's contents (already + // JSON-quoted by `computeQuotedSourceContents`). + { + const sep: []const u8 = if (emitted_contents == 0) "\n " else ",\n "; + j.pushStatic(sep); + j.pushStatic(quoted_source_map_contents[index].get() orelse "null"); + emitted_contents += 1; + } + // Slots 1..N: inner sources' contents, if any. + if (input_source_maps[index]) |ism| { + for (ism.sources_content) |content| { + j.pushStatic(",\n "); + if (content.len > 0) { + var quote_buf = MutableString.initEmpty(worker.allocator); + try js_printer.quoteForJSON(content, "e_buf, false); + j.pushStatic(quote_buf.slice()); + } else { + j.pushStatic("null"); + } + emitted_contents += 1; + } + } } } j.pushStatic( @@ -805,7 +870,12 @@ pub const LinkerContext = struct { try SourceMap.appendSourceMapChunk(&j, worker.allocator, prev_end_state, start_state, chunk.buffer.list.items); prev_end_state = chunk.end_state; - prev_end_state.source_index = mapping_source_index; + // If the input carried an inline map, `chunk.end_state.source_index` + // is the inner source_index of the last mapping within the chunk + // (the Builder emits remapped absolute-within-chunk indices). + // Otherwise it's 0. Either way, the final absolute index is + // `mapping_source_index + chunk.end_state.source_index`. + prev_end_state.source_index = mapping_source_index + chunk.end_state.source_index; prev_column_offset = chunk.final_generated_column; if (prev_end_state.generated_line == 0) { @@ -1360,6 +1430,7 @@ pub const LinkerContext = struct { c, ), .line_offset_tables = c.graph.files.items(.line_offset_table)[source_index.get()], + .input_source_map = c.parse_graph.input_files.items(.input_source_map)[source_index.get()], .target = c.options.target, .hmr_ref = if (c.options.output_format == .internal_bake_dev) diff --git a/src/bundler/ParseTask.zig b/src/bundler/ParseTask.zig index bcc793aad939..d0799a8bfeb2 100644 --- a/src/bundler/ParseTask.zig +++ b/src/bundler/ParseTask.zig @@ -88,6 +88,14 @@ pub const Result = struct { /// The package name from package.json, used for barrel optimization. package_name: string = "", + + /// Parsed trailing `//# sourceMappingURL=data:...` — lets the linker + /// chain the output sourcemap through an upstream compile step's + /// inline map so `sources[]` / `sourcesContent[]` and mappings + /// reference the authored source instead of the intermediate `.js`. + /// Ownership moves to `Graph.InputFile.input_source_map` on + /// consumption. + input_source_map: ?*bun.SourceMap.InputSourceMap = null, }; pub const Error = struct { @@ -1301,6 +1309,21 @@ fn runWithSourceCode( step.* = .resolve; + // Parse any inline `//# sourceMappingURL=data:...` the input carries. + // When present, the linker will chain its output map through it so + // `sources[]` / `sourcesContent[]` point at the authored source (e.g. + // the `.vue` / `.svelte` / `.ts` file that an upstream compile step + // turned into this intermediate `.js`) rather than at the intermediate. + // Gated on `source_map != .none` to avoid spending cycles on builds + // that won't emit a map anyway, and on `canHaveSourceMap` so we skip + // binary / asset loaders whose "contents" are not source code. + const input_source_map: ?*bun.SourceMap.InputSourceMap = if (transpiler.options.source_map != .none and + loader.canHaveSourceMap() and + source.contents.len > 0) + bun.SourceMap.InputSourceMap.parseFromSource(source.contents) + else + null; + return .{ .ast = ast, .source = source.*, @@ -1316,6 +1339,8 @@ fn runWithSourceCode( unique_key_for_additional_file.content_hash else 0, + + .input_source_map = input_source_map, }; } diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index d12d0998a285..b0961e8986d1 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -2227,6 +2227,13 @@ pub const BundleV2 = struct { } defer { + // Free any parsed input sourcemaps before the backing + // MultiArrayList goes away. These own their mappings + source + // contents on `bun.default_allocator` independent of the bundle + // arena. + for (this.graph.input_files.items(.input_source_map)) |maybe_map| { + if (maybe_map) |ism| ism.deinit(); + } this.graph.ast.deinit(this.allocator()); this.graph.input_files.deinit(this.allocator()); this.graph.entry_points.deinit(this.allocator()); @@ -3665,6 +3672,10 @@ pub const BundleV2 = struct { // Record which loader we used for this file graph.input_files.items(.loader)[result.source.index.get()] = result.loader; + // Move ownership of the parsed inline `//# sourceMappingURL=` + // onto the input file so the linker can chain through it. + graph.input_files.items(.input_source_map)[result.source.index.get()] = result.input_source_map; + debug("onParse({d}, {s}) = {d} imports, {d} exports", .{ result.source.index.get(), result.source.path.text, diff --git a/src/js_printer/js_printer.zig b/src/js_printer/js_printer.zig index 9e31478401d9..06b616afdb5a 100644 --- a/src/js_printer/js_printer.zig +++ b/src/js_printer/js_printer.zig @@ -428,6 +428,13 @@ pub const Options = struct { // us do binary search on to figure out what line a given AST node came from line_offset_tables: ?SourceMap.LineOffsetTable.List = null, + /// Parsed trailing `//# sourceMappingURL=` of the input file, if any. + /// When set, the printer's sourcemap builder remaps each mapping + /// through this inner map so the emitted `source_index` and original + /// (line, column) reference the authored source rather than the + /// bundler's intermediate input. Owned by `Graph.InputFile`. + input_source_map: ?*SourceMap.InputSourceMap = null, + mangled_props: ?*const bun.bundle_v2.MangledProps, // Default indentation is 2 spaces @@ -5930,6 +5937,7 @@ pub fn getSourceMapBuilder( ); break :brk .empty; }, + .input_source_map = opts.input_source_map, }; } diff --git a/src/sourcemap/Chunk.zig b/src/sourcemap/Chunk.zig index d2cbbedb9b1e..a356af3ef6e1 100644 --- a/src/sourcemap/Chunk.zig +++ b/src/sourcemap/Chunk.zig @@ -211,6 +211,14 @@ pub fn NewBuilder(comptime SourceMapFormatType: type) type { line_offset_table_byte_offset_list: []const u32 = &.{}, + /// When the input file carried a trailing `//# sourceMappingURL=`, + /// remap each mapping through its inner map so the emitted + /// `source_index` / original `(line, col)` refer to the authored + /// source instead of the bundler's intermediate input. Unset + /// otherwise — the emitted mapping uses the Builder's own + /// `prev_state.source_index` (the outer source's slot). + input_source_map: ?*bun.SourceMap.InputSourceMap = null, + // This is a workaround for a bug in the popular "source-map" library: // https://github.com/mozilla/source-map/issues/261. The library will // sometimes return null when querying a source map unless every line @@ -356,6 +364,37 @@ pub fn NewBuilder(comptime SourceMapFormatType: type) type { b.updateGeneratedLineAndColumn(output); + // If the input file carried its own `//# sourceMappingURL=`, + // translate the (line, column) we just computed — which refers + // to the intermediate file — into the authored source's + // (source_index, line, column) via the inner map. + // + // Layout of the per-outer-source slots in the output + // sourcemap's `sources[]` array: + // 0 → the intermediate input file + // 1 + inner_idx → inner `sources[inner_idx]` + // The emitted `source_index` is relative to the chunk's start + // (the Builder always begins with `prev_state.source_index = 0`); + // `LinkerContext` stitches the absolute base in when joining + // chunks. Mappings the inner map doesn't cover fall back to + // slot 0 (the intermediate) so stack traces land in the right + // file rather than silently disappearing. + var mapped_source_index: i32 = 0; + var mapped_original_line: i32 = @max(original_line, 0); + var mapped_original_column: i32 = @max(original_column, 0); + if (b.input_source_map) |ism| { + if (ism.map.findMapping( + .fromZeroBased(@intCast(mapped_original_line)), + .fromZeroBased(@intCast(mapped_original_column)), + )) |inner| { + mapped_source_index = 1 + inner.source_index; + mapped_original_line = @intCast(inner.original.lines.zeroBased()); + mapped_original_column = @intCast(inner.original.columns.zeroBased()); + } + // else: fall back to the intermediate (slot 0), using the + // (line, col) we already have in the intermediate. + } + // If this line doesn't start with a mapping and we're about to add a mapping // that's not at the start, insert a mapping first so the line starts with one. if (b.cover_lines_without_mappings and !b.line_starts_with_mapping and b.generated_column > 0 and b.has_prev_state) { @@ -371,9 +410,9 @@ pub fn NewBuilder(comptime SourceMapFormatType: type) type { b.appendMapping(.{ .generated_line = b.prev_state.generated_line, .generated_column = @max(b.generated_column, 0), - .source_index = b.prev_state.source_index, - .original_line = @max(original_line, 0), - .original_column = @max(original_column, 0), + .source_index = mapped_source_index, + .original_line = mapped_original_line, + .original_column = mapped_original_column, }); // This line now has a mapping on it, so don't insert another one diff --git a/src/sourcemap/InputSourceMap.zig b/src/sourcemap/InputSourceMap.zig new file mode 100644 index 000000000000..14a822257718 --- /dev/null +++ b/src/sourcemap/InputSourceMap.zig @@ -0,0 +1,218 @@ +//! Per-input-file sourcemap used by the bundler to chain sourcemaps through +//! upstream compile steps (e.g. `.vue` → `.js`, `.svelte` → `.js`, +//! TypeScript plugins). When `Bun.build` reads an input file that carries +//! an inline `//# sourceMappingURL=data:application/json;...` comment, we +//! parse it into an `InputSourceMap` and store it on the file's +//! `Graph.InputFile`. `LinkerContext` then emits its `sources` / +//! `sourcesContent` in place of the intermediate, and `Chunk.Builder` +//! remaps each mapping through `map.findMapping` during printing so stack +//! traces surface in the authored source. + +const InputSourceMap = @This(); + +/// Parsed mappings + `external_source_names` (the chained-in `sources[]`). +/// Owned — must be `deref`'d on cleanup. +map: *bun.SourceMap.ParsedSourceMap, + +/// One entry per source in `map.external_source_names`. A slot is empty +/// (`""`) when the inner map did not carry content for that source. +/// Owned by `bun.default_allocator`. +sources_content: [][]const u8, + +pub fn deinit(this: *InputSourceMap) void { + this.map.deref(); + for (this.sources_content) |content| { + if (content.len > 0) bun.default_allocator.free(content); + } + bun.default_allocator.free(this.sources_content); + bun.destroy(this); +} + +/// Parse a sourcemap JSON blob intended to chain through a bundler input +/// file. Returns an owned `*InputSourceMap` (free with `deinit`) or `null` +/// if the JSON doesn't look like a valid sourcemap. Non-fatal parse +/// failures return `null` — callers fall back to the raw file bytes. +/// +/// `json_bytes` is borrowed; the function copies out what it needs. +pub fn parse(json_bytes: []const u8) ?*InputSourceMap { + const allocator = bun.default_allocator; + + var arena = bun.ArenaAllocator.init(allocator); + defer arena.deinit(); + const arena_allocator = arena.allocator(); + + const json_src = bun.logger.Source.initPathString("sourcemap.json", json_bytes); + var log = bun.logger.Log.init(arena_allocator); + defer log.deinit(); + + bun.ast.Expr.Data.Store.reset(); + bun.ast.Stmt.Data.Store.reset(); + defer { + bun.ast.Expr.Data.Store.reset(); + bun.ast.Stmt.Data.Store.reset(); + } + + var json = bun.json.parse(&json_src, &log, arena_allocator, false) catch return null; + + if (json.get("version")) |version| { + if (version.data != .e_number or version.data.e_number.value != 3.0) return null; + } + + const mappings_str = json.get("mappings") orelse return null; + if (mappings_str.data != .e_string) return null; + + const sources_paths = switch ((json.get("sources") orelse return null).data) { + .e_array => |arr| arr, + else => return null, + }; + + // `sourcesContent` is optional; when absent we leave every slot empty. + const sources_content_opt: ?*bun.ast.E.Array = if (json.get("sourcesContent")) |sc| switch (sc.data) { + .e_array => |arr| arr, + .e_null => null, + else => return null, + } else null; + + if (sources_content_opt) |arr| { + if (arr.items.len != sources_paths.items.len) return null; + } + + const source_count = sources_paths.items.len; + + // Allocate sources_paths / sources_content slices up-front so we can + // errdefer their cleanup cleanly. + var source_paths_slice = allocator.alloc([]const u8, source_count) catch return null; + var paths_written: usize = 0; + errdefer { + for (source_paths_slice[0..paths_written]) |p| allocator.free(p); + allocator.free(source_paths_slice); + } + + for (sources_paths.items.slice()) |item| { + if (item.data != .e_string) return null; + const str = item.data.e_string.string(arena_allocator) catch return null; + source_paths_slice[paths_written] = allocator.dupe(u8, str) catch return null; + paths_written += 1; + } + + var sources_content_slice = allocator.alloc([]const u8, source_count) catch return null; + var contents_written: usize = 0; + errdefer { + for (sources_content_slice[0..contents_written]) |c| if (c.len > 0) allocator.free(c); + allocator.free(sources_content_slice); + } + + if (sources_content_opt) |arr| { + for (arr.items.slice()) |item| { + if (item.data == .e_string) { + const str = item.data.e_string.string(arena_allocator) catch return null; + sources_content_slice[contents_written] = if (str.len == 0) + "" + else + allocator.dupe(u8, str) catch return null; + } else { + // Non-strings (null, etc.) get empty content. + sources_content_slice[contents_written] = ""; + } + contents_written += 1; + } + } else { + for (0..source_count) |i| sources_content_slice[i] = ""; + contents_written = source_count; + } + + const map_data = switch (bun.SourceMap.Mapping.parse( + allocator, + mappings_str.data.e_string.slice(arena_allocator), + null, + std.math.maxInt(i32), + std.math.maxInt(i32), + .{ .allow_names = false, .sort = true }, + )) { + .success => |x| x, + .fail => return null, + }; + + const psm = bun.new(bun.SourceMap.ParsedSourceMap, map_data); + psm.external_source_names = source_paths_slice; + // The `ref_count` was zero-initialized by `bun.new`; calling code holds + // the only reference, which is released via `InputSourceMap.deinit`. + + const result = bun.new(InputSourceMap, .{ + .map = psm, + .sources_content = sources_content_slice, + }); + return result; +} + +/// Locate a `//# sourceMappingURL=` trailing comment in the source text and +/// parse the inline `data:application/json;base64,...` (or `;,...`) map +/// into an owned `*InputSourceMap`. Returns `null` when no URL is present, +/// when the URL is not a data URL (e.g. a `.map` filename), or when the +/// payload fails to parse. External `.map` file resolution is the caller's +/// responsibility. +pub fn parseFromSource(source: []const u8) ?*InputSourceMap { + const url = findSourceMappingURL(source) orelse return null; + return parseDataUrl(url); +} + +/// Find the trailing `//# sourceMappingURL=` comment in a file. The +/// spec calls for the *last* such comment, hence `lastIndexOf`. +fn findSourceMappingURL(source: []const u8) ?[]const u8 { + const needle = "//# sourceMappingURL="; + // Require a preceding newline so we don't mis-detect the comment as + // the opening line of a compiled-away string literal etc. + const found = std.mem.lastIndexOf(u8, source, "\n" ++ needle) orelse { + // First line edge case: if the file literally starts with the + // comment, `lastIndexOf` with a leading newline would miss it. + if (bun.strings.hasPrefixComptime(source, needle)) { + const end = std.mem.indexOfScalarPos(u8, source, needle.len, '\n') orelse source.len; + return bun.strings.trim(source[needle.len..end], " \r\t"); + } + return null; + }; + const start = found + 1 + needle.len; + const end = std.mem.indexOfScalarPos(u8, source, start, '\n') orelse source.len; + return bun.strings.trim(source[start..end], " \r\t"); +} + +/// Decode `data:application/json[;base64],...` payloads. Returns `null` +/// when the URL is not a supported data scheme. +fn parseDataUrl(url: []const u8) ?*InputSourceMap { + const prefix = "data:application/json"; + if (!bun.strings.hasPrefixComptime(url, prefix)) return null; + if (url.len <= prefix.len + 1) return null; + + const remainder = url[prefix.len..]; + // `data:application/json;charset=utf-8;base64,...` is permitted in + // the wild; we tolerate any number of `;name[=value]` parameters + // between the prefix and the final `;base64,` / `,` separator. + var rest = remainder; + var is_base64 = false; + while (rest.len > 0 and rest[0] == ';') { + // Advance past one parameter up to the next ';' or ','. + const after = rest[1..]; + const param_end = std.mem.indexOfAny(u8, after, ";,") orelse return null; + const param = after[0..param_end]; + if (bun.strings.eqlComptime(param, "base64")) is_base64 = true; + rest = after[param_end..]; + } + if (rest.len == 0 or rest[0] != ',') return null; + const payload = rest[1..]; + + if (is_base64) { + const decoded_len = bun.base64.decodeLen(payload); + var buf = bun.default_allocator.alloc(u8, decoded_len) catch return null; + defer bun.default_allocator.free(buf); + const decoded = bun.base64.decode(buf, payload); + if (!decoded.isSuccessful()) return null; + return parse(buf[0..decoded.count]); + } + + // Not base64; treat the payload as the raw JSON text (sometimes + // percent-encoded in URLs, but bundlers emit the literal form). + return parse(payload); +} + +const std = @import("std"); +const bun = @import("bun"); diff --git a/src/sourcemap/sourcemap.zig b/src/sourcemap/sourcemap.zig index 13036c5e16dd..5a69dd7b5cf8 100644 --- a/src/sourcemap/sourcemap.zig +++ b/src/sourcemap/sourcemap.zig @@ -914,6 +914,7 @@ pub const VLQ = @import("./VLQ.zig"); pub const LineOffsetTable = @import("./LineOffsetTable.zig"); pub const JSSourceMap = @import("../sourcemap_jsc/JSSourceMap.zig"); pub const InternalSourceMap = @import("./InternalSourceMap.zig"); +pub const InputSourceMap = @import("./InputSourceMap.zig"); const decodeVLQAssumeValid = VLQ.decodeAssumeValid; const decodeVLQ = VLQ.decode; diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 4487b0e273a3..68f650c03851 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1391,3 +1391,178 @@ test("Bun.build can be called thousands of times in one process without crashing expect(stdout.trim()).toBe("OK 400"); expect(exitCode).toBe(0); }, 180_000); + +// https://github.com/oven-sh/bun/issues/30536 — Bun.build ignores inline +// `//# sourceMappingURL=` comments on input files. A `.vue` / `.svelte` / +// `.ts` file compiled to an intermediate `.js` with an inline sourcemap +// should have its authored sources surface in the final bundle's map. +describe("Bun.build chains inline input sourcemaps", () => { + // Build a tiny intermediate `.js` that carries an inline base64 sourcemap + // pointing at a fake "authored" source, then bundle an entry that imports + // it. The output map's `sources[]` should include the authored source, + // and `sourcesContent[]` should include the inner content verbatim + // (without the trailing `//# sourceMappingURL=` comment). + test("inline data: URL — authored source surfaces in bundled map", async () => { + const authoredSrc = "export const x = 5;\nthrow new Error('authored');\n"; + const innerMap = { + version: 3, + sources: ["authored.ts"], + sourcesContent: [authoredSrc], + names: [], + mappings: "AAAA;AACA;", + }; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap", { + "intermediate.js": authoredSrc + inline, + "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + + // The authored source name must appear somewhere in `sources[]`. + const sourcesJoined = parsed.sources.join("|"); + expect(sourcesJoined).toMatch(/authored\.ts/); + + // `sourcesContent` length must equal `sources` length (spec). + expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); + + // The slot for `authored.ts` must hold the clean authored content, no + // trailing `//# sourceMappingURL=` comment. + const authoredIdx = parsed.sources.findIndex((s: string) => s.endsWith("authored.ts")); + expect(authoredIdx).toBeGreaterThanOrEqual(0); + expect(parsed.sourcesContent[authoredIdx]).toBe(authoredSrc); + expect(parsed.sourcesContent[authoredIdx]).not.toMatch(/sourceMappingURL/); + }); + + // Non-base64 `data:application/json,` must work too — some + // toolchains emit the comment in that form. + test("inline data: URL without base64 — authored source surfaces", async () => { + const authoredSrc = "export const y = 1;\n"; + const innerMap = { + version: 3, + sources: ["authored.ts"], + sourcesContent: [authoredSrc], + names: [], + mappings: "AAAA;", + }; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-raw", { + "intermediate.js": + authoredSrc + `\n//# sourceMappingURL=data:application/json,${JSON.stringify(innerMap)}\n`, + "entry.ts": `import { y } from './intermediate.js';\nconsole.log(y);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + expect(parsed.sources.some((s: string) => s.endsWith("authored.ts"))).toBe(true); + }); + + // Inner map with multiple sources (e.g. a `.vue` compiler splitting + // template vs script into two virtual sources) — each must round-trip. + test("inline map with multiple inner sources — all surface", async () => { + const scriptSrc = "export const x = 5;\n"; + const templateSrc = "// template part\n"; + const innerMap = { + version: 3, + sources: ["component.vue?script", "component.vue?template"], + sourcesContent: [scriptSrc, templateSrc], + names: [], + mappings: "AAAA;ACAA;", + }; + const intermediate = scriptSrc + templateSrc; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-multi", { + "intermediate.js": intermediate + inline, + "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + expect(parsed.sources.some((s: string) => s.endsWith("component.vue?script"))).toBe(true); + expect(parsed.sources.some((s: string) => s.endsWith("component.vue?template"))).toBe(true); + expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); + }); + + // A malformed inline map must not break the build — we silently fall + // back to the intermediate as the deepest source. + test("malformed inline map — build succeeds and falls back", async () => { + const dir = tempDirWithFiles("bun-build-chained-sourcemap-bad", { + "intermediate.js": + "export const z = 2;\n//# sourceMappingURL=data:application/json;base64,!!!not-valid!!!\n", + "entry.ts": `import { z } from './intermediate.js';\nconsole.log(z);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + // Must still produce a valid output map — regression guard for the + // "parse failure kills the whole build" path. + const text = await Bun.file(result.outputs[0].path).text(); + expect(text).toMatch(/sourceMappingURL=data:application\/json;base64,/); + }); + + // Non-inline `sourceMappingURL=foo.js.map` references aren't chained + // (external map resolution is out of scope for this change). The build + // must behave exactly as before — the intermediate ends up as the + // deepest source, not a spurious crash. + test("external .map reference — unchanged behavior", async () => { + const dir = tempDirWithFiles("bun-build-chained-sourcemap-external", { + "intermediate.js": "export const q = 3;\n//# sourceMappingURL=intermediate.js.map\n", + "entry.ts": `import { q } from './intermediate.js';\nconsole.log(q);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + // No inner chain. The intermediate should be in sources[], not some + // phantom "authored.ts". + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); + }); +}); From 82f402524732e1830995cc512aaefc64fd357321 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 06:07:15 +0000 Subject: [PATCH 02/32] [autofix.ci] apply automated fixes --- src/sourcemap/InputSourceMap.zig | 2 +- test/bundler/bun-build-api.test.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/sourcemap/InputSourceMap.zig b/src/sourcemap/InputSourceMap.zig index 14a822257718..0cdf108eb9a5 100644 --- a/src/sourcemap/InputSourceMap.zig +++ b/src/sourcemap/InputSourceMap.zig @@ -214,5 +214,5 @@ fn parseDataUrl(url: []const u8) ?*InputSourceMap { return parse(payload); } -const std = @import("std"); const bun = @import("bun"); +const std = @import("std"); diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 68f650c03851..5708b390f663 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1460,8 +1460,7 @@ describe("Bun.build chains inline input sourcemaps", () => { }; const dir = tempDirWithFiles("bun-build-chained-sourcemap-raw", { - "intermediate.js": - authoredSrc + `\n//# sourceMappingURL=data:application/json,${JSON.stringify(innerMap)}\n`, + "intermediate.js": authoredSrc + `\n//# sourceMappingURL=data:application/json,${JSON.stringify(innerMap)}\n`, "entry.ts": `import { y } from './intermediate.js';\nconsole.log(y);\n`, }); @@ -1521,8 +1520,7 @@ describe("Bun.build chains inline input sourcemaps", () => { // back to the intermediate as the deepest source. test("malformed inline map — build succeeds and falls back", async () => { const dir = tempDirWithFiles("bun-build-chained-sourcemap-bad", { - "intermediate.js": - "export const z = 2;\n//# sourceMappingURL=data:application/json;base64,!!!not-valid!!!\n", + "intermediate.js": "export const z = 2;\n//# sourceMappingURL=data:application/json;base64,!!!not-valid!!!\n", "entry.ts": `import { z } from './intermediate.js';\nconsole.log(z);\n`, }); From c7885a579d626ade7794386bcc7b9291f115394b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 06:19:19 +0000 Subject: [PATCH 03/32] test: cover plugin onLoad with inline sourcemap (#6173) The ParseTask scan runs on `source.contents` regardless of whether the contents came from disk or an `onLoad` plugin return, so the plugin case from #6173 is covered by the same pipeline. Add an explicit regression test so it stays covered if the scanner ever moves. A custom-extension plugin that emits transformed JS with its own `//# sourceMappingURL=data:...` comment should have the pre-transform authored source show up in the final map's `sources[]` / `sourcesContent[]`. Uses a distinct inner-source filename so the slot assertion can tell the plugin intermediate apart from the chained authored source. --- test/bundler/bun-build-api.test.ts | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 5708b390f663..b86e12a44c06 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1563,4 +1563,62 @@ describe("Bun.build chains inline input sourcemaps", () => { // phantom "authored.ts". expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); }); + + // https://github.com/oven-sh/bun/issues/6173 — a plugin `onLoad` that + // transpiles and returns JS with an inline sourcemap comment should + // have the pre-transform authored source surface in the final map. + // The scanner runs on `source.contents` regardless of origin, so the + // plugin case rides on the same pipeline as the file case. + test("onLoad plugin returning JS with inline sourcemap — authored source surfaces", async () => { + const dir = tempDirWithFiles("bun-build-plugin-chained-sourcemap", { + "src.custom": "export const x = 42;\n", + "entry.ts": `import { x } from './src.custom';\nconsole.log(x);\n`, + }); + + // Use a distinct inner-source name so we can tell which `sources[]` + // slot is the plugin intermediate vs. which is the chained inner. + const authoredContent = "const x_authored_marker = 42;\nexport { x_authored_marker as x };\n"; + const innerMap = { + version: 3, + sources: ["original-authored.custom"], + sourcesContent: [authoredContent], + names: [], + mappings: "AAAA;", + }; + const inlineComment = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + plugins: [ + { + name: "custom-transpiler", + setup(build) { + build.onLoad({ filter: /\.custom$/ }, () => ({ + // Emit transformed JS carrying its own inline sourcemap + // pointing back at the authored `.custom` source. + contents: "export const x = 42;\n" + inlineComment, + loader: "js", + })); + }, + }, + ], + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + + // The authored-source slot (distinct filename) must be present and + // carry the pre-transform content verbatim. + expect(parsed.sources.some((s: string) => s.endsWith("original-authored.custom"))).toBe(true); + expect(parsed.sourcesContent).toHaveLength(parsed.sources.length); + + const authoredIdx = parsed.sources.findIndex((s: string) => s.endsWith("original-authored.custom")); + expect(parsed.sourcesContent[authoredIdx]).toBe(authoredContent); + }); }); From 64ed7f6a23dbab839227d02384d5058e9c5f0367 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 06:34:04 +0000 Subject: [PATCH 04/32] address review findings: OOM, leak, test guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude[bot] and coderabbit spotted three real issues in the initial landing. One refactor fixes the first two; the third is just more assertions. 1. InputSourceMap.parse: return `?*InputSourceMap` made the `errdefer` blocks dead code — Zig only fires `errdefer` on error returns, and the function had none. Every mid-parse `return null` (most realistically `Mapping.parse` rejecting a malformed VLQ, which passes all the JSON structure checks above it) leaked `source_paths_slice`, `sources_content_slice`, and every `allocator.dupe`d string on `bun.default_allocator` — never reclaimed for the life of the process, nasty in long-running dev-server / watch-mode callers. Split into a public `parse` + internal `parseInternal` that returns `ParseError!*InputSourceMap`. The `errdefer`s now fire on every malformed-payload bail. OOM propagates through the error union and the outer `parse` wraps it in `bun.outOfMemory()` (fatal), while validation failures collapse back to `null` — the original contract callers depend on. Ownership-transfer sites (`psm.external_source_names =`, building the result `InputSourceMap`) neuter the now-redundant `errdefer`s by zeroing `paths_written` / `contents_written` and re-pointing the slices at `&.{}`, so a future `try` added between transfer and return can't double-free. 2. bundle_v2 onParseTaskComplete: the new `input_source_map` slot was written without freeing any prior value, leaking on dev-server / watch-mode reparses where the same source index gets a fresh map. Also, the early-failure path where `runResolutionForParseTask` downgrades `.success` to `.err` was dropping the freshly parsed map without deinit. Both paths now explicitly `deinit()` before overwrite. The success transfer also nulls the result slot so the old layout can't be mistaken for still-owning after the move. 3. Test guards: four call sites pulled `m![1]` without first asserting `m` matched. Added `expect(m).not.toBeNull()` so a future output regression fails the test cleanly instead of surfacing as an unhelpful TypeError on the `m![1]` coercion. Rejected coderabbit's "restrict to file-backed inputs" suggestion: plugin `onLoad` returns are explicitly in scope (closes #6173) and covered by the test committed in 192b3558. A blanket `source.path.isFile()` gate would break that case. Rejected claude[bot]'s suggestion to add a hostile-VLQ regression test: it exposed a pre-existing panic inside `Mapping.parse` (`addScalar` → `fromZeroBased` assert on negative column delta), which is out of scope for this PR. The structural fix above removes the leak on every failure path anyway; the test was documentation- only and didn't need to sit atop a landmine. --- src/bundler/bundle_v2.zig | 17 ++++++- src/sourcemap/InputSourceMap.zig | 82 +++++++++++++++++++++--------- test/bundler/bun-build-api.test.ts | 4 ++ 3 files changed, 79 insertions(+), 24 deletions(-) diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index b0961e8986d1..c11f1a20cf14 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -2876,6 +2876,11 @@ pub const BundleV2 = struct { // build before link time, so saving the AST is safe. this.graph.ast.items(.import_records)[source_index.get()] = result.ast.import_records; + // Free the parsed inline `//# sourceMappingURL=` allocation + // that won't reach the graph — the `.err` overwrite below + // would otherwise drop the pointer on the floor. + if (result.input_source_map) |ism| ism.deinit(); + parse_result.value = .{ .err = .{ .err = err, @@ -3674,7 +3679,17 @@ pub const BundleV2 = struct { // Move ownership of the parsed inline `//# sourceMappingURL=` // onto the input file so the linker can chain through it. - graph.input_files.items(.input_source_map)[result.source.index.get()] = result.input_source_map; + // Incremental reparses (dev server, watch mode) hit this + // slot more than once per source index, so an existing + // map must be freed before the new one takes its place — + // otherwise each reparse leaks the prior `ParsedSourceMap` + // and its `sourcesContent` copies. + { + const slot = &graph.input_files.items(.input_source_map)[result.source.index.get()]; + if (slot.*) |old| old.deinit(); + slot.* = result.input_source_map; + result.input_source_map = null; + } debug("onParse({d}, {s}) = {d} imports, {d} exports", .{ result.source.index.get(), diff --git a/src/sourcemap/InputSourceMap.zig b/src/sourcemap/InputSourceMap.zig index 0cdf108eb9a5..3bae1bcdce98 100644 --- a/src/sourcemap/InputSourceMap.zig +++ b/src/sourcemap/InputSourceMap.zig @@ -28,13 +28,32 @@ pub fn deinit(this: *InputSourceMap) void { bun.destroy(this); } +/// Signals a malformed sourcemap payload — the callers treat this as +/// "no chain available" and fall back to the raw file bytes. `OutOfMemory` +/// is deliberately *not* wrapped into this: it propagates out via the +/// internal error union so `bun.handleOom` can take Bun's fatal path +/// instead of silently pretending the map didn't exist. +const InvalidSourceMapError = error{InvalidSourceMap}; +const ParseError = InvalidSourceMapError || std.mem.Allocator.Error; + /// Parse a sourcemap JSON blob intended to chain through a bundler input /// file. Returns an owned `*InputSourceMap` (free with `deinit`) or `null` -/// if the JSON doesn't look like a valid sourcemap. Non-fatal parse -/// failures return `null` — callers fall back to the raw file bytes. +/// when the payload is malformed — callers fall back to the raw file bytes. +/// Allocation failures bubble up via `bun.handleOom`. /// /// `json_bytes` is borrowed; the function copies out what it needs. pub fn parse(json_bytes: []const u8) ?*InputSourceMap { + return parseInternal(json_bytes) catch |err| switch (err) { + error.InvalidSourceMap => null, + error.OutOfMemory => bun.outOfMemory(), + }; +} + +/// Internal workhorse for `parse`. Returns an error union so `errdefer` +/// fires on malformed-payload bails — critical because JSON can pass the +/// structural checks but still have a malformed `mappings` VLQ, and we'd +/// otherwise leak everything allocated up to that point. +fn parseInternal(json_bytes: []const u8) ParseError!*InputSourceMap { const allocator = bun.default_allocator; var arena = bun.ArenaAllocator.init(allocator); @@ -52,36 +71,41 @@ pub fn parse(json_bytes: []const u8) ?*InputSourceMap { bun.ast.Stmt.Data.Store.reset(); } - var json = bun.json.parse(&json_src, &log, arena_allocator, false) catch return null; + var json = bun.json.parse(&json_src, &log, arena_allocator, false) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidSourceMap, + }; if (json.get("version")) |version| { - if (version.data != .e_number or version.data.e_number.value != 3.0) return null; + if (version.data != .e_number or version.data.e_number.value != 3.0) return error.InvalidSourceMap; } - const mappings_str = json.get("mappings") orelse return null; - if (mappings_str.data != .e_string) return null; + const mappings_str = json.get("mappings") orelse return error.InvalidSourceMap; + if (mappings_str.data != .e_string) return error.InvalidSourceMap; - const sources_paths = switch ((json.get("sources") orelse return null).data) { + const sources_paths = switch ((json.get("sources") orelse return error.InvalidSourceMap).data) { .e_array => |arr| arr, - else => return null, + else => return error.InvalidSourceMap, }; // `sourcesContent` is optional; when absent we leave every slot empty. const sources_content_opt: ?*bun.ast.E.Array = if (json.get("sourcesContent")) |sc| switch (sc.data) { .e_array => |arr| arr, .e_null => null, - else => return null, + else => return error.InvalidSourceMap, } else null; if (sources_content_opt) |arr| { - if (arr.items.len != sources_paths.items.len) return null; + if (arr.items.len != sources_paths.items.len) return error.InvalidSourceMap; } const source_count = sources_paths.items.len; - // Allocate sources_paths / sources_content slices up-front so we can - // errdefer their cleanup cleanly. - var source_paths_slice = allocator.alloc([]const u8, source_count) catch return null; + // Everything below is owned by `bun.default_allocator`, not the arena, + // because it survives past this function. `errdefer`s clean up on any + // thrown error — which now actually fires thanks to the error-union + // return type. + var source_paths_slice = try allocator.alloc([]const u8, source_count); var paths_written: usize = 0; errdefer { for (source_paths_slice[0..paths_written]) |p| allocator.free(p); @@ -89,13 +113,13 @@ pub fn parse(json_bytes: []const u8) ?*InputSourceMap { } for (sources_paths.items.slice()) |item| { - if (item.data != .e_string) return null; - const str = item.data.e_string.string(arena_allocator) catch return null; - source_paths_slice[paths_written] = allocator.dupe(u8, str) catch return null; + if (item.data != .e_string) return error.InvalidSourceMap; + const str = try item.data.e_string.string(arena_allocator); + source_paths_slice[paths_written] = try allocator.dupe(u8, str); paths_written += 1; } - var sources_content_slice = allocator.alloc([]const u8, source_count) catch return null; + var sources_content_slice = try allocator.alloc([]const u8, source_count); var contents_written: usize = 0; errdefer { for (sources_content_slice[0..contents_written]) |c| if (c.len > 0) allocator.free(c); @@ -105,11 +129,11 @@ pub fn parse(json_bytes: []const u8) ?*InputSourceMap { if (sources_content_opt) |arr| { for (arr.items.slice()) |item| { if (item.data == .e_string) { - const str = item.data.e_string.string(arena_allocator) catch return null; + const str = try item.data.e_string.string(arena_allocator); sources_content_slice[contents_written] = if (str.len == 0) "" else - allocator.dupe(u8, str) catch return null; + try allocator.dupe(u8, str); } else { // Non-strings (null, etc.) get empty content. sources_content_slice[contents_written] = ""; @@ -130,18 +154,30 @@ pub fn parse(json_bytes: []const u8) ?*InputSourceMap { .{ .allow_names = false, .sort = true }, )) { .success => |x| x, - .fail => return null, + .fail => |fail| switch (fail.err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidSourceMap, + }, }; const psm = bun.new(bun.SourceMap.ParsedSourceMap, map_data); psm.external_source_names = source_paths_slice; - // The `ref_count` was zero-initialized by `bun.new`; calling code holds - // the only reference, which is released via `InputSourceMap.deinit`. + // Ownership of `source_paths_slice` has transferred to `psm`; neuter + // the earlier `errdefer` so a later failure doesn't double-free via + // both it and `psm.deref()`. (Currently unreachable because nothing + // between here and `return result` can fail, but this keeps the + // invariant local and robust to future additions.) + paths_written = 0; + source_paths_slice = &.{}; const result = bun.new(InputSourceMap, .{ .map = psm, .sources_content = sources_content_slice, }); + // Ownership of `sources_content_slice` and `psm` has transferred to + // `result`; neuter their earlier `errdefer`s for the same reason. + contents_written = 0; + sources_content_slice = &.{}; return result; } @@ -202,7 +238,7 @@ fn parseDataUrl(url: []const u8) ?*InputSourceMap { if (is_base64) { const decoded_len = bun.base64.decodeLen(payload); - var buf = bun.default_allocator.alloc(u8, decoded_len) catch return null; + const buf = bun.handleOom(bun.default_allocator.alloc(u8, decoded_len)); defer bun.default_allocator.free(buf); const decoded = bun.base64.decode(buf, payload); if (!decoded.isSuccessful()) return null; diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index b86e12a44c06..c5b96133bef1 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1475,6 +1475,7 @@ describe("Bun.build chains inline input sourcemaps", () => { const text = await Bun.file(result.outputs[0].path).text(); const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); expect(parsed.sources.some((s: string) => s.endsWith("authored.ts"))).toBe(true); }); @@ -1510,6 +1511,7 @@ describe("Bun.build chains inline input sourcemaps", () => { const text = await Bun.file(result.outputs[0].path).text(); const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); expect(parsed.sources.some((s: string) => s.endsWith("component.vue?script"))).toBe(true); expect(parsed.sources.some((s: string) => s.endsWith("component.vue?template"))).toBe(true); @@ -1558,6 +1560,7 @@ describe("Bun.build chains inline input sourcemaps", () => { expect(result.success).toBe(true); const text = await Bun.file(result.outputs[0].path).text(); const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); // No inner chain. The intermediate should be in sources[], not some // phantom "authored.ts". @@ -1611,6 +1614,7 @@ describe("Bun.build chains inline input sourcemaps", () => { const text = await Bun.file(result.outputs[0].path).text(); const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); // The authored-source slot (distinct filename) must be present and From dd29d7d02e3130fb1278f4f2d89bf81b63e9be89 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 06:45:31 +0000 Subject: [PATCH 05/32] sourcemap: anchor findSourceMappingURL to the final line coderabbit flag on the inline map scanner: the old `lastIndexOf(source, "\n//# sourceMappingURL=")` would match the needle anywhere in the file, including inside a string / template literal that happens to contain the marker text. Per the Source Map spec the comment MUST sit on the last line, so rewrite `findSourceMappingURL` to trim trailing whitespace first, then only prefix-match the final line. Added two test-coverage improvements in the same commit: - New test "sourceMappingURL marker in body is ignored": crafts an intermediate that embeds a FULLY VALID inline map inside a template literal, then follows it with a plain `export` on the last line. Old scanner would chain through the embedded `hijack.ts` source; new scanner ignores it. - Strengthened "malformed inline map" assertion: decodes the output map and verifies `sources` lists the intermediate (not some fabricated path from the malformed payload) instead of just checking that a sourceMappingURL comment was emitted. Skipping coderabbit's third suggestion ("require `version` field") because Bun's existing `sourcemap.parseJSON` treats `version` as optional too (sourcemap.zig:82-86). Tightening it is worth doing but needs to happen in both places together; out of scope for this PR. --- src/sourcemap/InputSourceMap.zig | 32 ++++++++--------- test/bundler/bun-build-api.test.ts | 57 ++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/src/sourcemap/InputSourceMap.zig b/src/sourcemap/InputSourceMap.zig index 3bae1bcdce98..5b3dc3de8ea9 100644 --- a/src/sourcemap/InputSourceMap.zig +++ b/src/sourcemap/InputSourceMap.zig @@ -192,24 +192,24 @@ pub fn parseFromSource(source: []const u8) ?*InputSourceMap { return parseDataUrl(url); } -/// Find the trailing `//# sourceMappingURL=` comment in a file. The -/// spec calls for the *last* such comment, hence `lastIndexOf`. +/// Find the trailing `//# sourceMappingURL=` comment in a file. Per +/// the Source Map spec the comment MUST be on the last line of the file +/// (see "3. Source Map Format" / "Linking generated code to source maps"), +/// so we anchor to the final line rather than the first `lastIndexOf` +/// match — a string literal earlier in the file containing that needle +/// must not hijack the lookup. fn findSourceMappingURL(source: []const u8) ?[]const u8 { + // Trim trailing whitespace/newlines so a file that ends with + // `\n//# sourceMappingURL=...\n\n` still resolves to its final line. + const body = std.mem.trimRight(u8, source, " \r\n\t"); + if (body.len == 0) return null; + + const last_line_start = if (std.mem.lastIndexOfScalar(u8, body, '\n')) |i| i + 1 else 0; + const last_line = body[last_line_start..]; + const needle = "//# sourceMappingURL="; - // Require a preceding newline so we don't mis-detect the comment as - // the opening line of a compiled-away string literal etc. - const found = std.mem.lastIndexOf(u8, source, "\n" ++ needle) orelse { - // First line edge case: if the file literally starts with the - // comment, `lastIndexOf` with a leading newline would miss it. - if (bun.strings.hasPrefixComptime(source, needle)) { - const end = std.mem.indexOfScalarPos(u8, source, needle.len, '\n') orelse source.len; - return bun.strings.trim(source[needle.len..end], " \r\t"); - } - return null; - }; - const start = found + 1 + needle.len; - const end = std.mem.indexOfScalarPos(u8, source, start, '\n') orelse source.len; - return bun.strings.trim(source[start..end], " \r\t"); + if (!bun.strings.hasPrefixComptime(last_line, needle)) return null; + return bun.strings.trim(last_line[needle.len..], " \r\t"); } /// Decode `data:application/json[;base64],...` payloads. Returns `null` diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index c5b96133bef1..b1dcb17e1203 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1534,10 +1534,61 @@ describe("Bun.build chains inline input sourcemaps", () => { sourcemap: "inline", }); expect(result.success).toBe(true); - // Must still produce a valid output map — regression guard for the - // "parse failure kills the whole build" path. + + // Regression guard for the "parse failure kills the whole build" + // path: a valid output map must still be produced, and the deepest + // source must be the intermediate (no spurious chained source from + // the malformed payload). + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); + }); + + // Guard the last-line anchoring — a file that has a fully-valid + // `//# sourceMappingURL=` marker embedded EARLIER in the body (inside + // a template literal / multi-line string) but NO trailing comment must + // not get mis-chained off that in-body text. Without last-line + // anchoring, `lastIndexOf("\n//# sourceMappingURL=")` finds the + // embedded marker and chains through the fake payload — the authored + // "hijack.ts" would show up in the output sources. + test("sourceMappingURL marker in body is ignored (only trailing line counts)", async () => { + const hijackMap = { + version: 3, + sources: ["hijack.ts"], + sourcesContent: ["// i should not appear\n"], + names: [], + mappings: "AAAA;", + }; + const hijackInline = `//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(hijackMap)).toString("base64")}`; + // Embed the full valid inline comment inside a template literal so + // the file parses as JS, but the real trailing line is the plain + // `export` — no sourcemap comment at end-of-file. + const intermediate = ["export const doc = `", hijackInline, "`;", "export const val = 99;", ""].join("\n"); + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-nohijack", { + "intermediate.js": intermediate, + "entry.ts": `import { val } from './intermediate.js';\nconsole.log(val);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + const text = await Bun.file(result.outputs[0].path).text(); - expect(text).toMatch(/sourceMappingURL=data:application\/json;base64,/); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + // The in-body marker must not hijack the chain — `hijack.ts` must + // NOT appear as a source in the final map. + expect(parsed.sources.some((s: string) => s.endsWith("hijack.ts"))).toBe(false); + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); }); // Non-inline `sourceMappingURL=foo.js.map` references aren't chained From 557971bbfe3dc49ee29640828547c2fa95a66b9c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 06:49:41 +0000 Subject: [PATCH 06/32] address 3 review findings from claude[bot] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more real bugs in the chaining landing that claude[bot] walked through in detail. Fixing all three: 1. `InputSourceMap.zig`: passing `std.math.maxInt(i32)` as `sources_count` to `Mapping.parse` disabled its `source_index >= sources_count` bounds check. A malformed inline map whose VLQ segment referenced an index past the end of its own `sources[]` would parse successfully. Downstream, the Builder emits `1 + inner.source_index` unclamped and LinkerContext reserves exactly `1 + external_source_names.len` slots per file, so the out-of-range index silently aliases the NEXT input file's slot range in the output — stack traces from file A get misattributed to file B. Pass the real `source_count` so such maps hit the `.fail` path and we fall back cleanly. Regression test: `inline map with out-of-range inner source_index is rejected` constructs a map with VLQ "AAAA;ACAA" (second mapping source_index = 1) against `sources: ["authored.ts"]` (len 1) and verifies `authored.ts` does NOT appear in the output `sources[]` after the fix. 2. `ParseTask.zig`: the dev-server HMR path in `runFromThreadPool` flips a parse-succeeded-with-errors `Success` into an `.err` by value-assigning a fresh `.err` payload and dropping the original `ast`. The prior commit already handled the resolve-error downgrade in `bundle_v2.runResolutionForParseTask`, but this second drop site leaks `ast.input_source_map` on every HMR rebuild of a file that both carries an inline map and logs parse errors. Deinit before the overwrite. 3. `LinkerContext.zig`: the `input_source_map` was plumbed into the print options unconditionally, but Bake's DevServer has its own sourcemap stitcher (`SourceMapStore.joinVLQ` + `PackedMap`) that hard-codes one `sources[]` slot per file and discards `chunk.end_state.source_index`. With `input_source_map` set, chunks now emit non-zero per-chunk `source_index` deltas that the DevServer stitcher would re-rebase across neighboring files' slots, corrupting served browser stack traces for any prebuilt `.js` carrying an inline `data:` sourcemap. Gate the field on `c.dev_server == null` until the DevServer stitcher is taught the slot-expansion layout (follow-up, separate PR). `test/bake/dev/sourcemap.test.ts` still passes; the existing Bun.build (non-dev) suite still passes too. Gate-check: `bun bd test bun-build-api` → 45/45 + 1 todo, `bake sourcemap.test.ts` → 2/2. --- src/bundler/LinkerContext.zig | 12 +++++++- src/bundler/ParseTask.zig | 6 ++++ src/sourcemap/InputSourceMap.zig | 9 +++++- test/bundler/bun-build-api.test.ts | 45 ++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/bundler/LinkerContext.zig b/src/bundler/LinkerContext.zig index 5a3fac60f9f3..bd5e2784e49f 100644 --- a/src/bundler/LinkerContext.zig +++ b/src/bundler/LinkerContext.zig @@ -1430,7 +1430,17 @@ pub const LinkerContext = struct { c, ), .line_offset_tables = c.graph.files.items(.line_offset_table)[source_index.get()], - .input_source_map = c.parse_graph.input_files.items(.input_source_map)[source_index.get()], + // Bake's DevServer has its own sourcemap stitcher + // (`SourceMapStore.joinVLQ` / `PackedMap`) that hard-codes one + // `sources[]` slot per file and discards `chunk.end_state.source_index`. + // The per-chunk `source_index` remapping this field enables + // would corrupt served stack traces there until that stitcher + // is taught the slot-expansion layout. Feed the inline map + // only on the non-dev-server path for now. + .input_source_map = if (c.dev_server == null) + c.parse_graph.input_files.items(.input_source_map)[source_index.get()] + else + null, .target = c.options.target, .hmr_ref = if (c.options.output_format == .internal_bake_dev) diff --git a/src/bundler/ParseTask.zig b/src/bundler/ParseTask.zig index d0799a8bfeb2..00d4978715ca 100644 --- a/src/bundler/ParseTask.zig +++ b/src/bundler/ParseTask.zig @@ -1395,6 +1395,12 @@ pub fn runFromThreadPool(this: *ParseTask) void { // When using HMR, always flag asts with errors as parse failures. // Not done outside of the dev server out of fear of breaking existing code. if (this.ctx.transpiler.options.dev_server != null and ast.log.hasErrors()) { + // The `.success` payload is being dropped on the floor + // by the `.err` overwrite — free any parsed inline + // sourcemap that would otherwise leak. Hot on HMR + // rebuilds of a file that both carries an inline map and + // logs parse errors. + if (ast.input_source_map) |ism| ism.deinit(); break :value .{ .err = .{ .err = error.SyntaxError, diff --git a/src/sourcemap/InputSourceMap.zig b/src/sourcemap/InputSourceMap.zig index 5b3dc3de8ea9..aa40d52ad27b 100644 --- a/src/sourcemap/InputSourceMap.zig +++ b/src/sourcemap/InputSourceMap.zig @@ -145,11 +145,18 @@ fn parseInternal(json_bytes: []const u8) ParseError!*InputSourceMap { contents_written = source_count; } + // `sources_count` bounds every `source_index` encoded in the VLQ + // mappings. The downstream consumers here (Chunk.Builder emits + // `1 + inner.source_index`; LinkerContext reserves exactly + // `1 + external_source_names.len` slots per file) DON'T defensively + // clamp — out-of-range indices would alias a neighboring input + // file's slot in the output sources[]. Pass the real source count + // so malformed maps hit `.fail` and we fall back cleanly. const map_data = switch (bun.SourceMap.Mapping.parse( allocator, mappings_str.data.e_string.slice(arena_allocator), null, - std.math.maxInt(i32), + std.math.cast(i32, source_count) orelse return error.InvalidSourceMap, std.math.maxInt(i32), .{ .allow_names = false, .sort = true }, )) { diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index b1dcb17e1203..7114ab29c276 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1546,6 +1546,51 @@ describe("Bun.build chains inline input sourcemaps", () => { expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); }); + // Inner map whose VLQ references `source_index >= sources.len` is + // malformed per the spec. Accepting it would alias the next input + // file's slot in the output `sources[]` (Chunk.Builder emits + // `1 + inner.source_index` unclamped; LinkerContext reserves exactly + // `1 + external_source_names.len` slots per file). Pass the real + // source count to `Mapping.parse` so the map gets rejected and we + // fall back to the intermediate. + test("inline map with out-of-range inner source_index is rejected", async () => { + // VLQ "AAAA;ACAA" = line 0: (0, 0, 0, 0); line 1: (0, +1, 0, 0) + // → second mapping references source_index = 1, but sources has + // only one entry. + const innerMap = { + version: 3, + sources: ["authored.ts"], + sourcesContent: ["// authored\n"], + names: [], + mappings: "AAAA;ACAA", + }; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-oob", { + "intermediate.js": "export const x = 1;\nexport const y = 2;\n" + inline, + "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir, "entry.ts")], + outdir: join(dir, "out"), + format: "esm", + target: "bun", + sourcemap: "inline", + }); + expect(result.success).toBe(true); + + const text = await Bun.file(result.outputs[0].path).text(); + const m = text.match(/\/\/# sourceMappingURL=data:application\/json(?:;charset=utf-?8)?;base64,(.+)/); + expect(m).not.toBeNull(); + const parsed = JSON.parse(Buffer.from(m![1], "base64").toString("utf-8")); + // The malformed map must be rejected — no `authored.ts` slot + // appears in the output, and no neighboring file's slot got + // aliased away. + expect(parsed.sources.some((s: string) => s.endsWith("authored.ts"))).toBe(false); + expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); + }); + // Guard the last-line anchoring — a file that has a fully-valid // `//# sourceMappingURL=` marker embedded EARLIER in the body (inside // a template literal / multi-line string) but NO trailing comment must From ccff53854c60ee77f207fbbc192b93d94c231dfe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 07:27:32 +0000 Subject: [PATCH 07/32] ci: retrigger debian-13-x64-asan-test-bun failed with exit 2 on 706b461 (single shard out of 20 parallel). All other ASAN build lanes passed. Local bun bd (ASAN on by default) is clean on the full bundler suite and integration tests. Can't scrape which test failed from Buildkite anonymously; rolling the dice one time. From 2597a8a5b8d7734d30ee55bab944fc0d70d0667f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 08:11:37 +0000 Subject: [PATCH 08/32] build: serialize release smoke-test against strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate hit this as `release with fix: BUILD FAILED` on my PR, but it's a pre-existing race that any release build from a clean state can trigger. Reproducing it is just: rm -f build/release/bun-zig.*.o build/release/bun scripts/build.ts --profile=release [1/4] link bun-profile [2/4] bun-profile --revision /bin/sh: 1: /workspace/bun/build/release/bun: Permission denied FAILED: bun-profile.smoke-test-passed [4/4] strip bun The smoke-test rule wraps its command in `${cfg.jsRuntime} stream.ts check --console ...`. In configure.ts, jsRuntime is hard-wired to `process.execPath` of whatever bun drove the build — and because `build/release/` is ahead of the system bin dir on the standard dev PATH, that's `build/release/bun`: the same file `strip bun` is about to write. Ninja schedules the smoke test and strip concurrently (no declared dep between them), the strip open(O_WRONLY) races the jsRuntime execve, and execve fails with EACCES. The race was masked by a warm cache: when the zig .o files are already present, strip and smoke-test both finish so fast the window closes. A clean zig recompile (PR commits touching anything the zig build reads, or any CI box starting cold) opens the window wide enough to hit reliably. Fix: make the stripped binary an order-only input of the smoke test. Ninja then serializes `strip bun` before `bun-profile --revision` so jsRuntime execve lands on a quiescent file. Confirmed: [1/5] link bun-profile [3/5] strip bun [3/5] bun-profile --revision [build] done Order-only (ninja's `||` edge) is the right tool here — the smoke test doesn't consume `bun` as input, it just needs it to exist and not be mid-write. Not an implicit dep, which would force a rebuild of the stamp whenever strip runs. Threaded through both the full-build (`emitBun`) and link-only (`emitLinkOnly`) emit paths. Non-strip configs (debug, asan) pass `undefined` and keep their current behavior — no stripped binary, no race. --- scripts/build/bun.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/scripts/build/bun.ts b/scripts/build/bun.ts index 4b9b4f3c08de..411e6a07d08b 100644 --- a/scripts/build/bun.ts +++ b/scripts/build/bun.ts @@ -514,7 +514,16 @@ export function emitBun(n: Ninja, cfg: Config, sources: Sources): BunOutput { // ASAN binaries to run from subprocesses (shadow memory layout conflict // with ELF_ET_DYN_BASE, see sanitizers/856). We try with setarch first, // fall back to direct invocation. - emitSmokeTest(n, cfg, exe, exeName); + // + // We order the smoke-test AFTER strip (when strip runs) because + // `cfg.jsRuntime` — the bun that wraps the smoke-test command — is + // typically `${buildDir}/bun` on a dev's PATH, i.e. the same file + // `strip` writes to. Without an explicit dep, ninja can schedule the + // smoke test concurrently with strip on a full rebuild and the + // jsRuntime invocation races against strip's write with an + // `EACCES`-family failure ("Permission denied"). Making the stripped + // binary an order-only input to the smoke test serializes them. + emitSmokeTest(n, cfg, exe, exeName, strippedExe); return { exe, strippedExe, dsym, deps, codegen, rustObjects, objects: allObjects }; } @@ -626,7 +635,10 @@ function emitLinkOnly(n: Ninja, cfg: Config): BunOutput { linkerMapOutput: cfg.linux && cfg.release && !cfg.asan && !cfg.valgrind ? linkerMapPath(cfg) : undefined, }); - // Strip + smoke test — same as full mode. + // Strip + smoke test — same as full mode. Pass `strippedExe` through + // to the smoke test as an order-only dep so ninja serializes `strip` + // against the smoke-test's `cfg.jsRuntime` (see the comment at the + // emit site above). let strippedExe: string | undefined; let dsym: string | undefined; if (shouldStrip(cfg)) { @@ -634,7 +646,7 @@ function emitLinkOnly(n: Ninja, cfg: Config): BunOutput { if (cfg.darwin) dsym = emitDsymutil(n, cfg, exe, exeName); } if (strippedExe === undefined) n.phony("bun", [exe]); - emitSmokeTest(n, cfg, exe, exeName); + emitSmokeTest(n, cfg, exe, exeName, strippedExe); return { exe, @@ -652,7 +664,7 @@ function emitLinkOnly(n: Ninja, cfg: Config): BunOutput { * linker didn't catch (missing symbol only referenced at init, ICU ABI * mismatch, etc.). */ -function emitSmokeTest(n: Ninja, cfg: Config, exe: string, exeName: string): void { +function emitSmokeTest(n: Ninja, cfg: Config, exe: string, exeName: string, strippedExe?: string): void { // Cross-compiled binaries can't run on the build host. Skip the smoke // test entirely — `ninja check` becomes a no-op alias for the exe. if (cfg.crossTarget !== undefined) { @@ -697,6 +709,10 @@ function emitSmokeTest(n: Ninja, cfg: Config, exe: string, exeName: string): voi outputs: [stamp], rule: "smoke_test", inputs: [exe], + // `strippedExe` as an order-only input keeps ninja from racing + // `strip bun` against this smoke test's use of the same file as + // `cfg.jsRuntime` on a dev's PATH. See the emit site above. + orderOnlyInputs: strippedExe !== undefined ? [strippedExe] : undefined, }); // Phony target — `ninja check` runs the smoke test. From 193fc5593f5138e4cec9b27cb5e0f96d11c2cc4a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 12 May 2026 08:36:44 +0000 Subject: [PATCH 09/32] sourcemap: swap std.mem.indexOfAny for bun.strings.indexAnyComptime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes `test/internal/ban-words.test.ts` failing uniformly across every CI platform (debian 13, 25.04, 3.23 x64+aarch64+baseline+asan; macos 14+26 aarch64+x64; windows 2019+11 x64+aarch64+baseline). The test greps `src/` for each phrase in `test/internal/ban-limits.json` and asserts the count is <= the configured limit; `std.mem.indexOfAny(u8` is set to 0 (reason: "Use bun.strings.indexOfAny") and this PR added exactly one call, pushing the count from 0 to 1. `bun.strings.indexAnyComptime(target, comptime chars) ?usize` is a drop-in with the same `orelse` shape and comptime needle — no behavior change. Other callers in `src/` (resolve_path.zig, package_json.zig, bunx_command.zig, patch.zig) use the same pattern. --- src/sourcemap/InputSourceMap.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sourcemap/InputSourceMap.zig b/src/sourcemap/InputSourceMap.zig index aa40d52ad27b..a46ca07833b6 100644 --- a/src/sourcemap/InputSourceMap.zig +++ b/src/sourcemap/InputSourceMap.zig @@ -235,7 +235,7 @@ fn parseDataUrl(url: []const u8) ?*InputSourceMap { while (rest.len > 0 and rest[0] == ';') { // Advance past one parameter up to the next ';' or ','. const after = rest[1..]; - const param_end = std.mem.indexOfAny(u8, after, ";,") orelse return null; + const param_end = bun.strings.indexAnyComptime(after, ";,") orelse return null; const param = after[0..param_end]; if (bun.strings.eqlComptime(param, "base64")) is_base64 = true; rest = after[param_end..]; From 61826564e794379d44c3f9552c76c9acca8e48f0 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 16 May 2026 02:32:55 +0000 Subject: [PATCH 10/32] test: gate chained-sourcemap tests behind describe.todo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root CLAUDE.md bans new behavior in .zig files — they're kept as a porting reference only. The 8 tests in this block exercise the chained input-sourcemap feature implemented in the .zig tree of this PR, which isn't wired into the live Rust bundler. Wrapping them in describe.todo keeps them as the intended-behavior spec for the eventual Rust port (per the file-by-file plan in the PR description) without red-lining every CI run. Flip back to describe() once InputSourceMap.rs + the Graph.rs / ParseTask.rs / LinkerContext.rs / Chunk.rs changes land. --- test/bundler/bun-build-api.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 7114ab29c276..e4f3ce26f5cc 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1396,7 +1396,15 @@ test("Bun.build can be called thousands of times in one process without crashing // `//# sourceMappingURL=` comments on input files. A `.vue` / `.svelte` / // `.ts` file compiled to an intermediate `.js` with an inline sourcemap // should have its authored sources surface in the final bundle's map. -describe("Bun.build chains inline input sourcemaps", () => { +// +// `describe.todo` because the bundler is being ported from Zig to Rust +// (#30412). The feature is implemented end-to-end in the `.zig` tree as +// the porting reference (see PR #30539 description for the file-by-file +// port plan), but `.zig` files no longer compile or ship — the active +// bundler path is Rust and has not been extended yet. These tests pin +// the intended behavior for when the Rust port lands; flip back to +// `describe(...)` at that point. +describe.todo("Bun.build chains inline input sourcemaps", () => { // Build a tiny intermediate `.js` that carries an inline base64 sourcemap // pointing at a fake "authored" source, then bundle an entry that imports // it. The output map's `sources[]` should include the authored source, From 313b504206aeef82b7cd706083094cea2ce0f726 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 16 May 2026 03:05:40 +0000 Subject: [PATCH 11/32] bundler: chain inline input sourcemaps through to output (Rust port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the Zig design in an earlier commit on this branch to Rust, since the bundler is now implemented in Rust (#30412). Same behavior, same 8 tests, different language. Files touched (Rust): - src/sourcemap/InputSourceMap.rs (new): owns *ParsedSourceMap + per-source contents; `parse` (validates v3 JSON, returns None on malformed), `parse_from_source` (last-line-anchored `//# sourceMappingURL=data:...` scan + base64/raw payload decode). - src/bundler/Graph.rs: `InputFile.input_source_map` column on the MultiArrayList + SoA accessor via `multi_array_columns!`. - src/bundler/ParseTask.rs: after getAST, scan `source.contents` for an inline map; gated on `source_map != None` + `can_have_source_map` + non-empty contents. Field added to `Success`. - src/bundler/ServerComponentParseTask.rs: wrapper Success constructor gets `input_source_map: None` (generated, not authored). - src/bundler/bundle_v2.rs: move from Success into the Graph SoA slot on parse completion; drain slots in `deinit_without_freeing_arena`. - src/bundler/LinkerContext.rs: `generate_source_map_for_chunk` expands outer `sources[]` to [intermediate, inner_0, …, inner_N-1] and mirrors `sourcesContent[]`; stitch the absolute source_index using `base + chunk.end_state.source_index`. Option threaded through to the printer, gated on `dev_server.is_none()` (Bake's SourceMapStore is a separate stitcher with a different layout). - src/sourcemap/Chunk.rs: `Builder.input_source_map`; in `add_source_mapping`, look up the intermediate (line, col) via `find_mapping`. On hit emit `mapped_source_index = 1 + inner.source_index` + inner's (line, col); on miss fall back to slot 0. - src/js_printer/lib.rs: `Options.input_source_map` forwarded into the builder in `get_source_map_builder`. Test suite in test/bundler/bun-build-api.test.ts flipped back from `describe.todo` to `describe` — all 8 pass: - base64 data URL: authored source surfaces - raw data URL: authored source surfaces - multi-inner-source map (e.g. .vue split): all surface - malformed payload: build succeeds, falls back - out-of-range inner source_index: rejected, no slot aliasing - in-body sourceMappingURL marker: ignored, no hijack - external .map reference: unchanged behavior - plugin onLoad inline map: regression guard for #6173 Closes #30536, also fixes #6173. --- src/bundler/Graph.rs | 8 + src/bundler/LinkerContext.rs | 212 +++++++++++++------ src/bundler/ParseTask.rs | 30 +++ src/bundler/ServerComponentParseTask.rs | 3 + src/bundler/bundle_v2.rs | 22 ++ src/js_printer/lib.rs | 22 ++ src/sourcemap/Chunk.rs | 49 ++++- src/sourcemap/InputSourceMap.rs | 264 ++++++++++++++++++++++++ src/sourcemap/lib.rs | 4 + test/bundler/bun-build-api.test.ts | 10 +- 10 files changed, 554 insertions(+), 70 deletions(-) create mode 100644 src/sourcemap/InputSourceMap.rs diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index 4de30205a97f..01dd393bb89e 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -107,6 +107,13 @@ pub struct InputFile { pub unique_key_for_additional_file: Box<[u8], AstAlloc>, pub content_hash_for_additional_file: u64, pub flags: InputFileFlags, + /// When this file carried an inline `//# sourceMappingURL=data:...` + /// comment, the decoded inner map plus its `sourcesContent` bytes. The + /// linker expands outer `sources[]` / `sourcesContent[]` with these + /// inner entries and the `Chunk::Builder` remaps its mappings through + /// the inner `find_mapping` so final stack traces surface in the + /// authored source. `None` when no chain is available (most inputs). + pub input_source_map: Option>, } impl Default for InputFile { @@ -137,6 +144,7 @@ bun_collections::multi_array_columns! { unique_key_for_additional_file: Box<[u8], AstAlloc>, content_hash_for_additional_file: u64, flags: InputFileFlags, + input_source_map: Option>, } } diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 6bb2dec44cba..562148f6c6bf 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1092,70 +1092,62 @@ impl<'a> LinkerContext<'a> { let sources = self.parse_graph().input_files.items_source(); let quoted_source_map_contents = self.graph.files.items_quoted_source_contents(); + // DevServer uses a separate sourcemap stitcher (`SourceMapStore::join_vlq`) + // that hard-codes one `sources[]` slot per input; threading inner-map + // expansion through there would corrupt its output. `DevServer == None` + // gates the whole feature so the HMR path stays byte-identical. + let input_source_maps: Option<&[Option>]> = + if self.dev_server.is_none() { + Some(self.parse_graph().input_files.items_input_source_map()) + } else { + None + }; // Entries in `results` do not 1:1 map to source files, the mapping // is actually many to one, where a source file can have multiple chunks // in the sourcemap. // - // This hashmap is going to map: + // This hashmap maps: // `source_index` (per compilation) in a chunk // --> - // Which source index in the generated sourcemap, referred to - // as the "mapping source index" within this function to be distinct. + // Base source index in the generated sourcemap (inclusive). When + // the input file did not carry an inline sourcemap, the chunk's + // mappings all use that base. When the input file carried an + // inline `//# sourceMappingURL=`, the chunk's mappings were + // remapped through that inner map at print time and now span + // `base .. base + inner.external_source_names.len - 1`. let mut source_id_map: ArrayHashMap = ArrayHashMap::new(); let source_indices = results.items_source_index(); j.push_static(b"{\n \"version\": 3,\n \"sources\": ["); + let mut next_mapping_source_index: i32 = 0; if !source_indices.is_empty() { - { - let index = source_indices[0]; - let path = &sources[index as usize].path; - source_id_map.put_no_clobber(index, 0)?; - - // Note: the relative path lives in a local owned buffer - // (drops at scope exit). - let rel_path_storage; - let pretty: &[u8] = if path.is_file() { - rel_path_storage = - bun_paths::resolve_path::relative_alloc(chunk_abs_dir, path.text)?; - &rel_path_storage - } else { - path.pretty - }; - - let mut quote_buf = MutableString::init(pretty.len() + 2)?; - js_printer::quote_for_json(pretty, &mut quote_buf, false)?; - // `to_default_owned` moves the buffer into the joiner - // (joiner owns it until `done`). - j.push_owned(quote_buf.to_default_owned()); - } - - let mut next_mapping_source_index: i32 = 1; - for &index in &source_indices[1..] { + for (chunk_i, &index) in source_indices.iter().enumerate() { let gop = source_id_map.get_or_put(index)?; if gop.found_existing { continue; } *gop.value_ptr = next_mapping_source_index; - next_mapping_source_index += 1; - - let path = &sources[index as usize].path; - - let rel_path_storage; - let pretty: &[u8] = if path.is_file() { - rel_path_storage = - bun_paths::resolve_path::relative_alloc(chunk_abs_dir, path.text)?; - &rel_path_storage - } else { - path.pretty + // `1` for the intermediate input, plus one slot per inner + // source listed in its `sourceMappingURL`. + let inner: Option<&bun_sourcemap::InputSourceMap> = input_source_maps + .and_then(|m| m[index as usize].as_deref()); + let expansion: i32 = 1 + match inner { + Some(ism) => i32::try_from(ism.map.external_source_names.len()) + .expect("int cast"), + None => 0, }; - - let mut quote_buf = MutableString::init(pretty.len() + ", ".len() + 2)?; - quote_buf.append_assume_capacity(b", "); - js_printer::quote_for_json(pretty, &mut quote_buf, false)?; - j.push_owned(quote_buf.to_default_owned()); + next_mapping_source_index += expansion; + + write_sources_for( + &mut j, + chunk_abs_dir, + &sources[index as usize].path, + inner, + chunk_i > 0, + )?; } } @@ -1163,20 +1155,39 @@ impl<'a> LinkerContext<'a> { let source_indices_for_contents = source_id_map.keys(); if !source_indices_for_contents.is_empty() { - j.push_static(b"\n "); - j.push_static( - quoted_source_map_contents[source_indices_for_contents[0] as usize] - .as_deref() - .unwrap_or(b""), - ); - - for &index in &source_indices_for_contents[1..] { - j.push_static(b",\n "); - j.push_static( - quoted_source_map_contents[index as usize] + let mut emitted_contents: usize = 0; + for &index in source_indices_for_contents.iter() { + // Slot 0: the intermediate input file's contents (already + // JSON-quoted by `compute_quoted_source_contents`). + { + let sep: &[u8] = if emitted_contents == 0 { + b"\n " + } else { + b",\n " + }; + j.push_static(sep); + 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 }); + emitted_contents += 1; + } + // Slots 1..N: inner sources' contents, if any. + if let Some(ism) = + input_source_maps.and_then(|m| m[index as usize].as_deref()) + { + for content in ism.sources_content.iter() { + j.push_static(b",\n "); + if !content.is_empty() { + let mut quote_buf = MutableString::init(content.len() + 2)?; + js_printer::quote_for_json(content, &mut quote_buf, false)?; + j.push_owned(quote_buf.to_default_owned()); + } else { + j.push_static(b"null"); + } + emitted_contents += 1; + } + } } } j.push_static(b"\n ],\n \"mappings\": \""); @@ -1216,7 +1227,12 @@ impl<'a> LinkerContext<'a> { )?; prev_end_state = chunk.end_state; - prev_end_state.source_index = mapping_source_index; + // If the input carried an inline map, `chunk.end_state.source_index` + // is the inner source_index of the last mapping within the chunk + // (the Builder emits remapped absolute-within-chunk indices). + // Otherwise it's 0. Either way, the final absolute index is + // `mapping_source_index + chunk.end_state.source_index`. + prev_end_state.source_index = mapping_source_index + chunk.end_state.source_index; prev_column_offset = chunk.final_generated_column; if prev_end_state.generated_line == 0 { @@ -1260,6 +1276,74 @@ impl<'a> LinkerContext<'a> { } } +/// Emit one outer source's quoted path, plus any inner source paths +/// contributed by its `//# sourceMappingURL=` (one slot per inner source, +/// in `external_source_names` order). `leading_comma` is true when this is +/// not the first path appended to the running `sources[]` array — we +/// prefix `", "` before the outer path in that case. +/// +/// Layout matches the one `Chunk::Builder` assumes in `Chunk.rs`: +/// slot 0 → the intermediate input (this outer file) +/// slot 1..N → inner `sources[i]` (chained) +fn write_sources_for( + joiner: &mut StringJoiner, + chunk_abs_dir: &[u8], + outer_path: &bun_paths::fs::Path, + input_map: Option<&bun_sourcemap::InputSourceMap>, + leading_comma: bool, +) -> Result<(), BunError> { + // 1) the intermediate input. + let rel_path_storage; + let pretty: &[u8] = if outer_path.is_file() { + rel_path_storage = + bun_paths::resolve_path::relative_alloc(chunk_abs_dir, outer_path.text)?; + &rel_path_storage + } else { + outer_path.pretty + }; + { + let mut quote_buf = MutableString::init(pretty.len() + ", ".len() + 2)?; + if leading_comma { + quote_buf.append_assume_capacity(b", "); + } + js_printer::quote_for_json(pretty, &mut quote_buf, false)?; + joiner.push_owned(quote_buf.to_default_owned()); + } + + // 2) inner sources, if any. Each inner `sources[i]` is resolved + // relative to the directory of the intermediate file it came from, + // then made relative to `chunk_abs_dir` (the chunk's output dir) for + // the emitted JSON. Absolute inner paths stay absolute before + // relativization. + if let Some(ism) = input_map { + let base_dir = bun_paths::resolve_path::dirname::< + bun_paths::resolve_path::platform::Auto, + >(outer_path.text); + for name in ism.map.external_source_names.iter() { + let name: &[u8] = name.as_ref(); + // Use `join_abs` to produce an absolute inner path (when the + // inner map emitted a relative source name) that can then be + // re-relativized against `chunk_abs_dir`. `join_abs` returns + // a borrow into a thread-local buffer; we copy out immediately + // via `relative_alloc`. + let abs_path: &[u8] = if bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { + name + } else { + bun_paths::resolve_path::join_abs::( + base_dir, name, + ) + }; + let rel_path = bun_paths::resolve_path::relative_alloc(chunk_abs_dir, abs_path)?; + + let mut quote_buf = MutableString::init(rel_path.len() + ", ".len() + 2)?; + quote_buf.append_assume_capacity(b", "); + js_printer::quote_for_json(&rel_path, &mut quote_buf, false)?; + joiner.push_owned(quote_buf.to_default_owned()); + } + } + Ok(()) +} + #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum ScanCssImportsResult { Ok, @@ -2207,6 +2291,17 @@ impl<'a> LinkerContext<'a> { // SAFETY: `self.mangled_props` is not mutated during printing; detached borrow // outlives only this call (see above). unsafe { bun_ptr::detach_lifetime_ref(&self.mangled_props) }; + // DevServer uses a separate sourcemap stitcher that hard-codes one + // `sources[]` slot per file; passing `input_source_map` would + // corrupt its output. Gate the whole feature on the Bun.build path. + let input_source_map: Option<&bun_sourcemap::InputSourceMap> = + if self.dev_server.is_none() { + parse_graph.input_files.items_input_source_map() + [source_index.get() as usize] + .as_deref() + } else { + None + }; let print_options = js_printer::Options { bundling: true, @@ -2255,6 +2350,7 @@ impl<'a> LinkerContext<'a> { } else { None }, + input_source_map, mangled_props: Some(mangled_props), ..Default::default() }; diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 67402efd94a7..6ca445a13239 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -177,6 +177,14 @@ pub struct Success { /// The package name from package.json, used for barrel optimization. pub package_name: ast::StoreStr, + + /// Decoded trailing inline `//# sourceMappingURL=data:...` inner map, + /// parsed from the source bytes. `None` when the file had no inline + /// sourcemap comment, when sourcemaps are disabled on the build, or + /// when the inline payload was malformed (caller silently falls back + /// to the raw file bytes). Moved into `graph.input_files.input_source_map` + /// by `on_parse_task_complete`. + pub input_source_map: Option>, } pub struct ResultError { @@ -2684,6 +2692,26 @@ pub mod parse_worker { *step = Step::Resolve; + // Chain any inline `//# sourceMappingURL=data:...` map the input + // file carries (e.g. a `.vue`/`.svelte` compiler's trailing + // comment on the intermediate `.js`) into the output sourcemap. + // This scan runs on `source.contents` whether they came from a + // file read or a plugin `onLoad` return, so this covers #6173 + // too. Gated on: + // - source maps enabled on the build (no cost otherwise) + // - loader can have source maps (js/ts/jsx/tsx; skip binary/asset) + // - non-empty contents (the scanner would find nothing) + // Malformed payloads return `None` and fall back cleanly. + let input_source_map: Option> = + if topts.source_map != options::SourceMapOption::None + && loader.can_have_source_map() + && !source.contents.is_empty() + { + bun_sourcemap::InputSourceMap::parse_from_source(&source.contents) + } else { + None + }; + Ok(Success { ast, source: source.clone(), @@ -2700,6 +2728,8 @@ pub mod parse_worker { } else { 0 }, + + input_source_map, }) } diff --git a/src/bundler/ServerComponentParseTask.rs b/src/bundler/ServerComponentParseTask.rs index e8a51b602cdb..7b3a3a1e4370 100644 --- a/src/bundler/ServerComponentParseTask.rs +++ b/src/bundler/ServerComponentParseTask.rs @@ -197,6 +197,9 @@ fn task_callback( unique_key_for_additional_file: bun_ast::StoreStr::EMPTY, content_hash_for_additional_file: 0, package_name: bun_ast::StoreStr::EMPTY, + // Server-component wrappers are generated, not authored — no inline + // sourcemap comment to chain through. + input_source_map: None, }) } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 6c390e509c65..0985bab00e99 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4806,6 +4806,16 @@ pub mod bv2_impl { // `memcpy` of `graph.ast`), and `CssChunk::asts` `forget()`s its // aliases, so this is the unique drop. { + // `input_source_map` columns hold owned `Box` + // (inner `Arc` + owned `sources_content` Vec) + // allocated from the global heap, not the AST arena. The + // slab-only `MultiArrayList::drop` would strand them, so + // drain explicitly before the slab is released. Matches the + // explicit-drain pattern kept for `css` below. + for m in self.graph.input_files.items_input_source_map_mut() { + drop(m.take()); + } + macro_rules! take_ast_cols { ($ast:expr) => {{ let ast = $ast; @@ -6923,6 +6933,18 @@ pub mod bv2_impl { // Record which loader we used for this file this.graph.input_files.items_loader_mut()[result_source_index] = result.loader; + // Transfer ownership of any decoded inline input sourcemap + // from the parse result onto the SoA slot. An earlier + // occupant (e.g. incremental reparse of a previously-loaded + // file) is dropped here — the `Box`'s Drop + // releases the inner `Arc` and the owned + // `sources_content` buffers. + { + let slot = &mut this.graph.input_files.items_input_source_map_mut() + [result_source_index]; + *slot = core::mem::take(&mut result.input_source_map); + } + bun_core::scoped_log!( Bundle, "onParse({}, {}) = {} imports, {} exports", diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index f85dde30ad1d..d68288de0cd2 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1313,6 +1313,16 @@ pub struct Options<'a> { /// builder (`ManuallyDrop`, never freed on the bundler path). pub line_offset_tables: Option<&'a SourceMap::line_offset_table::List>, + /// When `Some`, the bundler input file carried an inline + /// `//# sourceMappingURL=data:...` comment. The chunk builder + /// remaps each emitted mapping through this inner map so the final + /// output's `source_index`/`(original_line, original_column)` refer + /// to the authored source instead of the intermediate input. + /// `None` for files that don't carry an inline sourcemap, or for + /// the DevServer HMR path (which uses a separate stitcher that + /// hard-codes one `sources[]` slot per file). + pub input_source_map: Option<&'a SourceMap::InputSourceMap>, + pub mangled_props: Option<&'a crate::MangledProps>, } @@ -1372,6 +1382,7 @@ impl<'a> Default for Options<'a> { module_type: bundle_opts::Format::Esm, ts_enums: None, line_offset_tables: None, + input_source_map: None, mangled_props: None, } } @@ -7773,6 +7784,16 @@ pub fn get_source_map_builder( } let precomputed = opts.line_offset_tables.take(); + // PORT NOTE: Zig passed `?*InputSourceMap` directly; Rust holds the + // slot as `Option>` in the SoA and lends a + // `&InputSourceMap` through `Options.input_source_map`. Erase the + // lifetime to `'static` for the `Builder` field — the borrow lives in + // `Graph::input_files[i].input_source_map`, which outlives every + // `add_source_mapping` call (see field docs). + let input_source_map: Option<&'static crate::SourceMap::InputSourceMap> = + opts.input_source_map.take().map(|r| unsafe { + &*(r as *const _) + }); let mut builder = SourceMap::chunk::Builder { source_map: SourceMap::chunk::SourceMapFormat::init( // opts.source_map_allocator orelse opts.allocator — allocator dropped @@ -7781,6 +7802,7 @@ pub fn get_source_map_builder( cover_lines_without_mappings: true, approximate_input_line_count: tree.approximate_newline_count, prepend_count: IS_BUN_PLATFORM && generate_source_map == GenerateSourceMap::Lazy, + input_source_map, // `Options.line_offset_tables` is a borrow into shared linker // state; copy it bitwise via `ptr::read` into a // `ManuallyDrop` so dropping the `Builder` never frees borrowed diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index b7e87959f7ac..e655bfe5c817 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -360,6 +360,19 @@ pub struct NewBuilder { /// as `line_offset_table_byte_offset_list` above. pub line_offset_table_first_non_ascii: &'static [u32], + /// When set, the bundler/printer input file carried an inline + /// `//# sourceMappingURL=data:...` comment; `add_source_mapping` will + /// remap each mapping through its inner map so the emitted + /// `source_index` / original `(line, col)` refer to the authored + /// source instead of the bundler's intermediate input. Unset + /// otherwise — the emitted mapping uses the Builder's own + /// `prev_state.source_index` (the outer source's slot). + /// + /// Borrow lives in `Graph::input_files[i].input_source_map` + /// (`Option>`); the slot outlives every printer + /// invocation for the bundle pass, so the erased `'static` is safe. + pub input_source_map: Option<&'static crate::InputSourceMap>, + // This is a workaround for a bug in the popular "source-map" library: // https://github.com/mozilla/source-map/issues/261. The library will // sometimes return null when querying a source map unless every line @@ -395,6 +408,7 @@ impl Default for NewBuilder { has_prev_state: false, line_offset_table_byte_offset_list: &[], line_offset_table_first_non_ascii: &[], + input_source_map: None, line_starts_with_mapping: false, cover_lines_without_mappings: false, approximate_input_line_count: 0, @@ -753,6 +767,35 @@ impl NewBuilder { self.update_generated_line_and_column(output); + // Remap through the input's inline sourcemap if present. The + // intermediate input's `(original_line, original_column)` becomes + // the authored source's `(line, col)` via `find_mapping`. On + // hit, the emitted `source_index` is `1 + inner.source_index` — + // the layout `LinkerContext` uses for this file: + // slot 0 → the intermediate input + // 1 + inner_idx → inner `sources[inner_idx]` + // The emitted `source_index` is relative to the chunk's start + // (the Builder always begins with `prev_state.source_index = 0`); + // `LinkerContext` stitches the absolute base in when joining + // chunks. Mappings the inner map doesn't cover fall back to + // slot 0 (the intermediate) so stack traces land in the right + // file rather than silently disappearing. + let mut mapped_source_index: i32 = 0; + let mut mapped_original_line: i32 = original_line.max(0); + let mut mapped_original_column: i32 = original_column.max(0); + if let Some(ism) = self.input_source_map { + if let Some(inner) = ism.map.find_mapping( + crate::Ordinal::from_zero_based(mapped_original_line), + crate::Ordinal::from_zero_based(mapped_original_column), + ) { + mapped_source_index = 1 + inner.source_index; + mapped_original_line = inner.original.lines.zero_based(); + mapped_original_column = inner.original.columns.zero_based(); + } + // else: fall back to the intermediate (slot 0) using the + // (line, col) we already have in the intermediate. + } + // If this line doesn't start with a mapping and we're about to add a mapping // that's not at the start, insert a mapping first so the line starts with one. if self.cover_lines_without_mappings @@ -772,9 +815,9 @@ impl NewBuilder { self.append_mapping(SourceMapState { generated_line: self.prev_state.generated_line, generated_column: self.generated_column.max(0), - source_index: self.prev_state.source_index, - original_line: original_line.max(0), - original_column: original_column.max(0), + source_index: mapped_source_index, + original_line: mapped_original_line, + original_column: mapped_original_column, }); // This line now has a mapping on it, so don't insert another one diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs new file mode 100644 index 000000000000..bf3014558ebb --- /dev/null +++ b/src/sourcemap/InputSourceMap.rs @@ -0,0 +1,264 @@ +//! Per-input-file sourcemap used by the bundler to chain sourcemaps through +//! upstream compile steps (e.g. `.vue` → `.js`, `.svelte` → `.js`, +//! TypeScript plugins). When `Bun.build` reads an input file that carries +//! an inline `//# sourceMappingURL=data:application/json;...` comment, we +//! parse it into an `InputSourceMap` and store it on the file's +//! `Graph::InputFile`. `LinkerContext` then emits its `sources` / +//! `sourcesContent` in place of the intermediate, and `Chunk::Builder` +//! remaps each mapping through `map.find_mapping` during printing so stack +//! traces surface in the authored source. + +use std::sync::Arc; + +use bun_collections::VecExt; + +use crate::{Mapping, ParsedSourceMap}; + +/// Parsed inner sourcemap + per-source content bytes, owned. +/// +/// `map.external_source_names` holds the chained-in `sources[]`. +/// `sources_content[i]` is the inner file's `sourcesContent[i]`; an empty +/// slot (`b""`) means the inner map did not carry content for that source. +pub struct InputSourceMap { + pub map: Arc, + pub sources_content: Box<[Box<[u8]>]>, +} + +impl InputSourceMap { + /// Parse a sourcemap JSON blob intended to chain through a bundler input + /// file. Returns `None` when the payload is malformed — callers fall back + /// to the raw file bytes. Allocation failures panic via `handle_oom`. + /// + /// `json_bytes` is borrowed; the function copies out what it needs. + pub fn parse(json_bytes: &[u8]) -> Option> { + parse_internal(json_bytes).ok() + } + + /// Locate a trailing `//# sourceMappingURL=data:...` inline comment in + /// `source` and parse the embedded map. Returns `None` when no URL is + /// present, when the URL is not a data URL (e.g. a `.map` filename), or + /// when the payload fails to parse. External `.map` file resolution is + /// the caller's responsibility. + pub fn parse_from_source(source: &[u8]) -> Option> { + let url = find_source_mapping_url(source)?; + parse_data_url(url) + } +} + +/// Malformed input is indistinguishable from "no chain available" — callers +/// treat it as a silent fallback to the raw file bytes. +struct InvalidSourceMap; + +/// Workhorse returning `Result` so `?` fires cleanup on malformed-payload +/// bails — critical because JSON can pass the structural checks but still +/// have a malformed `mappings` VLQ, and we'd otherwise leak everything +/// allocated up to that point. Zig's `errdefer` becomes Rust's automatic +/// drop on early return. +fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourceMap> { + use bun_ast::StoreResetGuard as DataStoreScope; + + let arena = bun_alloc::Arena::new(); + let json_src = bun_ast::Source::init_path_string("sourcemap.json", json_bytes); + let mut log = bun_ast::Log::init(); + + // The JSON parser doesn't respect the supplied allocator for every + // alloc, so reset the AST store on entry and exit. + let _store_scope = DataStoreScope::new(); + + let json = bun_parsers::json::parse::(&json_src, &mut log, &arena) + .map_err(|_| InvalidSourceMap)?; + + if let Some(version) = json.get(b"version") { + match version.data.as_e_number() { + Some(n) if n.value == 3.0 => {} + _ => return Err(InvalidSourceMap), + } + } + + let mappings_str = json.get(b"mappings").ok_or(InvalidSourceMap)?; + let mut mappings_e_string = mappings_str.data.as_e_string().ok_or(InvalidSourceMap)?; + let mappings_slice: &[u8] = mappings_e_string.slice(&arena); + + let sources_paths = json + .get(b"sources") + .ok_or(InvalidSourceMap)? + .data + .as_e_array() + .ok_or(InvalidSourceMap)?; + + // `sourcesContent` is optional; when absent or null every slot is empty. + let sources_content_opt = match json.get(b"sourcesContent") { + None => None, + Some(v) => match v.data.as_e_array() { + Some(arr) => Some(arr), + None => { + // `null` is tolerated; other non-array values are malformed. + if matches!(v.data, bun_ast::ExprData::ENull(_)) { + None + } else { + return Err(InvalidSourceMap); + } + } + }, + }; + + if let Some(arr) = sources_content_opt { + if arr.items.len_u32() != sources_paths.items.len_u32() { + return Err(InvalidSourceMap); + } + } + + let source_count = sources_paths.items.len_u32() as usize; + + // Copy source paths out of the arena into owned storage. + let mut source_paths_slice: Vec> = Vec::with_capacity(source_count); + for item in sources_paths.items.slice() { + let mut estr = item.data.as_e_string().ok_or(InvalidSourceMap)?; + // handle_oom — fatal if OOM + let s = estr.string(&arena).expect("OOM"); + source_paths_slice.push(Box::<[u8]>::from(s)); + } + + // Copy source contents. Non-strings (null, etc.) and empty slots map to `b""`. + let mut sources_content_slice: Vec> = Vec::with_capacity(source_count); + if let Some(arr) = sources_content_opt { + for item in arr.items.slice() { + let slot: Box<[u8]> = if let Some(mut estr) = item.data.as_e_string() { + let s = estr.string(&arena).expect("OOM"); + if s.is_empty() { + Box::<[u8]>::from(&b""[..]) + } else { + Box::<[u8]>::from(s) + } + } else { + Box::<[u8]>::from(&b""[..]) + }; + sources_content_slice.push(slot); + } + } else { + for _ in 0..source_count { + sources_content_slice.push(Box::<[u8]>::from(&b""[..])); + } + } + + // `sources_count` bounds every `source_index` encoded in the VLQ + // mappings. The downstream consumers (`Chunk::Builder` emits + // `1 + inner.source_index`; `LinkerContext` reserves exactly + // `1 + external_source_names.len` slots per file) DON'T defensively + // clamp — out-of-range indices would alias a neighboring input file's + // slot in the output `sources[]`. Pass the real source count so + // malformed maps hit `Fail` and we fall back cleanly. + let sources_count_i32: i32 = i32::try_from(source_count).map_err(|_| InvalidSourceMap)?; + let map_data = match crate::mapping::parse( + mappings_slice, + None, + sources_count_i32, + i32::MAX as usize, + crate::mapping::ParseOptions { + allow_names: false, + sort: true, + }, + ) { + crate::ParseResult::Success(x) => x, + crate::ParseResult::Fail(_) => return Err(InvalidSourceMap), + }; + + let mut psm = map_data; + psm.external_source_names = source_paths_slice; + + Ok(Box::new(InputSourceMap { + map: Arc::new(psm), + sources_content: sources_content_slice.into_boxed_slice(), + })) +} + +/// Find the trailing `//# sourceMappingURL=` comment in a file. Per +/// the Source Map spec the comment MUST be on the last line of the file +/// (see "3. Source Map Format" / "Linking generated code to source maps"), +/// so we anchor to the final line rather than the first `last_index_of` +/// match — a string literal earlier in the file containing that needle +/// must not hijack the lookup. +fn find_source_mapping_url(source: &[u8]) -> Option<&[u8]> { + // Trim trailing whitespace/newlines so a file that ends with + // `\n//# sourceMappingURL=...\n\n` still resolves to its final line. + let mut end = source.len(); + while end > 0 { + let c = source[end - 1]; + if c == b' ' || c == b'\r' || c == b'\n' || c == b'\t' { + end -= 1; + } else { + break; + } + } + let body = &source[..end]; + if body.is_empty() { + return None; + } + + let last_line_start = match body.iter().rposition(|&b| b == b'\n') { + Some(i) => i + 1, + None => 0, + }; + let last_line = &body[last_line_start..]; + + const NEEDLE: &[u8] = b"//# sourceMappingURL="; + if !last_line.starts_with(NEEDLE) { + return None; + } + let mut url = &last_line[NEEDLE.len()..]; + // Trim trailing whitespace within the line (the final-line trim above + // already handled newlines, but intra-line `\r\n` style endings and + // stray spaces still need trimming). + while let Some(&last) = url.last() { + if last == b' ' || last == b'\r' || last == b'\t' { + url = &url[..url.len() - 1]; + } else { + break; + } + } + Some(url) +} + +/// Decode `data:application/json[;...;base64],...` payloads. Returns `None` +/// when the URL is not a supported data scheme. +fn parse_data_url(url: &[u8]) -> Option> { + const PREFIX: &[u8] = b"data:application/json"; + if !url.starts_with(PREFIX) || url.len() <= PREFIX.len() + 1 { + return None; + } + + // `data:application/json;charset=utf-8;base64,...` is permitted in the + // wild; tolerate any number of `;name[=value]` parameters between the + // prefix and the final `;base64,` / `,` separator. + let mut rest = &url[PREFIX.len()..]; + let mut is_base64 = false; + while !rest.is_empty() && rest[0] == b';' { + let after = &rest[1..]; + // Advance past one parameter up to the next ';' or ','. + let param_end = after.iter().position(|&b| b == b';' || b == b',')?; + let param = &after[..param_end]; + if param == b"base64" { + is_base64 = true; + } + rest = &after[param_end..]; + } + if rest.is_empty() || rest[0] != b',' { + return None; + } + let payload = &rest[1..]; + + if is_base64 { + let decoded_len = bun_base64::decode_len(payload); + let mut buf: Vec = vec![0u8; decoded_len]; + let decoded = bun_base64::decode(&mut buf, payload); + if !decoded.is_successful() { + return None; + } + InputSourceMap::parse(&buf[..decoded.count]) + } else { + // Not base64; treat the payload as the raw JSON text. + InputSourceMap::parse(payload) + } +} + +// ported from: src/sourcemap/InputSourceMap.zig diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index 2d58dd7c90bc..d995e49f8ea0 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -20,6 +20,10 @@ pub mod line_offset_table; pub mod mapping; #[path = "ParsedSourceMap.rs"] pub mod parsed_source_map; +#[path = "InputSourceMap.rs"] +pub mod input_source_map; + +pub use input_source_map::InputSourceMap; pub use bun_base64::vlq; pub use vlq::{VLQ, encode as encode_vlq}; diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index e4f3ce26f5cc..7114ab29c276 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1396,15 +1396,7 @@ test("Bun.build can be called thousands of times in one process without crashing // `//# sourceMappingURL=` comments on input files. A `.vue` / `.svelte` / // `.ts` file compiled to an intermediate `.js` with an inline sourcemap // should have its authored sources surface in the final bundle's map. -// -// `describe.todo` because the bundler is being ported from Zig to Rust -// (#30412). The feature is implemented end-to-end in the `.zig` tree as -// the porting reference (see PR #30539 description for the file-by-file -// port plan), but `.zig` files no longer compile or ship — the active -// bundler path is Rust and has not been extended yet. These tests pin -// the intended behavior for when the Rust port lands; flip back to -// `describe(...)` at that point. -describe.todo("Bun.build chains inline input sourcemaps", () => { +describe("Bun.build chains inline input sourcemaps", () => { // Build a tiny intermediate `.js` that carries an inline base64 sourcemap // pointing at a fake "authored" source, then bundle an entry that imports // it. The output map's `sources[]` should include the authored source, From 31fafb8558d7f5250b29b806273183b4c128fa9c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 03:07:37 +0000 Subject: [PATCH 12/32] [autofix.ci] apply automated fixes --- src/bundler/LinkerContext.rs | 36 ++++++++++++++++-------------------- src/bundler/ParseTask.rs | 18 +++++++++--------- src/js_printer/lib.rs | 8 ++++---- src/sourcemap/lib.rs | 4 ++-- 4 files changed, 31 insertions(+), 35 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 562148f6c6bf..e04d56a0a092 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1132,11 +1132,12 @@ impl<'a> LinkerContext<'a> { *gop.value_ptr = next_mapping_source_index; // `1` for the intermediate input, plus one slot per inner // source listed in its `sourceMappingURL`. - let inner: Option<&bun_sourcemap::InputSourceMap> = input_source_maps - .and_then(|m| m[index as usize].as_deref()); + let inner: Option<&bun_sourcemap::InputSourceMap> = + input_source_maps.and_then(|m| m[index as usize].as_deref()); let expansion: i32 = 1 + match inner { - Some(ism) => i32::try_from(ism.map.external_source_names.len()) - .expect("int cast"), + Some(ism) => { + i32::try_from(ism.map.external_source_names.len()).expect("int cast") + } None => 0, }; next_mapping_source_index += expansion; @@ -1173,9 +1174,7 @@ impl<'a> LinkerContext<'a> { emitted_contents += 1; } // Slots 1..N: inner sources' contents, if any. - if let Some(ism) = - input_source_maps.and_then(|m| m[index as usize].as_deref()) - { + if let Some(ism) = input_source_maps.and_then(|m| m[index as usize].as_deref()) { for content in ism.sources_content.iter() { j.push_static(b",\n "); if !content.is_empty() { @@ -1295,8 +1294,7 @@ fn write_sources_for( // 1) the intermediate input. let rel_path_storage; let pretty: &[u8] = if outer_path.is_file() { - rel_path_storage = - bun_paths::resolve_path::relative_alloc(chunk_abs_dir, outer_path.text)?; + rel_path_storage = bun_paths::resolve_path::relative_alloc(chunk_abs_dir, outer_path.text)?; &rel_path_storage } else { outer_path.pretty @@ -1316,9 +1314,9 @@ fn write_sources_for( // the emitted JSON. Absolute inner paths stay absolute before // relativization. if let Some(ism) = input_map { - let base_dir = bun_paths::resolve_path::dirname::< - bun_paths::resolve_path::platform::Auto, - >(outer_path.text); + let base_dir = bun_paths::resolve_path::dirname::( + outer_path.text, + ); for name in ism.map.external_source_names.iter() { let name: &[u8] = name.as_ref(); // Use `join_abs` to produce an absolute inner path (when the @@ -2294,14 +2292,12 @@ impl<'a> LinkerContext<'a> { // DevServer uses a separate sourcemap stitcher that hard-codes one // `sources[]` slot per file; passing `input_source_map` would // corrupt its output. Gate the whole feature on the Bun.build path. - let input_source_map: Option<&bun_sourcemap::InputSourceMap> = - if self.dev_server.is_none() { - parse_graph.input_files.items_input_source_map() - [source_index.get() as usize] - .as_deref() - } else { - None - }; + let input_source_map: Option<&bun_sourcemap::InputSourceMap> = if self.dev_server.is_none() + { + parse_graph.input_files.items_input_source_map()[source_index.get() as usize].as_deref() + } else { + None + }; let print_options = js_printer::Options { bundling: true, diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 6ca445a13239..9d429ef1b443 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2702,15 +2702,15 @@ pub mod parse_worker { // - loader can have source maps (js/ts/jsx/tsx; skip binary/asset) // - non-empty contents (the scanner would find nothing) // Malformed payloads return `None` and fall back cleanly. - let input_source_map: Option> = - if topts.source_map != options::SourceMapOption::None - && loader.can_have_source_map() - && !source.contents.is_empty() - { - bun_sourcemap::InputSourceMap::parse_from_source(&source.contents) - } else { - None - }; + let input_source_map: Option> = if topts.source_map + != options::SourceMapOption::None + && loader.can_have_source_map() + && !source.contents.is_empty() + { + bun_sourcemap::InputSourceMap::parse_from_source(&source.contents) + } else { + None + }; Ok(Success { ast, diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index d68288de0cd2..fee14ea7fc3f 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -7790,10 +7790,10 @@ pub fn get_source_map_builder( // lifetime to `'static` for the `Builder` field — the borrow lives in // `Graph::input_files[i].input_source_map`, which outlives every // `add_source_mapping` call (see field docs). - let input_source_map: Option<&'static crate::SourceMap::InputSourceMap> = - opts.input_source_map.take().map(|r| unsafe { - &*(r as *const _) - }); + let input_source_map: Option<&'static crate::SourceMap::InputSourceMap> = opts + .input_source_map + .take() + .map(|r| unsafe { &*(r as *const _) }); let mut builder = SourceMap::chunk::Builder { source_map: SourceMap::chunk::SourceMapFormat::init( // opts.source_map_allocator orelse opts.allocator — allocator dropped diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index d995e49f8ea0..7d9f95544835 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -12,6 +12,8 @@ use bun_collections::VecExt; // ── sibling modules ─────────────────────────────────────────────────────── #[path = "Chunk.rs"] pub mod chunk; +#[path = "InputSourceMap.rs"] +pub mod input_source_map; #[path = "InternalSourceMap.rs"] pub mod internal_source_map; #[path = "LineOffsetTable.rs"] @@ -20,8 +22,6 @@ pub mod line_offset_table; pub mod mapping; #[path = "ParsedSourceMap.rs"] pub mod parsed_source_map; -#[path = "InputSourceMap.rs"] -pub mod input_source_map; pub use input_source_map::InputSourceMap; From b72e85e008c4b3f3fd05acd13d00ed7084d7ac72 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 19 May 2026 09:51:42 +0000 Subject: [PATCH 13/32] sourcemap: trim whitespace on both sides of the URL after sourceMappingURL= Matches Zig's `bun.strings.trim(_, " \r\t")` at InputSourceMap.zig:219. A leading space after `=` (e.g. `//# sourceMappingURL= data:...`) is spec-invalid but some toolchains emit it, and `parse_data_url`'s prefix check would fail on the leading space without this. Flagged by claude[bot] as a port-fidelity divergence. --- src/sourcemap/InputSourceMap.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index bf3014558ebb..0bb9fcba8d99 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -206,9 +206,18 @@ fn find_source_mapping_url(source: &[u8]) -> Option<&[u8]> { return None; } let mut url = &last_line[NEEDLE.len()..]; - // Trim trailing whitespace within the line (the final-line trim above - // already handled newlines, but intra-line `\r\n` style endings and - // stray spaces still need trimming). + // Trim whitespace on both sides within the line. Matches Zig's + // `bun.strings.trim(_, " \r\t")`; a leading space after `=` (e.g. + // `//# sourceMappingURL= data:...`) is spec-invalid but some + // toolchains emit it, and `parse_data_url` would fail on the + // leading space without this. + while let Some(&first) = url.first() { + if first == b' ' || first == b'\r' || first == b'\t' { + url = &url[1..]; + } else { + break; + } + } while let Some(&last) = url.last() { if last == b' ' || last == b'\r' || last == b'\t' { url = &url[..url.len() - 1]; From 8255258a0b73c7714ad757cdc42c6b4d1db8edd0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 20 May 2026 05:51:59 +0000 Subject: [PATCH 14/32] bundler: init Graph::InputFile.input_source_map in Default impl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's #30875 replaced the auto-derived Default impl for InputFile with an explicit one, which I missed in the last rebase — CI caught the missing-field on every build-rust lane. Add the None init. Fixes the build-rust failure in build #56341. --- src/bundler/Graph.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index 01dd393bb89e..9973fa19c907 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -127,6 +127,7 @@ impl Default for InputFile { unique_key_for_additional_file: AstAlloc::vec().into_boxed_slice(), content_hash_for_additional_file: 0, flags: InputFileFlags::default(), + input_source_map: None, } } } From e33965ec63b552f0987a3d0030414d6f619bb0d2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 20 May 2026 06:25:01 +0000 Subject: [PATCH 15/32] sourcemap: drop unused Mapping import from InputSourceMap.rs Only ParsedSourceMap is referenced; the mapping::parse call is fully-qualified through the lowercase module path. Flagged by claude[bot]. --- src/sourcemap/InputSourceMap.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index 0bb9fcba8d99..d6e45a8f7211 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use bun_collections::VecExt; -use crate::{Mapping, ParsedSourceMap}; +use crate::ParsedSourceMap; /// Parsed inner sourcemap + per-source content bytes, owned. /// From 446bcc8efeeb191baab10cc0ad0b8251ad157908 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 20 May 2026 06:43:33 +0000 Subject: [PATCH 16/32] bundler: hoist source_map option before get_ast to avoid stacked-borrows UB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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]. --- src/bundler/ParseTask.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 9d429ef1b443..97d17f885b67 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2632,6 +2632,12 @@ pub mod parse_worker { // SAFETY: task.ctx backref valid for the bundle pass (outlives `'r`). let task_ctx = unsafe { task.ctx() }; let module_type = opts.module_type; + // Hoist the `source_map` flag before the tombstone: we need it + // below after `get_ast` runs, but reading `topts.source_map` there + // would touch the invalidated shared borrow (get_ast reborrows + // `(*transpiler).options` mutably via raw pointer, which pops + // `topts`'s tag under Stacked Borrows). Copy it out now. + let source_map_option = topts.source_map; // `topts` (a `&BundleOptions`) is dead past this point; the callees take // raw `*mut Transpiler` and reborrow `(*transpiler).options` mutably. let _ = topts; @@ -2702,7 +2708,7 @@ pub mod parse_worker { // - loader can have source maps (js/ts/jsx/tsx; skip binary/asset) // - non-empty contents (the scanner would find nothing) // Malformed payloads return `None` and fall back cleanly. - let input_source_map: Option> = if topts.source_map + let input_source_map: Option> = if source_map_option != options::SourceMapOption::None && loader.can_have_source_map() && !source.contents.is_empty() From 42e1787b75618b6bf069332aba0f1b00ee19030f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:51:04 +0000 Subject: [PATCH 17/32] sourcemap: drop redundant mut on estr bindings (-D unused-mut) 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). --- src/sourcemap/InputSourceMap.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index d6e45a8f7211..e8e2e0c08d97 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -113,7 +113,7 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc // Copy source paths out of the arena into owned storage. let mut source_paths_slice: Vec> = Vec::with_capacity(source_count); for item in sources_paths.items.slice() { - let mut estr = item.data.as_e_string().ok_or(InvalidSourceMap)?; + let estr = item.data.as_e_string().ok_or(InvalidSourceMap)?; // handle_oom — fatal if OOM let s = estr.string(&arena).expect("OOM"); source_paths_slice.push(Box::<[u8]>::from(s)); @@ -123,7 +123,7 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc let mut sources_content_slice: Vec> = Vec::with_capacity(source_count); if let Some(arr) = sources_content_opt { for item in arr.items.slice() { - let slot: Box<[u8]> = if let Some(mut estr) = item.data.as_e_string() { + let slot: Box<[u8]> = if let Some(estr) = item.data.as_e_string() { let s = estr.string(&arena).expect("OOM"); if s.is_empty() { Box::<[u8]>::from(&b""[..]) From e803a328f1bb28bcb5f6737124a219956d4845ad Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:23:06 +0000 Subject: [PATCH 18/32] sourcemap: hint find_line_with_hint from intermediate line, not remapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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]. --- src/sourcemap/Chunk.rs | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index e655bfe5c817..1ab1f659a135 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -373,6 +373,17 @@ pub struct NewBuilder { /// invocation for the bundle pass, so the erased `'static` is safe. pub input_source_map: Option<&'static crate::InputSourceMap>, + /// Last *intermediate-file* line emitted (before any `input_source_map` + /// remap), kept only to seed `find_line_with_hint`. When chaining is + /// active, `prev_state.original_line` holds the remapped *authored* line, + /// which is the wrong coordinate space for the intermediate's + /// line-offset table — using it as the hint would fail the O(1) fast + /// path on every token and fall through to binary search. This field + /// keeps the hint in the intermediate's space. `0` when no chaining is + /// active (then `prev_state.original_line` is already the intermediate + /// line, but reading this costs nothing). + pub prev_intermediate_line: i32, + // This is a workaround for a bug in the popular "source-map" library: // https://github.com/mozilla/source-map/issues/261. The library will // sometimes return null when querying a source map unless every line @@ -409,6 +420,7 @@ impl Default for NewBuilder { line_offset_table_byte_offset_list: &[], line_offset_table_first_non_ascii: &[], input_source_map: None, + prev_intermediate_line: 0, line_starts_with_mapping: false, cover_lines_without_mappings: false, approximate_input_line_count: 0, @@ -731,14 +743,16 @@ impl NewBuilder { let byte_offsets = self.line_offset_table_byte_offset_list; // The printer emits mappings in (mostly) source order, so the previous - // call's `original_line` is the right answer or one/two lines before - // it >95% of the time. Seed `find_line_with_hint` with it; the - // fallback is the same binary search as before. - let original_line = LineOffsetTable::find_line_with_hint( - byte_offsets, - loc, - self.prev_state.original_line as u32, - ); + // call's *intermediate* line is the right answer or one/two lines + // before it >95% of the time. Seed `find_line_with_hint` with it; the + // fallback is the same binary search as before. Hint from + // `prev_intermediate_line` (not `prev_state.original_line`) because the + // latter holds the remapped *authored* line when `input_source_map` is + // active — wrong coordinate space for this (intermediate) table, which + // would poison the fast path. Without chaining the two are equal. + let original_line = + LineOffsetTable::find_line_with_hint(byte_offsets, loc, self.prev_intermediate_line as u32); + self.prev_intermediate_line = original_line.max(0); let idx = original_line.max(0) as usize; // PERF: read the three columns directly instead of `list.get(idx)`. From 96fd1f23ec8dbcdb2c527a08df75dc983a839cee Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:25:03 +0000 Subject: [PATCH 19/32] [autofix.ci] apply automated fixes --- src/sourcemap/Chunk.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index 1ab1f659a135..b0c616f2f440 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -750,8 +750,11 @@ impl NewBuilder { // latter holds the remapped *authored* line when `input_source_map` is // active — wrong coordinate space for this (intermediate) table, which // would poison the fast path. Without chaining the two are equal. - let original_line = - LineOffsetTable::find_line_with_hint(byte_offsets, loc, self.prev_intermediate_line as u32); + let original_line = LineOffsetTable::find_line_with_hint( + byte_offsets, + loc, + self.prev_intermediate_line as u32, + ); self.prev_intermediate_line = original_line.max(0); let idx = original_line.max(0) as usize; From 4d879a333bc859af1fc5bde0a38eaa3b254b5727 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:50:35 +0000 Subject: [PATCH 20/32] js_printer: use transmute for input_source_map lifetime erasure (clippy) --- src/js_printer/lib.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index fee14ea7fc3f..22c2fa7f44b1 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -7790,10 +7790,22 @@ pub fn get_source_map_builder( // lifetime to `'static` for the `Builder` field — the borrow lives in // `Graph::input_files[i].input_source_map`, which outlives every // `add_source_mapping` call (see field docs). - let input_source_map: Option<&'static crate::SourceMap::InputSourceMap> = opts - .input_source_map - .take() - .map(|r| unsafe { &*(r as *const _) }); + let input_source_map: Option<&'static crate::SourceMap::InputSourceMap> = + opts.input_source_map.take().map(|r| { + // SAFETY: the referent lives in + // `Graph::input_files[i].input_source_map` (an `Option>` + // owned by the bundle graph), which is not dropped until after the + // whole chunk — including every `add_source_mapping` call that + // reads this borrow — has finished. Extending the borrow to + // `'static` only erases the lifetime tracked by the `Builder` + // field; the underlying `Box` outlives it. + unsafe { + core::mem::transmute::< + &crate::SourceMap::InputSourceMap, + &'static crate::SourceMap::InputSourceMap, + >(r) + } + }); let mut builder = SourceMap::chunk::Builder { source_map: SourceMap::chunk::SourceMapFormat::init( // opts.source_map_allocator orelse opts.allocator — allocator dropped From 80207831f87b50eb7cd50d476aa78822a09176c1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:39:41 +0000 Subject: [PATCH 21/32] sourcemap: align comments with main's port-note cleanup --- src/js_printer/lib.rs | 6 ------ src/sourcemap/InputSourceMap.rs | 15 ++++++--------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 22c2fa7f44b1..f241dd5919f5 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -7784,12 +7784,6 @@ pub fn get_source_map_builder( } let precomputed = opts.line_offset_tables.take(); - // PORT NOTE: Zig passed `?*InputSourceMap` directly; Rust holds the - // slot as `Option>` in the SoA and lends a - // `&InputSourceMap` through `Options.input_source_map`. Erase the - // lifetime to `'static` for the `Builder` field — the borrow lives in - // `Graph::input_files[i].input_source_map`, which outlives every - // `add_source_mapping` call (see field docs). let input_source_map: Option<&'static crate::SourceMap::InputSourceMap> = opts.input_source_map.take().map(|r| { // SAFETY: the referent lives in diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index e8e2e0c08d97..a2a892458a26 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -52,8 +52,8 @@ struct InvalidSourceMap; /// Workhorse returning `Result` so `?` fires cleanup on malformed-payload /// bails — critical because JSON can pass the structural checks but still /// have a malformed `mappings` VLQ, and we'd otherwise leak everything -/// allocated up to that point. Zig's `errdefer` becomes Rust's automatic -/// drop on early return. +/// allocated up to that point (cleanup rides on `Drop` at each early +/// return). fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourceMap> { use bun_ast::StoreResetGuard as DataStoreScope; @@ -206,11 +206,10 @@ fn find_source_mapping_url(source: &[u8]) -> Option<&[u8]> { return None; } let mut url = &last_line[NEEDLE.len()..]; - // Trim whitespace on both sides within the line. Matches Zig's - // `bun.strings.trim(_, " \r\t")`; a leading space after `=` (e.g. - // `//# sourceMappingURL= data:...`) is spec-invalid but some - // toolchains emit it, and `parse_data_url` would fail on the - // leading space without this. + // Trim whitespace (` `, `\r`, `\t`) on both sides within the line: a + // leading space after `=` (e.g. `//# sourceMappingURL= data:...`) is + // spec-invalid but some toolchains emit it, and `parse_data_url` + // would fail on the leading space without this. while let Some(&first) = url.first() { if first == b' ' || first == b'\r' || first == b'\t' { url = &url[1..]; @@ -269,5 +268,3 @@ fn parse_data_url(url: &[u8]) -> Option> { InputSourceMap::parse(payload) } } - -// ported from: src/sourcemap/InputSourceMap.zig From 731de5078c03fdab8a651d4276c271447c90fa84 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:56:21 +0000 Subject: [PATCH 22/32] bundler: chain external input sourcemaps + thread chains through the dev server Extends the inline input-sourcemap chaining from #30539 to also: 1. Load external '//# sourceMappingURL=foo.map' references from disk (resolved relative to the input file). data: URLs were already handled; now linked sidecar .map files are too. http(s):// and protocol-relative URLs are skipped. Missing or malformed sidecars fall back silently to mapping against the intermediate. 2. Thread chained input sourcemaps through the Bun.serve dev server. SourceMapStore previously hard-coded one sources[] slot per input file; PackedMap now carries the inner-source list and render_json/join_vlq emit and stitch them in slot order. The printer's input_source_map path is no longer gated on dev_server == None. ErrorReportRequest uses the new Entry::lookup_source to resolve a flat source_index back to the right path/contents. Fixes #26713: Bun.serve HTML routes that reference a pre-built .js with a --sourcemap=linked sidecar now surface the authored source in browser DevTools instead of the intermediate .js. --- src/bundler/LinkerContext.rs | 15 +- src/bundler/ParseTask.rs | 27 ++- src/runtime/bake/DevServer.rs | 59 +++++++ .../bake/DevServer/ErrorReportRequest.rs | 31 ++-- .../bake/dev_server/incremental_graph.rs | 5 + src/runtime/bake/dev_server/packed_map.rs | 47 ++++- .../bake/dev_server/source_map_store.rs | 128 +++++++++++--- src/sourcemap/InputSourceMap.rs | 31 ++++ test/bundler/bun-build-api.test.ts | 10 +- test/regression/issue/26713.test.ts | 160 ++++++++++++++++++ 10 files changed, 448 insertions(+), 65 deletions(-) create mode 100644 test/regression/issue/26713.test.ts diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index e04d56a0a092..106953c6a2a4 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -2289,15 +2289,12 @@ impl<'a> LinkerContext<'a> { // SAFETY: `self.mangled_props` is not mutated during printing; detached borrow // outlives only this call (see above). unsafe { bun_ptr::detach_lifetime_ref(&self.mangled_props) }; - // DevServer uses a separate sourcemap stitcher that hard-codes one - // `sources[]` slot per file; passing `input_source_map` would - // corrupt its output. Gate the whole feature on the Bun.build path. - let input_source_map: Option<&bun_sourcemap::InputSourceMap> = if self.dev_server.is_none() - { - parse_graph.input_files.items_input_source_map()[source_index.get() as usize].as_deref() - } else { - None - }; + // DevServer's sourcemap stitcher (`SourceMapStore::join_vlq`) now + // tracks per-file inner-source expansion via `PackedMap.inner_sources`, + // so chained input sourcemaps flow through both paths. + let input_source_map: Option<&bun_sourcemap::InputSourceMap> = + parse_graph.input_files.items_input_source_map()[source_index.get() as usize] + .as_deref(); let print_options = js_printer::Options { bundling: true, diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 67d8ff32a48f..3c535c873040 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2698,22 +2698,33 @@ pub mod parse_worker { *step = Step::Resolve; - // Chain any inline `//# sourceMappingURL=data:...` map the input - // file carries (e.g. a `.vue`/`.svelte` compiler's trailing - // comment on the intermediate `.js`) into the output sourcemap. - // This scan runs on `source.contents` whether they came from a - // file read or a plugin `onLoad` return, so this covers #6173 - // too. Gated on: + // Chain any `//# sourceMappingURL=` map the input file carries + // (e.g. a `.vue`/`.svelte` compiler's trailing comment on the + // intermediate `.js`, or a pre-bundled file with + // `--sourcemap=linked`) into the output sourcemap. This scan + // runs on `source.contents` whether they came from a file + // read or a plugin `onLoad` return, so this covers #6173 too. + // When the input lives on disk we also resolve external + // `.map` references relative to the input's directory + // (#26713). Gated on: // - source maps enabled on the build (no cost otherwise) // - loader can have source maps (js/ts/jsx/tsx; skip binary/asset) // - non-empty contents (the scanner would find nothing) - // Malformed payloads return `None` and fall back cleanly. + // Malformed payloads or unreadable `.map` files return `None` + // and fall back cleanly. let input_source_map: Option> = if source_map_option != options::SourceMapOption::None && loader.can_have_source_map() && !source.contents.is_empty() { - bun_sourcemap::InputSourceMap::parse_from_source(&source.contents) + if source.path.is_file() { + bun_sourcemap::InputSourceMap::parse_from_source_with_fs( + &source.contents, + source.path.name().dir_with_trailing_slash(), + ) + } else { + bun_sourcemap::InputSourceMap::parse_from_source(&source.contents) + } } else { None }; diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index f697101b5d75..4aad9816bf0d 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -3852,6 +3852,59 @@ impl<'a> HotUpdateContext<'a> { } } +/// Build the dev-server `InnerSource` list for a compiled file from its +/// parsed `//# sourceMappingURL=` chain (if any). Paths are resolved to +/// absolute against the intermediate file's directory so +/// `SourceMapStore::render_json` can `file://`-encode them like any other +/// source; contents are JSON-quoted for direct emission in `sourcesContent`. +fn collect_inner_sources( + input_source_map: Option<&bun_sourcemap::InputSourceMap>, + intermediate_path: &bun_paths::fs::Path<'_>, +) -> Box<[packed_map::InnerSource]> { + let Some(ism) = input_source_map else { + return Box::default(); + }; + let names = &ism.map.external_source_names; + if names.is_empty() { + return Box::default(); + } + let base_dir: &[u8] = if intermediate_path.is_file() { + intermediate_path.name().dir_with_trailing_slash() + } else { + b"" + }; + let mut out: Vec = Vec::with_capacity(names.len()); + let mut path_buf = bun_paths::path_buffer_pool::get(); + for (i, name) in names.iter().enumerate() { + let name: &[u8] = name.as_ref(); + let abs: &[u8] = if !base_dir.is_empty() + && !bun_paths::resolve_path::Platform::AUTO.is_absolute(name) + { + bun_paths::resolve_path::join_abs_string_buf::( + base_dir, + &mut **path_buf, + &[name], + ) + } else { + name + }; + let escaped = match ism.sources_content.get(i) { + Some(content) if !content.is_empty() => { + let mut buf = bun_core::MutableString::init(content.len() + 2) + .unwrap_or_else(|_| bun_core::MutableString::init_empty()); + let _ = bun_core::quote_for_json(content, &mut buf, false); + buf.list.into_boxed_slice() + } + _ => Box::default(), + }; + out.push(packed_map::InnerSource { + path: Box::<[u8]>::from(abs), + escaped_content: escaped, + }); + } + out.into_boxed_slice() +} + /// Called at the end of BundleV2 to index bundle contents into the `IncrementalGraph`s /// This function does not recover DevServer state if it fails (allocation failure) pub(super) fn finalize_bundle( @@ -3986,6 +4039,7 @@ pub(super) fn finalize_bundle( let html_chunks_mut = &mut html_rest[..n_html]; let input_file_sources = bv2.graph.input_files.items_source(); let input_file_loaders = bv2.graph.input_files.items_loader(); + let input_source_maps = bv2.graph.input_files.items_input_source_map(); let import_records = bv2.graph.ast.items_import_records(); let targets = bv2.graph.ast.items_target(); let scbs = bv2.graph.server_component_boundaries.slice(); @@ -4060,6 +4114,8 @@ pub(super) fn finalize_bundle( } }; let quoted_contents = "ed_source_contents[part_range.source_index.get() as usize]; + let source = &input_file_sources[part_range.source_index.get() as usize]; + let input_source_map = input_source_maps[part_range.source_index.get() as usize].as_deref(); match targets[part_range.source_index.get() as usize].bake_graph() { bake::Graph::Client => dev.client_graph.receive_chunk( &mut ctx, @@ -4075,6 +4131,7 @@ pub(super) fn finalize_bundle( escaped_source: quoted_contents .as_ref() .map(|v| v.as_slice().to_vec().into_boxed_slice()), + inner_sources: collect_inner_sources(input_source_map, &source.path), }), }, false, @@ -4093,6 +4150,7 @@ pub(super) fn finalize_bundle( escaped_source: quoted_contents .as_ref() .map(|v| v.as_slice().to_vec().into_boxed_slice()), + inner_sources: collect_inner_sources(input_source_map, &source.path), }), }, graph == bake::Graph::Ssr, @@ -7167,6 +7225,7 @@ fn extract_pathname_from_url(url: &[u8]) -> &[u8] { // Type aliases referenced throughout (Phase B will resolve to real paths) use crate::bake::dev_server::incremental_graph; +use crate::bake::dev_server::packed_map; use crate::bake::dev_server::route_bundle; use crate::bake::dev_server::serialized_failure; use crate::bake::dev_server::source_map_store; diff --git a/src/runtime/bake/DevServer/ErrorReportRequest.rs b/src/runtime/bake/DevServer/ErrorReportRequest.rs index 9d35c13c0e80..c0958b1b841a 100644 --- a/src/runtime/bake/DevServer/ErrorReportRequest.rs +++ b/src/runtime/bake/DevServer/ErrorReportRequest.rs @@ -245,8 +245,8 @@ impl ErrorReportRequest { line_start_byte: 0, }; let index = remapped_position.source_index; - if index >= 1 && (index as usize - 1) < result.file_paths.len() { - let abs_path: &[u8] = &result.file_paths[index as usize - 1]; + if let Some(src) = result.entry.lookup_source(index as usize) { + let abs_path: &[u8] = src.path; frame.source_url = BunString::init(abs_path); let mut relative_path_buf = path_buffer_pool::get(); let rel_path = dev.relative_path(&mut relative_path_buf, abs_path); @@ -255,21 +255,18 @@ impl ErrorReportRequest { } frame.remapped = true; - if runtime_lines.is_none() { - let file = &result.entry_files[index as usize - 1]; - if let Some(source_map) = file.get() { - let json_encoded_source_code = source_map.quoted_contents(); - // First line of interest is two above the target line. - let target_line = frame.position.line.zero_based() as usize; - first_line_of_interest = target_line.saturating_sub(2); - region_of_interest_line = (target_line - first_line_of_interest) as u32; - runtime_lines = extract_json_encoded_source_code::<5>( - json_encoded_source_code, - first_line_of_interest as u32, - &arena, - )?; - top_frame_position = frame.position; - } + if runtime_lines.is_none() && !src.escaped_content.is_empty() { + let json_encoded_source_code = src.escaped_content; + // First line of interest is two above the target line. + let target_line = frame.position.line.zero_based() as usize; + first_line_of_interest = target_line.saturating_sub(2); + region_of_interest_line = (target_line - first_line_of_interest) as u32; + runtime_lines = extract_json_encoded_source_code::<5>( + json_encoded_source_code, + first_line_of_interest as u32, + &arena, + )?; + top_frame_position = frame.position; } } else if index == 0 { // Should be picked up by above but just in case. diff --git a/src/runtime/bake/dev_server/incremental_graph.rs b/src/runtime/bake/dev_server/incremental_graph.rs index 90109e3fec18..e46edc56eca1 100644 --- a/src/runtime/bake/dev_server/incremental_graph.rs +++ b/src/runtime/bake/dev_server/incremental_graph.rs @@ -209,6 +209,9 @@ pub enum InsertFailureKey<'a> { pub struct ReceiveChunkSourceMap { pub chunk: bun_sourcemap::Chunk, pub escaped_source: Option>, + /// Inner sources contributed by the input file's own + /// `//# sourceMappingURL=` chain. Empty when no chain is present. + pub inner_sources: Box<[packed_map::InnerSource]>, } pub enum ReceiveChunkContent { @@ -692,6 +695,7 @@ impl IncrementalGraph { packed_map::Shared::Some(packed_map::PackedMap::new_non_empty( &mut sm.chunk, sm.escaped_source.take().expect("escaped_source"), + core::mem::take(&mut sm.inner_sources), )) } _ => { @@ -818,6 +822,7 @@ impl IncrementalGraph { packed_map::Shared::Some(packed_map::PackedMap::new_non_empty( &mut sm.chunk, sm.escaped_source.take().unwrap(), + core::mem::take(&mut sm.inner_sources), )) } _ => packed_map::Shared::LineCount(packed_map::LineCount::init(line_count)), diff --git a/src/runtime/bake/dev_server/packed_map.rs b/src/runtime/bake/dev_server/packed_map.rs index fb438fc6d7e1..92400edf9154 100644 --- a/src/runtime/bake/dev_server/packed_map.rs +++ b/src/runtime/bake/dev_server/packed_map.rs @@ -7,13 +7,29 @@ use std::rc::Rc; /// Line count newtype. pub(crate) type LineCount = bun_core::GenericIndex; -/// `PackedMap.end_state` — only the two fields the bundler needs to thread -/// between chunks (generated_column is always 0 because minification is off, +/// `PackedMap.end_state` — the fields the bundler needs to thread between +/// chunks (generated_column is always 0 because minification is off, /// generated_line is recomputed per concatenation). #[derive(Copy, Clone, Default)] pub struct EndState { pub original_line: i32, pub original_column: i32, + /// Chunk-local source index of the last emitted mapping. + /// `0` → the input file itself (the intermediate); `1 + i` → inner + /// source `i` from the input file's `//# sourceMappingURL=` chain. + /// Only nonzero when `inner_sources` is non-empty. + pub source_index: i32, +} + +/// An inner source contributed by the input file's own +/// `//# sourceMappingURL=` comment (inline `data:` or external `.map`). +/// One per entry in the input sourcemap's `sources[]`; `path` is the +/// resolved absolute path (or the raw name when not resolvable) and +/// `escaped_content` is the JSON-quoted `sourcesContent[i]` (empty when +/// the input map did not carry content for that slot). +pub struct InnerSource { + pub path: Box<[u8]>, + pub escaped_content: Box<[u8]>, } /// Packed source mapping data for a single file. @@ -25,10 +41,18 @@ pub struct PackedMap { /// to preserve that effort for concatenation and re-concatenation. escaped_source: Box<[u8]>, pub end_state: EndState, + /// Inner sources contributed by the input file's own sourcemap. + /// Empty when the input carried no `//# sourceMappingURL=` chain (the + /// common case). + pub inner_sources: Box<[InnerSource]>, } impl PackedMap { - pub fn new_non_empty(chunk: &mut bun_sourcemap::Chunk, escaped_source: Box<[u8]>) -> Rc { + pub fn new_non_empty( + chunk: &mut bun_sourcemap::Chunk, + escaped_source: Box<[u8]>, + inner_sources: Box<[InnerSource]>, + ) -> Rc { let buffer = &mut chunk.buffer; debug_assert!(!buffer.is_empty()); Rc::new(Self { @@ -37,13 +61,28 @@ impl PackedMap { end_state: EndState { original_line: chunk.end_state.original_line, original_column: chunk.end_state.original_column, + source_index: chunk.end_state.source_index, }, + inner_sources, }) } + /// How many `sources[]` slots this file occupies in the rendered + /// sourcemap: one for the intermediate input, plus one per inner source. + #[inline] + pub fn source_slot_count(&self) -> usize { + 1 + self.inner_sources.len() + } + #[inline] pub fn memory_cost(&self) -> usize { - self.vlq().len() + self.quoted_contents().len() + core::mem::size_of::() + let mut cost = self.vlq().len() + self.quoted_contents().len() + core::mem::size_of::(); + for inner in self.inner_sources.iter() { + cost += inner.path.len() + + inner.escaped_content.len() + + core::mem::size_of::(); + } + cost } #[inline] diff --git a/src/runtime/bake/dev_server/source_map_store.rs b/src/runtime/bake/dev_server/source_map_store.rs index 9c3372ca523e..6f2daeea8d87 100644 --- a/src/runtime/bake/dev_server/source_map_store.rs +++ b/src/runtime/bake/dev_server/source_map_store.rs @@ -82,7 +82,62 @@ pub struct Entry { pub overlapping_memory_cost: u32, } +/// Result of [`Entry::lookup_source`]. +pub struct SourceLookup<'a> { + /// Absolute path of the source. + pub path: &'a [u8], + /// JSON-quoted source contents for this slot (empty when unavailable). + pub escaped_content: &'a [u8], +} + impl Entry { + /// Total number of `sources[]` slots this entry occupies, excluding the + /// HMR runtime at slot 0. One per file plus one per inner source + /// contributed by a file's input-sourcemap chain. + pub fn source_slot_count(&self) -> usize { + let mut n = 0usize; + for f in self.files.iter() { + n += match f.get() { + Some(pm) => pm.source_slot_count(), + None => 1, + }; + } + n + } + + /// Resolve a rendered-sourcemap `source_index` back to the path / + /// content it names. Index 0 is the HMR runtime; indices 1.. cover the + /// flattened per-file slots in the same order `render_json` emits them. + pub fn lookup_source(&self, source_index: usize) -> Option> { + if source_index == 0 { + return None; + } + let mut base = 1usize; + for (path, file) in self.paths.iter().zip(self.files.iter()) { + let pm = file.get(); + let slots = match pm { + Some(pm) => pm.source_slot_count(), + None => 1, + }; + if source_index < base + slots { + let sub = source_index - base; + if sub == 0 { + return Some(SourceLookup { + path, + escaped_content: pm.map(|p| p.quoted_contents()).unwrap_or(b""), + }); + } + let inner = &pm?.inner_sources[sub - 1]; + return Some(SourceLookup { + path: &inner.path, + escaped_content: &inner.escaped_content, + }); + } + base += slots; + } + None + } + /// `SourceMapStore.Entry.renderMappings`. pub fn render_mappings(&self, kind: ChunkKind) -> Result, bun_core::Error> { let mut j = StringJoiner::default(); @@ -112,8 +167,17 @@ impl Entry { #[cfg(windows)] let mut buf = bun_paths::path_buffer_pool::get(); - for native_file_path in paths.iter() { - let native_file_path: &[u8] = native_file_path; + // Walk each file's outer path + inner-source paths together so slot + // order matches `join_vlq` / `sourcesContent` below. Files without a + // PackedMap (HTML, empty JS) have exactly one slot (their own path). + let path_iter = paths.iter().zip(map_files.iter()).flat_map(|(p, f)| { + let inner: &[packed_map::InnerSource] = match f.get() { + Some(pm) => &pm.inner_sources, + None => &[], + }; + core::iter::once::<&[u8]>(p).chain(inner.iter().map(|s| s.path.as_ref())) + }); + for native_file_path in path_iter { source_map_strings.extend_from_slice(b","); #[cfg(windows)] let path: &[u8] = @@ -198,18 +262,29 @@ impl Entry { let quoted_slice = source_map.quoted_contents(); if quoted_slice.is_empty() { debug_assert!(false); // vlq without source contents! - j.push_static(b",\"// Did not have source contents for this file.\n// This is a bug in Bun's bundler and should be reported with a reproduction.\""); - continue; + j.push_static(b"\"// Did not have source contents for this file.\n// This is a bug in Bun's bundler and should be reported with a reproduction.\""); + } else { + // Store the location of the source file. Since it is going + // to be stored regardless for use by the served source map. + // These 8 bytes per file allow remapping sources without + // reading from disk, as well as ensuring that remaps to + // this exact sourcemap can print the previous state of + // the code when it was modified. + debug_assert_eq!(quoted_slice[0], b'"'); + debug_assert_eq!(quoted_slice[quoted_slice.len() - 1], b'"'); + j.push_static(quoted_slice); + } + // Inner sources (slots 1..N for this file). An inner source may + // legitimately lack content (input map had no `sourcesContent` + // entry) — emit `null` there. + for inner in source_map.inner_sources.iter() { + j.push_static(b","); + if inner.escaped_content.is_empty() { + j.push_static(b"null"); + } else { + j.push_static(&inner.escaped_content); + } } - // Store the location of the source file. Since it is going - // to be stored regardless for use by the served source map. - // These 8 bytes per file allow remapping sources without - // reading from disk, as well as ensuring that remaps to - // this exact sourcemap can print the previous state of - // the code when it was modified. - debug_assert_eq!(quoted_slice[0], b'"'); - debug_assert_eq!(quoted_slice[quoted_slice.len() - 1], b'"'); - j.push_static(quoted_slice); } // This first mapping makes the bytes from line 0 column 0 to the next mapping j.push_static(br#"],"names":[],"mappings":"AAAA"#); @@ -294,14 +369,22 @@ impl Entry { // The runtime ends at line 2942 with })({ so modules start after that. let mut lines_between: u32 = runtime_line_count; - // Join all of the mappings together. + // Join all of the mappings together. Each file owns a + // contiguous run of `sources[]` slots: one for its own path + // (the intermediate), plus one per inner source it + // contributed via an input sourcemap chain. Within a file's + // VLQ chunk, `source_index` is chunk-local (0 = intermediate, + // 1+i = inner i); we rewrite the first segment of each chunk + // so chunk-local 0 lands at this file's base slot. + let mut next_source_index: usize = 1; // slot 0 = HMR runtime for (i, file) in map_files.iter().enumerate() { match file { packed_map::Shared::Some(source_map) => { - let source_index = i + 1; let content: &packed_map::PackedMap = source_map.as_ref(); + let base = next_source_index; + next_source_index += content.source_slot_count(); let start_state = SourceMapState { - source_index: i32::try_from(source_index).expect("int cast"), + source_index: i32::try_from(base).expect("int cast"), generated_line: i32::try_from(lines_between).expect("int cast"), generated_column: 0, original_line: 0, @@ -317,7 +400,8 @@ impl Entry { )?; prev_end_state = SourceMapState { - source_index: i32::try_from(source_index).expect("int cast"), + source_index: i32::try_from(base).expect("int cast") + + content.end_state.source_index, generated_line: 0, generated_column: 0, original_line: content.end_state.original_line, @@ -325,11 +409,13 @@ impl Entry { }; } packed_map::Shared::LineCount(count) => { + next_source_index += 1; lines_between += count.get(); // - Empty file has no breakpoints that could remap. // - Codegen of HTML files cannot throw. } packed_map::Shared::None => { + next_source_index += 1; // NOTE: It is too late to compute the line count since the bundled text may // have been freed already. For example, a HMR chunk is never persisted. // We could return an error here but what would be a better behavior for renderJSON and renderMappings? @@ -438,8 +524,7 @@ pub type EntryIndex = bun_core::GenericIndex; pub struct GetResult<'a> { pub index: EntryIndex, pub mappings: source_map::mapping::List, - pub file_paths: &'a [Box<[u8]>], - pub entry_files: &'a [packed_map::Shared], + pub entry: &'a Entry, } pub struct SourceMapStore { pub entries: ArrayHashMap, @@ -702,7 +787,7 @@ impl SourceMapStore { match source_map::mapping::parse( &vlq_bytes, None, - i32::try_from(entry.paths.len()).expect("int cast"), + i32::try_from(entry.source_slot_count()).expect("int cast"), 0, // unused Default::default(), ) { @@ -716,8 +801,7 @@ impl SourceMapStore { source_map::ParseResult::Success(mut psm) => Some(GetResult { index: EntryIndex::init(u32::try_from(index).expect("int cast")), mappings: core::mem::take(&mut psm.mappings), - file_paths: &entry.paths, - entry_files: &entry.files, + entry, }), } } diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index a2a892458a26..358847bda106 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -43,6 +43,37 @@ impl InputSourceMap { let url = find_source_mapping_url(source)?; parse_data_url(url) } + + /// Like [`parse_from_source`] but also resolves external `.map` + /// references (non-`data:` URLs) relative to `source_dir` and reads + /// them from disk. Used by the bundler when the input file lives in + /// the `file` namespace. `http(s)://` and other remote schemes are + /// skipped. Failure to read the sidecar file returns `None` (the + /// build falls back to mapping against the intermediate). + pub fn parse_from_source_with_fs( + source: &[u8], + source_dir: &[u8], + ) -> Option> { + let url = find_source_mapping_url(source)?; + if bun_core::strings::has_prefix_comptime(url, b"data:") { + return parse_data_url(url); + } + // Skip remote / protocol-relative references; only local paths are + // loadable during bundling. + if bun_core::strings::contains_comptime(url, b"://") + || bun_core::strings::has_prefix_comptime(url, b"//") + { + return None; + } + let mut buf = bun_paths::path_buffer_pool::get(); + let abs = bun_paths::resolve_path::join_abs_string_buf::( + source_dir, + &mut buf, + &[url], + ); + let bytes = bun_sys::File::read_from(bun_core::Fd::cwd(), abs).ok()?; + InputSourceMap::parse(&bytes) + } } /// Malformed input is indistinguishable from "no chain available" — callers diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 7114ab29c276..7a27cdc17ba9 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1636,11 +1636,11 @@ describe("Bun.build chains inline input sourcemaps", () => { expect(parsed.sources.some((s: string) => s.endsWith("intermediate.js"))).toBe(true); }); - // Non-inline `sourceMappingURL=foo.js.map` references aren't chained - // (external map resolution is out of scope for this change). The build - // must behave exactly as before — the intermediate ends up as the - // deepest source, not a spurious crash. - test("external .map reference — unchanged behavior", async () => { + // An external `sourceMappingURL=foo.js.map` reference whose sidecar + // file is missing on disk must not fail the build; the intermediate + // stays the deepest source. (When the sidecar exists the chain is + // loaded — covered in test/regression/issue/26713.test.ts.) + test("external .map reference with missing sidecar — falls back cleanly", async () => { const dir = tempDirWithFiles("bun-build-chained-sourcemap-external", { "intermediate.js": "export const q = 3;\n//# sourceMappingURL=intermediate.js.map\n", "entry.ts": `import { q } from './intermediate.js';\nconsole.log(q);\n`, diff --git a/test/regression/issue/26713.test.ts b/test/regression/issue/26713.test.ts new file mode 100644 index 000000000000..92b94b4d2eef --- /dev/null +++ b/test/regression/issue/26713.test.ts @@ -0,0 +1,160 @@ +// https://github.com/oven-sh/bun/issues/26713 +// +// When a file referenced from an HTML route (or passed to Bun.build) carries +// its own `//# sourceMappingURL=.map` comment, the bundler should chain +// through that input sourcemap so the emitted map's `sources` point at the +// authored source, not the intermediate `.js`. + +import { test, expect, describe } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; + +const originalSource = `interface Foo { bar: string } +const x: Foo = { bar: "hello from main.ts" }; +console.log(x.bar); +`; + +async function makeFixture() { + const dir = tempDir("26713", { + "src/main.ts": originalSource, + "index.html": ``, + }); + // Step 1: pre-build src/main.ts -> main.js + main.js.map (linked sourcemap). + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "src/main.ts", "--sourcemap=linked", "--outdir", "./"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.includes("main.js"), stderr, code }).toEqual({ stdout: true, stderr: "", code: 0 }); + // Sanity: the pre-built map points at the original source. + const prebuiltMap = await Bun.file(join(String(dir), "main.js.map")).json(); + expect(prebuiltMap.sources).toEqual(["src/main.ts"]); + return dir; +} + +describe.concurrent("input sourcemap chaining for external .map references (#26713)", () => { + test("Bun.serve HTML route with development: true (dev server)", async () => { + using dir = await makeFixture(); + const fixture = ` + import index from "./index.html"; + const server = Bun.serve({ port: 0, routes: { "/": index }, development: true }); + const html = await fetch(server.url).then(r => r.text()); + const src = html.match(/src="([^"]+)"/)[1]; + const jsUrl = new URL(src, server.url); + const js = await fetch(jsUrl).then(r => r.text()); + const mapUrl = js.match(/sourceMappingURL=(\\S+)/)[1]; + const map = await fetch(new URL(mapUrl, jsUrl)).then(r => r.json()); + process.stdout.write(JSON.stringify({ + sources: map.sources, + sourcesContent: map.sourcesContent, + sourcesLen: map.sources.length, + contentLen: map.sourcesContent.length, + })); + server.stop(true); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + expect(stderr).not.toContain("error"); + const out = JSON.parse(stdout); + // The original TypeScript source must appear in the emitted sourcemap. + expect(out.sources.some((s: string) => s.replaceAll("\\", "/").endsWith("src/main.ts"))).toBe(true); + // sources and sourcesContent must be the same length (slot alignment). + expect(out.sourcesLen).toBe(out.contentLen); + // The slot for src/main.ts must carry the authored source text so the + // browser can show the original file without fetching it. + const tsIdx = out.sources.findIndex((s: string) => s.replaceAll("\\", "/").endsWith("src/main.ts")); + expect(out.sourcesContent[tsIdx]).toBe(originalSource); + expect(exitCode).toBe(0); + }); + + test("Bun.serve HTML route with development: false (prod bundler)", async () => { + using dir = await makeFixture(); + const fixture = ` + import index from "./index.html"; + const server = Bun.serve({ port: 0, routes: { "/": index }, development: false }); + const html = await fetch(server.url).then(r => r.text()); + const src = html.match(/src="([^"]+)"/)[1]; + const jsUrl = new URL(src, server.url); + const js = await fetch(jsUrl).then(r => r.text()); + const mapUrl = js.match(/sourceMappingURL=(\\S+)/)[1]; + const map = await fetch(new URL(mapUrl, jsUrl)).then(r => r.json()); + process.stdout.write(JSON.stringify({ sources: map.sources })); + server.stop(true); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + expect(stderr).not.toContain("error"); + const out = JSON.parse(stdout); + expect(out.sources.some((s: string) => s.replaceAll("\\", "/").endsWith("src/main.ts"))).toBe(true); + expect(exitCode).toBe(0); + }); + + test("Bun.build entrypoint with external .map", async () => { + using dir = await makeFixture(); + const result = await Bun.build({ + entrypoints: [join(String(dir), "main.js")], + sourcemap: "external", + outdir: join(String(dir), "out"), + }); + expect(result.success).toBe(true); + const mapFile = result.outputs.find(o => o.path.endsWith(".map")); + expect(mapFile).toBeDefined(); + const map = await Bun.file(mapFile!.path).json(); + expect(map.sources.some((s: string) => s.replaceAll("\\", "/").endsWith("src/main.ts"))).toBe(true); + // sourcesContent must carry the authored source. + const tsIdx = map.sources.findIndex((s: string) => s.replaceAll("\\", "/").endsWith("src/main.ts")); + expect(map.sourcesContent[tsIdx]).toBe(originalSource); + }); + + test("Bun.build with missing external .map falls back cleanly", async () => { + using dir = tempDir("26713-missing", { + "main.js": `console.log("hi");\n//# sourceMappingURL=does-not-exist.map\n`, + }); + const result = await Bun.build({ + entrypoints: [join(String(dir), "main.js")], + sourcemap: "external", + outdir: join(String(dir), "out"), + }); + // The build must not fail; the output sourcemap falls back to the + // intermediate without the chain. + expect(result.success).toBe(true); + const mapFile = result.outputs.find(o => o.path.endsWith(".map")); + const map = await Bun.file(mapFile!.path).json(); + expect(map.sources.some((s: string) => s.endsWith("main.js"))).toBe(true); + }); + + test("external .map with http:// URL is skipped, not fetched", async () => { + using dir = tempDir("26713-http", { + "main.js": `console.log("hi");\n//# sourceMappingURL=http://127.0.0.1:1/unreachable.map\n`, + }); + const result = await Bun.build({ + entrypoints: [join(String(dir), "main.js")], + sourcemap: "external", + outdir: join(String(dir), "out"), + }); + expect(result.success).toBe(true); + }); +}); From f8f9d527105266204086d80b847e6a44862556f5 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:59:23 +0000 Subject: [PATCH 23/32] [autofix.ci] apply automated fixes --- src/runtime/bake/DevServer.rs | 21 ++++++++++----------- src/runtime/bake/dev_server/packed_map.rs | 3 ++- test/regression/issue/26713.test.ts | 14 +++----------- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 4aad9816bf0d..1bbd4089c990 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -3877,17 +3877,16 @@ fn collect_inner_sources( let mut path_buf = bun_paths::path_buffer_pool::get(); for (i, name) in names.iter().enumerate() { let name: &[u8] = name.as_ref(); - let abs: &[u8] = if !base_dir.is_empty() - && !bun_paths::resolve_path::Platform::AUTO.is_absolute(name) - { - bun_paths::resolve_path::join_abs_string_buf::( - base_dir, - &mut **path_buf, - &[name], - ) - } else { - name - }; + let abs: &[u8] = + if !base_dir.is_empty() && !bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { + bun_paths::resolve_path::join_abs_string_buf::( + base_dir, + &mut **path_buf, + &[name], + ) + } else { + name + }; let escaped = match ism.sources_content.get(i) { Some(content) if !content.is_empty() => { let mut buf = bun_core::MutableString::init(content.len() + 2) diff --git a/src/runtime/bake/dev_server/packed_map.rs b/src/runtime/bake/dev_server/packed_map.rs index 92400edf9154..ee7456a15ff9 100644 --- a/src/runtime/bake/dev_server/packed_map.rs +++ b/src/runtime/bake/dev_server/packed_map.rs @@ -76,7 +76,8 @@ impl PackedMap { #[inline] pub fn memory_cost(&self) -> usize { - let mut cost = self.vlq().len() + self.quoted_contents().len() + core::mem::size_of::(); + let mut cost = + self.vlq().len() + self.quoted_contents().len() + core::mem::size_of::(); for inner in self.inner_sources.iter() { cost += inner.path.len() + inner.escaped_content.len() diff --git a/test/regression/issue/26713.test.ts b/test/regression/issue/26713.test.ts index 92b94b4d2eef..be5bb8adb4e6 100644 --- a/test/regression/issue/26713.test.ts +++ b/test/regression/issue/26713.test.ts @@ -5,7 +5,7 @@ // through that input sourcemap so the emitted map's `sources` point at the // authored source, not the intermediate `.js`. -import { test, expect, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "node:path"; @@ -62,11 +62,7 @@ describe.concurrent("input sourcemap chaining for external .map references (#267 stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).not.toContain("error"); const out = JSON.parse(stdout); // The original TypeScript source must appear in the emitted sourcemap. @@ -101,11 +97,7 @@ describe.concurrent("input sourcemap chaining for external .map references (#267 stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).not.toContain("error"); const out = JSON.parse(stdout); expect(out.sources.some((s: string) => s.replaceAll("\\", "/").endsWith("src/main.ts"))).toBe(true); From faf54b5a88173186cb2371e9790de6c56b46ef11 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:30:29 +0000 Subject: [PATCH 24/32] address review: drop stale dev-server gates, OOM handling, HMR cache key, parse slot count - LinkerContext.rs: drop the vestigial dev_server.is_none() gate in generate_source_map_for_chunk now that SourceMapStore handles multi-slot files; that function never runs on the dev-server path anyway. - js_printer Options::input_source_map doc: drop the outdated dev-server note and the data:-only wording. - LinkerContext.zig: update the reference comment to reflect that the Rust side passes input_source_map unconditionally. - DevServer collect_inner_sources: route MutableString::init / quote_for_json through bun_core::handle_oom instead of silently falling back to an empty buffer on allocation failure. - DevServer HMR script-id hash: fold inner_sources paths + contents into the source_map_hash so a sidecar .map change that keeps the same VLQ still invalidates the cached entry. - source_map_store::get_parsed_source_map: pass source_slot_count()+1 to mapping::parse so the HMR-runtime slot at index 0 is accounted for (pre-existing off-by-one, surfaced while touching this code). - InputSourceMap: route estr.string() through bun_core::handle_oom instead of .expect("OOM"). --- src/bundler/LinkerContext.rs | 16 ++++------------ src/bundler/LinkerContext.zig | 12 +++++------- src/js_printer/lib.rs | 15 +++++++-------- src/runtime/bake/DevServer.rs | 14 +++++++++++--- src/runtime/bake/dev_server/source_map_store.rs | 6 +++++- src/sourcemap/InputSourceMap.rs | 5 ++--- 6 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 106953c6a2a4..16e75a5a8e6a 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1092,16 +1092,8 @@ impl<'a> LinkerContext<'a> { let sources = self.parse_graph().input_files.items_source(); let quoted_source_map_contents = self.graph.files.items_quoted_source_contents(); - // DevServer uses a separate sourcemap stitcher (`SourceMapStore::join_vlq`) - // that hard-codes one `sources[]` slot per input; threading inner-map - // expansion through there would corrupt its output. `DevServer == None` - // gates the whole feature so the HMR path stays byte-identical. - let input_source_maps: Option<&[Option>]> = - if self.dev_server.is_none() { - Some(self.parse_graph().input_files.items_input_source_map()) - } else { - None - }; + let input_source_maps: &[Option>] = + self.parse_graph().input_files.items_input_source_map(); // Entries in `results` do not 1:1 map to source files, the mapping // is actually many to one, where a source file can have multiple chunks @@ -1133,7 +1125,7 @@ impl<'a> LinkerContext<'a> { // `1` for the intermediate input, plus one slot per inner // source listed in its `sourceMappingURL`. let inner: Option<&bun_sourcemap::InputSourceMap> = - input_source_maps.and_then(|m| m[index as usize].as_deref()); + input_source_maps[index as usize].as_deref(); let expansion: i32 = 1 + match inner { Some(ism) => { i32::try_from(ism.map.external_source_names.len()).expect("int cast") @@ -1174,7 +1166,7 @@ impl<'a> LinkerContext<'a> { emitted_contents += 1; } // Slots 1..N: inner sources' contents, if any. - if let Some(ism) = input_source_maps.and_then(|m| m[index as usize].as_deref()) { + if let Some(ism) = input_source_maps[index as usize].as_deref() { for content in ism.sources_content.iter() { j.push_static(b",\n "); if !content.is_empty() { diff --git a/src/bundler/LinkerContext.zig b/src/bundler/LinkerContext.zig index bd5e2784e49f..00f4f37f7c54 100644 --- a/src/bundler/LinkerContext.zig +++ b/src/bundler/LinkerContext.zig @@ -1430,13 +1430,11 @@ pub const LinkerContext = struct { c, ), .line_offset_tables = c.graph.files.items(.line_offset_table)[source_index.get()], - // Bake's DevServer has its own sourcemap stitcher - // (`SourceMapStore.joinVLQ` / `PackedMap`) that hard-codes one - // `sources[]` slot per file and discards `chunk.end_state.source_index`. - // The per-chunk `source_index` remapping this field enables - // would corrupt served stack traces there until that stitcher - // is taught the slot-expansion layout. Feed the inline map - // only on the non-dev-server path for now. + // NOTE: the Rust side passes this unconditionally now that + // `SourceMapStore.joinVLQ` / `PackedMap` track per-file + // inner-source expansion. This Zig reference still gates + // it because the Zig `PackedMap` was not taught the + // expansion layout. .input_source_map = if (c.dev_server == null) c.parse_graph.input_files.items(.input_source_map)[source_index.get()] else diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 3e2167db9df2..1c78e169821a 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1313,14 +1313,13 @@ pub struct Options<'a> { /// builder (`ManuallyDrop`, never freed on the bundler path). pub line_offset_tables: Option<&'a SourceMap::line_offset_table::List>, - /// When `Some`, the bundler input file carried an inline - /// `//# sourceMappingURL=data:...` comment. The chunk builder - /// remaps each emitted mapping through this inner map so the final - /// output's `source_index`/`(original_line, original_column)` refer - /// to the authored source instead of the intermediate input. - /// `None` for files that don't carry an inline sourcemap, or for - /// the DevServer HMR path (which uses a separate stitcher that - /// hard-codes one `sources[]` slot per file). + /// When `Some`, the bundler input file carried a + /// `//# sourceMappingURL=` comment (inline `data:` URL or a sidecar + /// `.map` file resolved on disk). The chunk builder remaps each + /// emitted mapping through this inner map so the final output's + /// `source_index`/`(original_line, original_column)` refer to the + /// authored source instead of the intermediate input. `None` for + /// files that don't carry an input sourcemap. pub input_source_map: Option<&'a SourceMap::InputSourceMap>, pub mangled_props: Option<&'a crate::MangledProps>, diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 1bbd4089c990..f2ae8937873b 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -3889,9 +3889,9 @@ fn collect_inner_sources( }; let escaped = match ism.sources_content.get(i) { Some(content) if !content.is_empty() => { - let mut buf = bun_core::MutableString::init(content.len() + 2) - .unwrap_or_else(|_| bun_core::MutableString::init_empty()); - let _ = bun_core::quote_for_json(content, &mut buf, false); + let mut buf = + bun_core::handle_oom(bun_core::MutableString::init(content.len() + 2)); + bun_core::handle_oom(bun_core::quote_for_json(content, &mut buf, false)); buf.list.into_boxed_slice() } _ => Box::default(), @@ -4698,6 +4698,14 @@ pub(super) fn finalize_bundle( source_map_hash.update(&keys[part.get() as usize]); if let Some(map) = values[part.get() as usize].source_map.get() { source_map_hash.update(map.vlq()); + // Inner-source paths/contents shape the rendered + // `.js.map` independently of the VLQ (e.g. a + // sidecar `.map` whose `sources`/`sourcesContent` + // changed but whose mappings are identical). + for inner in map.inner_sources.iter() { + source_map_hash.update(&inner.path); + source_map_hash.update(&inner.escaped_content); + } } } // Set the bottom bit. diff --git a/src/runtime/bake/dev_server/source_map_store.rs b/src/runtime/bake/dev_server/source_map_store.rs index 6f2daeea8d87..d0d50952a977 100644 --- a/src/runtime/bake/dev_server/source_map_store.rs +++ b/src/runtime/bake/dev_server/source_map_store.rs @@ -784,10 +784,14 @@ impl SourceMapStore { // PERF: `render_mappings` returns `Vec` (global alloc); could // use an arena. + // `render_mappings` emits source indices in `0..=slots` (slot 0 is the + // HMR runtime, slots `1..=slots` are files / their inner sources). + // `mapping::parse` rejects `source_index >= sources_count`, so pass + // `slots + 1` to cover the runtime slot. match source_map::mapping::parse( &vlq_bytes, None, - i32::try_from(entry.source_slot_count()).expect("int cast"), + i32::try_from(entry.source_slot_count() + 1).expect("int cast"), 0, // unused Default::default(), ) { diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index 358847bda106..3c7c037be31f 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -145,8 +145,7 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc let mut source_paths_slice: Vec> = Vec::with_capacity(source_count); for item in sources_paths.items.slice() { let estr = item.data.as_e_string().ok_or(InvalidSourceMap)?; - // handle_oom — fatal if OOM - let s = estr.string(&arena).expect("OOM"); + let s = bun_core::handle_oom(estr.string(&arena)); source_paths_slice.push(Box::<[u8]>::from(s)); } @@ -155,7 +154,7 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc if let Some(arr) = sources_content_opt { for item in arr.items.slice() { let slot: Box<[u8]> = if let Some(estr) = item.data.as_e_string() { - let s = estr.string(&arena).expect("OOM"); + let s = bun_core::handle_oom(estr.string(&arena)); if s.is_empty() { Box::<[u8]>::from(&b""[..]) } else { From 6d1ff14e1b67d8ccb70bb46a06ec675acb4cbcb2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:42:06 +0000 Subject: [PATCH 25/32] address review: honour sourceRoot, guard non-file namespaces in write_sources_for - InputSourceMap::parse_internal: read the optional sourceRoot field and prepend it to each sources[] entry (inserting a '/' when neither side has a separator, matching esbuild). Covered by a new test in 26713.test.ts. - LinkerContext::write_sources_for: when the intermediate's path is in a non-file namespace (plugin virtual module), emit inner source names as-is instead of joining against a meaningless dirname. Mirrors the guard already in DevServer::collect_inner_sources. --- src/bundler/LinkerContext.rs | 48 +++++++++++++++++++---------- src/sourcemap/InputSourceMap.rs | 27 +++++++++++++++- test/regression/issue/26713.test.ts | 26 ++++++++++++++++ 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 16e75a5a8e6a..49db916288a5 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1304,30 +1304,46 @@ fn write_sources_for( // relative to the directory of the intermediate file it came from, // then made relative to `chunk_abs_dir` (the chunk's output dir) for // the emitted JSON. Absolute inner paths stay absolute before - // relativization. + // relativization. When the intermediate lives in a non-file + // namespace (a plugin's virtual module), its `text` is not a + // filesystem path, so emit the inner name as-is rather than joining + // against a meaningless dirname. if let Some(ism) = input_map { - let base_dir = bun_paths::resolve_path::dirname::( - outer_path.text, - ); + let is_file = outer_path.is_file(); + let base_dir = if is_file { + bun_paths::resolve_path::dirname::( + outer_path.text, + ) + } else { + b"" + }; for name in ism.map.external_source_names.iter() { let name: &[u8] = name.as_ref(); - // Use `join_abs` to produce an absolute inner path (when the - // inner map emitted a relative source name) that can then be - // re-relativized against `chunk_abs_dir`. `join_abs` returns - // a borrow into a thread-local buffer; we copy out immediately - // via `relative_alloc`. - let abs_path: &[u8] = if bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { - name + let rel_path_storage; + let rel_path: &[u8] = if is_file { + // Use `join_abs` to produce an absolute inner path (when + // the inner map emitted a relative source name) that can + // then be re-relativized against `chunk_abs_dir`. + // `join_abs` returns a borrow into a thread-local buffer; + // we copy out immediately via `relative_alloc`. + let abs_path: &[u8] = + if bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { + name + } else { + bun_paths::resolve_path::join_abs::< + bun_paths::resolve_path::platform::Auto, + >(base_dir, name) + }; + rel_path_storage = + bun_paths::resolve_path::relative_alloc(chunk_abs_dir, abs_path)?; + &rel_path_storage } else { - bun_paths::resolve_path::join_abs::( - base_dir, name, - ) + name }; - let rel_path = bun_paths::resolve_path::relative_alloc(chunk_abs_dir, abs_path)?; let mut quote_buf = MutableString::init(rel_path.len() + ", ".len() + 2)?; quote_buf.append_assume_capacity(b", "); - js_printer::quote_for_json(&rel_path, &mut quote_buf, false)?; + js_printer::quote_for_json(rel_path, &mut quote_buf, false)?; joiner.push_owned(quote_buf.to_default_owned()); } } diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index 3c7c037be31f..f93c8c1530e8 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -141,12 +141,37 @@ fn parse_internal(json_bytes: &[u8]) -> Result, InvalidSourc let source_count = sources_paths.items.len_u32() as usize; + // `sourceRoot` is optional; per the spec it is prepended to each entry + // in `sources` before further resolution. + let source_root: &[u8] = match json.get(b"sourceRoot") { + Some(v) => match v.data.as_e_string() { + Some(estr) => bun_core::handle_oom(estr.string(&arena)), + None => b"", + }, + None => b"", + }; + // Copy source paths out of the arena into owned storage. let mut source_paths_slice: Vec> = Vec::with_capacity(source_count); for item in sources_paths.items.slice() { let estr = item.data.as_e_string().ok_or(InvalidSourceMap)?; let s = bun_core::handle_oom(estr.string(&arena)); - source_paths_slice.push(Box::<[u8]>::from(s)); + let owned: Box<[u8]> = if source_root.is_empty() { + Box::<[u8]>::from(s) + } else { + // Insert a separator if the root doesn't end in one and the + // source name doesn't begin with one (matches esbuild). + let need_sep = !matches!(source_root.last(), Some(b'/') | Some(b'\\')) + && !matches!(s.first(), Some(b'/') | Some(b'\\')); + let mut v = Vec::with_capacity(source_root.len() + need_sep as usize + s.len()); + v.extend_from_slice(source_root); + if need_sep { + v.push(b'/'); + } + v.extend_from_slice(s); + v.into_boxed_slice() + }; + source_paths_slice.push(owned); } // Copy source contents. Non-strings (null, etc.) and empty slots map to `b""`. diff --git a/test/regression/issue/26713.test.ts b/test/regression/issue/26713.test.ts index be5bb8adb4e6..deff18a5aca8 100644 --- a/test/regression/issue/26713.test.ts +++ b/test/regression/issue/26713.test.ts @@ -121,6 +121,32 @@ describe.concurrent("input sourcemap chaining for external .map references (#267 expect(map.sourcesContent[tsIdx]).toBe(originalSource); }); + test("external .map with sourceRoot is prepended to inner source paths", async () => { + using dir = tempDir("26713-sourceroot", { + "dist/main.js": `console.log("hi");\n//# sourceMappingURL=main.js.map\n`, + "dist/main.js.map": JSON.stringify({ + version: 3, + sourceRoot: "../src/", + sources: ["main.ts"], + sourcesContent: ['console.log("hi");\n'], + names: [], + mappings: "AAAA", + }), + }); + const result = await Bun.build({ + entrypoints: [join(String(dir), "dist", "main.js")], + sourcemap: "external", + outdir: join(String(dir), "out"), + }); + expect(result.success).toBe(true); + const mapFile = result.outputs.find(o => o.path.endsWith(".map")); + const map = await Bun.file(mapFile!.path).json(); + // The inner source must resolve through sourceRoot: dist/main.js + + // ../src/main.ts -> src/main.ts (not dist/main.ts). + expect(map.sources.some((s: string) => s.replaceAll("\\", "/").endsWith("src/main.ts"))).toBe(true); + expect(map.sources.some((s: string) => s.replaceAll("\\", "/").endsWith("dist/main.ts"))).toBe(false); + }); + test("Bun.build with missing external .map falls back cleanly", async () => { using dir = tempDir("26713-missing", { "main.js": `console.log("hi");\n//# sourceMappingURL=does-not-exist.map\n`, From 0c49f06e5e0a41a18e29934001d8e58dbd27b264 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:44:11 +0000 Subject: [PATCH 26/32] [autofix.ci] apply automated fixes --- src/bundler/LinkerContext.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 49db916288a5..b1794cc6f076 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1326,14 +1326,13 @@ fn write_sources_for( // then be re-relativized against `chunk_abs_dir`. // `join_abs` returns a borrow into a thread-local buffer; // we copy out immediately via `relative_alloc`. - let abs_path: &[u8] = - if bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { - name - } else { - bun_paths::resolve_path::join_abs::< - bun_paths::resolve_path::platform::Auto, - >(base_dir, name) - }; + let abs_path: &[u8] = if bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { + name + } else { + bun_paths::resolve_path::join_abs::( + base_dir, name, + ) + }; rel_path_storage = bun_paths::resolve_path::relative_alloc(chunk_abs_dir, abs_path)?; &rel_path_storage From e18bcc68eaa9d31501aa3b76a9d003ee60897f26 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:04:04 +0000 Subject: [PATCH 27/32] address review: pass URL-style inner source names through verbatim Inner-source names like webpack:///foo.ts or //host/path are virtual identifiers, not filesystem paths; joining them against the intermediate's dirname mangles them. Added bun_sourcemap::is_url_like_source_name (shared with the sourceMappingURL scheme check in parse_from_source_with_fs) and guard both write_sources_for and collect_inner_sources with it. Covered by a new test case. Also add expect(mapFile).toBeDefined() before non-null dereferences in the sourceRoot and missing-sidecar tests. --- src/bundler/LinkerContext.rs | 9 ++++++--- src/runtime/bake/DevServer.rs | 22 ++++++++++++---------- src/sourcemap/InputSourceMap.rs | 13 ++++++++++--- src/sourcemap/lib.rs | 2 +- test/regression/issue/26713.test.ts | 27 +++++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index b1794cc6f076..088c1021131a 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1320,7 +1320,12 @@ fn write_sources_for( for name in ism.map.external_source_names.iter() { let name: &[u8] = name.as_ref(); let rel_path_storage; - let rel_path: &[u8] = if is_file { + let rel_path: &[u8] = if !is_file || bun_sourcemap::is_url_like_source_name(name) { + // Non-file namespace, or a URL-style virtual name + // (`webpack://`, `ng://`, `//host/...`): pass through + // unchanged so DevTools sees the original identifier. + name + } else { // Use `join_abs` to produce an absolute inner path (when // the inner map emitted a relative source name) that can // then be re-relativized against `chunk_abs_dir`. @@ -1336,8 +1341,6 @@ fn write_sources_for( rel_path_storage = bun_paths::resolve_path::relative_alloc(chunk_abs_dir, abs_path)?; &rel_path_storage - } else { - name }; let mut quote_buf = MutableString::init(rel_path.len() + ", ".len() + 2)?; diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index f2ae8937873b..41ddebabef38 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -3877,16 +3877,18 @@ fn collect_inner_sources( let mut path_buf = bun_paths::path_buffer_pool::get(); for (i, name) in names.iter().enumerate() { let name: &[u8] = name.as_ref(); - let abs: &[u8] = - if !base_dir.is_empty() && !bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { - bun_paths::resolve_path::join_abs_string_buf::( - base_dir, - &mut **path_buf, - &[name], - ) - } else { - name - }; + let abs: &[u8] = if !base_dir.is_empty() + && !bun_paths::resolve_path::Platform::AUTO.is_absolute(name) + && !bun_sourcemap::is_url_like_source_name(name) + { + bun_paths::resolve_path::join_abs_string_buf::( + base_dir, + &mut **path_buf, + &[name], + ) + } else { + name + }; let escaped = match ism.sources_content.get(i) { Some(content) if !content.is_empty() => { let mut buf = diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index f93c8c1530e8..fae94109139b 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -60,9 +60,7 @@ impl InputSourceMap { } // Skip remote / protocol-relative references; only local paths are // loadable during bundling. - if bun_core::strings::contains_comptime(url, b"://") - || bun_core::strings::has_prefix_comptime(url, b"//") - { + if is_url_like_source_name(url) { return None; } let mut buf = bun_paths::path_buffer_pool::get(); @@ -76,6 +74,15 @@ impl InputSourceMap { } } +/// True for sourcemap `sources[]` entries / `sourceMappingURL` values that +/// are URL-shaped (scheme or protocol-relative) rather than filesystem +/// paths. These are passed through verbatim by the linker / dev server +/// instead of being joined against an on-disk directory. +pub fn is_url_like_source_name(name: &[u8]) -> bool { + bun_core::strings::contains_comptime(name, b"://") + || bun_core::strings::has_prefix_comptime(name, b"//") +} + /// Malformed input is indistinguishable from "no chain available" — callers /// treat it as a silent fallback to the raw file bytes. struct InvalidSourceMap; diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index 7ea6281508dc..fdf0a6d5508a 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -23,7 +23,7 @@ pub mod mapping; #[path = "ParsedSourceMap.rs"] pub mod parsed_source_map; -pub use input_source_map::InputSourceMap; +pub use input_source_map::{InputSourceMap, is_url_like_source_name}; pub use bun_base64::vlq; pub use vlq::{VLQ, encode as encode_vlq}; diff --git a/test/regression/issue/26713.test.ts b/test/regression/issue/26713.test.ts index deff18a5aca8..7e96f4a9df29 100644 --- a/test/regression/issue/26713.test.ts +++ b/test/regression/issue/26713.test.ts @@ -140,6 +140,7 @@ describe.concurrent("input sourcemap chaining for external .map references (#267 }); expect(result.success).toBe(true); const mapFile = result.outputs.find(o => o.path.endsWith(".map")); + expect(mapFile).toBeDefined(); const map = await Bun.file(mapFile!.path).json(); // The inner source must resolve through sourceRoot: dist/main.js + // ../src/main.ts -> src/main.ts (not dist/main.ts). @@ -147,6 +148,31 @@ describe.concurrent("input sourcemap chaining for external .map references (#267 expect(map.sources.some((s: string) => s.replaceAll("\\", "/").endsWith("dist/main.ts"))).toBe(false); }); + test("URL-style inner source names are passed through verbatim", async () => { + using dir = tempDir("26713-url-sources", { + "main.js": `console.log("hi");\n//# sourceMappingURL=main.js.map\n`, + "main.js.map": JSON.stringify({ + version: 3, + sources: ["webpack:///src/main.ts"], + sourcesContent: ['console.log("hi");\n'], + names: [], + mappings: "AAAA", + }), + }); + const result = await Bun.build({ + entrypoints: [join(String(dir), "main.js")], + sourcemap: "external", + outdir: join(String(dir), "out"), + }); + expect(result.success).toBe(true); + const mapFile = result.outputs.find(o => o.path.endsWith(".map")); + expect(mapFile).toBeDefined(); + const map = await Bun.file(mapFile!.path).json(); + // webpack:// and similar URL schemes must survive untouched; joining + // them against a filesystem dir would mangle them. + expect(map.sources).toContain("webpack:///src/main.ts"); + }); + test("Bun.build with missing external .map falls back cleanly", async () => { using dir = tempDir("26713-missing", { "main.js": `console.log("hi");\n//# sourceMappingURL=does-not-exist.map\n`, @@ -160,6 +186,7 @@ describe.concurrent("input sourcemap chaining for external .map references (#267 // intermediate without the chain. expect(result.success).toBe(true); const mapFile = result.outputs.find(o => o.path.endsWith(".map")); + expect(mapFile).toBeDefined(); const map = await Bun.file(mapFile!.path).json(); expect(map.sources.some((s: string) => s.endsWith("main.js"))).toBe(true); }); From 2fb4ac1f8eb2c52d59d870390d799832aea0494b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:15:32 +0000 Subject: [PATCH 28/32] address review: use length-checked path join for user-controlled sourcemap URLs The sourceMappingURL value and inner sources[] names are arbitrary bytes from file content; use join_abs_string_buf_checked so an overlong entry falls back to None / the raw name instead of panicking on the fixed PathBuffer. --- src/runtime/bake/DevServer.rs | 6 +++++- src/sourcemap/InputSourceMap.rs | 11 ++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 41ddebabef38..4608871f12bf 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -3881,11 +3881,15 @@ fn collect_inner_sources( && !bun_paths::resolve_path::Platform::AUTO.is_absolute(name) && !bun_sourcemap::is_url_like_source_name(name) { - bun_paths::resolve_path::join_abs_string_buf::( + // Inner source names come from arbitrary `.map` JSON; use + // the length-checked join so an overlong entry falls back + // to the raw name rather than panicking on the PathBuffer. + bun_paths::resolve_path::join_abs_string_buf_checked::( base_dir, &mut **path_buf, &[name], ) + .unwrap_or(name) } else { name }; diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index fae94109139b..b8552859bd4a 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -64,11 +64,12 @@ impl InputSourceMap { return None; } let mut buf = bun_paths::path_buffer_pool::get(); - let abs = bun_paths::resolve_path::join_abs_string_buf::( - source_dir, - &mut buf, - &[url], - ); + // `url` is the trailing line of an arbitrary input file; use the + // length-checked join so an overlong reference falls back to + // `None` rather than panicking on the fixed PathBuffer. + let abs = bun_paths::resolve_path::join_abs_string_buf_checked::< + bun_paths::platform::Loose, + >(source_dir, &mut buf, &[url])?; let bytes = bun_sys::File::read_from(bun_core::Fd::cwd(), abs).ok()?; InputSourceMap::parse(&bytes) } From 354dfdd91e70b0eebb8fd1bda9e28154184a4592 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:17:46 +0000 Subject: [PATCH 29/32] [autofix.ci] apply automated fixes --- src/sourcemap/InputSourceMap.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index b8552859bd4a..9ac1508759a0 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -67,9 +67,11 @@ impl InputSourceMap { // `url` is the trailing line of an arbitrary input file; use the // length-checked join so an overlong reference falls back to // `None` rather than panicking on the fixed PathBuffer. - let abs = bun_paths::resolve_path::join_abs_string_buf_checked::< - bun_paths::platform::Loose, - >(source_dir, &mut buf, &[url])?; + let abs = bun_paths::resolve_path::join_abs_string_buf_checked::( + source_dir, + &mut buf, + &[url], + )?; let bytes = bun_sys::File::read_from(bun_core::Fd::cwd(), abs).ok()?; InputSourceMap::parse(&bytes) } From 238418d705ea562945df668768138d00fa6036fd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:20:44 +0000 Subject: [PATCH 30/32] test(26713): normalize path separators in makeFixture pre-build sanity check bun build --sourcemap=linked emits platform separators in sources[] (src\main.ts on Windows); normalize before comparing so the fixture setup works on Windows aarch64. --- test/regression/issue/26713.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/regression/issue/26713.test.ts b/test/regression/issue/26713.test.ts index 7e96f4a9df29..72429906087a 100644 --- a/test/regression/issue/26713.test.ts +++ b/test/regression/issue/26713.test.ts @@ -29,9 +29,10 @@ async function makeFixture() { }); const [stdout, stderr, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: stdout.includes("main.js"), stderr, code }).toEqual({ stdout: true, stderr: "", code: 0 }); - // Sanity: the pre-built map points at the original source. + // Sanity: the pre-built map points at the original source. Bun emits + // platform separators here, so normalize before comparing. const prebuiltMap = await Bun.file(join(String(dir), "main.js.map")).json(); - expect(prebuiltMap.sources).toEqual(["src/main.ts"]); + expect(prebuiltMap.sources.map((s: string) => s.replaceAll("\\", "/"))).toEqual(["src/main.ts"]); return dir; } From 3466b4a5c44fa85a07b5e54b5d02d5d62abaf240 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:39:14 +0000 Subject: [PATCH 31/32] docs: mention the sidecar .map origin on the other three input_source_map fields faf54b5 updated js_printer::Options::input_source_map to say (inline data: URL or a sidecar .map file resolved on disk) but missed the three parallel fields carrying the same data: Graph::InputFile, ParseTask::Success, and Chunk::NewBuilder. Align them. --- src/bundler/Graph.rs | 13 +++++++------ src/bundler/ParseTask.rs | 13 +++++++------ src/sourcemap/Chunk.rs | 15 ++++++++------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index 9973fa19c907..b953a0a1023c 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -107,12 +107,13 @@ pub struct InputFile { pub unique_key_for_additional_file: Box<[u8], AstAlloc>, pub content_hash_for_additional_file: u64, pub flags: InputFileFlags, - /// When this file carried an inline `//# sourceMappingURL=data:...` - /// comment, the decoded inner map plus its `sourcesContent` bytes. The - /// linker expands outer `sources[]` / `sourcesContent[]` with these - /// inner entries and the `Chunk::Builder` remaps its mappings through - /// the inner `find_mapping` so final stack traces surface in the - /// authored source. `None` when no chain is available (most inputs). + /// When this file carried a `//# sourceMappingURL=` comment (inline + /// `data:` URL or a sidecar `.map` file resolved on disk), the decoded + /// inner map plus its `sourcesContent` bytes. The linker expands outer + /// `sources[]` / `sourcesContent[]` with these inner entries and the + /// `Chunk::Builder` remaps its mappings through the inner + /// `find_mapping` so final stack traces surface in the authored + /// source. `None` when no chain is available (most inputs). pub input_source_map: Option>, } diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 609ea624acc2..efb2a6b0620b 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -178,12 +178,13 @@ pub struct Success { /// The package name from package.json, used for barrel optimization. pub package_name: ast::StoreStr, - /// Decoded trailing inline `//# sourceMappingURL=data:...` inner map, - /// parsed from the source bytes. `None` when the file had no inline - /// sourcemap comment, when sourcemaps are disabled on the build, or - /// when the inline payload was malformed (caller silently falls back - /// to the raw file bytes). Moved into `graph.input_files.input_source_map` - /// by `on_parse_task_complete`. + /// Inner map decoded from the file's trailing `//# sourceMappingURL=` + /// comment (inline `data:` URL or a sidecar `.map` file resolved on + /// disk). `None` when the file had no such comment, when sourcemaps + /// are disabled on the build, or when the payload was malformed or + /// unreadable (caller silently falls back to the raw file bytes). + /// Moved into `graph.input_files.input_source_map` by + /// `on_parse_task_complete`. pub input_source_map: Option>, } diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index b0c616f2f440..1f7c0bb05504 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -360,13 +360,14 @@ pub struct NewBuilder { /// as `line_offset_table_byte_offset_list` above. pub line_offset_table_first_non_ascii: &'static [u32], - /// When set, the bundler/printer input file carried an inline - /// `//# sourceMappingURL=data:...` comment; `add_source_mapping` will - /// remap each mapping through its inner map so the emitted - /// `source_index` / original `(line, col)` refer to the authored - /// source instead of the bundler's intermediate input. Unset - /// otherwise — the emitted mapping uses the Builder's own - /// `prev_state.source_index` (the outer source's slot). + /// When set, the bundler/printer input file carried a + /// `//# sourceMappingURL=` comment (inline `data:` URL or a sidecar + /// `.map` file resolved on disk); `add_source_mapping` will remap + /// each mapping through its inner map so the emitted `source_index` + /// / original `(line, col)` refer to the authored source instead of + /// the bundler's intermediate input. Unset otherwise — the emitted + /// mapping uses the Builder's own `prev_state.source_index` (the + /// outer source's slot). /// /// Borrow lives in `Graph::input_files[i].input_source_map` /// (`Option>`); the slot outlives every printer From c67b78d2efc2ea51ff2b72ed8c4965bf25189a31 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 04:08:18 +0000 Subject: [PATCH 32/32] [autofix.ci] apply automated fixes --- src/bundler/LinkerContext.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 77bb0c21a1e8..232feb2165bb 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1295,8 +1295,7 @@ fn write_sources_for( // 1) the intermediate input. let rel_path_storage; let pretty: &[u8] = if outer_path.is_file() { - rel_path_storage = - LinkerContext::source_map_relative_path(chunk_abs_dir, outer_path.text)?; + rel_path_storage = LinkerContext::source_map_relative_path(chunk_abs_dir, outer_path.text)?; &rel_path_storage } else { outer_path.pretty