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. diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index 4de30205a97f..b953a0a1023c 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -107,6 +107,14 @@ 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 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>, } impl Default for InputFile { @@ -120,6 +128,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, } } } @@ -137,6 +146,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 8ce69747981a..232feb2165bb 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1101,68 +1101,55 @@ 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(); + 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 // 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 = Self::source_map_relative_path(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 = Self::source_map_relative_path(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[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, + )?; } } @@ -1170,20 +1157,37 @@ 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[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\": \""); @@ -1223,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 { @@ -1267,6 +1276,91 @@ 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 = LinkerContext::source_map_relative_path(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. 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 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(); + let rel_path_storage; + 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`. + // `join_abs` returns a borrow into a thread-local buffer; + // we copy out immediately via `source_map_relative_path`. + 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 = + LinkerContext::source_map_relative_path(chunk_abs_dir, abs_path)?; + &rel_path_storage + }; + + 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, @@ -2211,6 +2305,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'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, @@ -2259,6 +2359,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 f25c680c4b08..efb2a6b0620b 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -177,6 +177,15 @@ pub struct Success { /// The package name from package.json, used for barrel optimization. pub package_name: ast::StoreStr, + + /// 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>, } pub struct ResultError { @@ -2577,6 +2586,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; @@ -2637,6 +2652,37 @@ pub mod parse_worker { *step = Step::Resolve; + // 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 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() + { + 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 + }; + Ok(Success { ast, source: source.clone(), @@ -2653,6 +2699,8 @@ pub mod parse_worker { } else { 0 }, + + input_source_map, }) } diff --git a/src/bundler/ServerComponentParseTask.rs b/src/bundler/ServerComponentParseTask.rs index ea5a444aed3a..043ffba62573 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 a68baeb6510b..45e6b3fcd8e3 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4814,6 +4814,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; @@ -6954,6 +6964,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 7cb3107045db..d1f204e0c210 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1313,6 +1313,15 @@ 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 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>, } @@ -1372,6 +1381,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, } } @@ -7740,6 +7750,22 @@ pub fn get_source_map_builder( } let precomputed = opts.line_offset_tables.take(); + 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 @@ -7748,6 +7774,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/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 1825e72fa660..b54f242f68bd 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -3852,6 +3852,64 @@ 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_sourcemap::is_url_like_source_name(name) + { + // 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 + }; + let escaped = match ism.sources_content.get(i) { + Some(content) if !content.is_empty() => { + 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(), + }; + 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 +4044,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 +4119,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 +4136,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 +4155,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, @@ -4641,6 +4704,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. @@ -7166,6 +7237,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..ee7456a15ff9 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,29 @@ 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..d0d50952a977 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, @@ -699,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.paths.len()).expect("int cast"), + i32::try_from(entry.source_slot_count() + 1).expect("int cast"), 0, // unused Default::default(), ) { @@ -716,8 +805,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/Chunk.rs b/src/sourcemap/Chunk.rs index b7e87959f7ac..1f7c0bb05504 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -360,6 +360,31 @@ 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 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 + /// 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 @@ -395,6 +420,8 @@ impl Default for NewBuilder { has_prev_state: false, 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, @@ -717,14 +744,19 @@ 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. + // 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_state.original_line as u32, + 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)`. @@ -753,6 +785,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 +833,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..6e2c933d2f06 --- /dev/null +++ b/src/sourcemap/InputSourceMap.rs @@ -0,0 +1,335 @@ +//! 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::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) + } + + /// 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 is_url_like_source_name(url) { + return None; + } + let mut buf = bun_paths::path_buffer_pool::get(); + // `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::( + source_dir, + &mut buf, + &[url], + )?; + let bytes = bun_sys::File::read_from(bun_core::Fd::cwd(), abs).ok()?; + InputSourceMap::parse(&bytes) + } +} + +/// 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; + +/// 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 (cleanup rides on `Drop` at each 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; + + // `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)); + 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""`. + 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(estr) = item.data.as_e_string() { + let s = bun_core::handle_oom(estr.string(&arena)); + 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 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..]; + } else { + break; + } + } + 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) + } +} diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index 02ace9fd5ee2..3fea5a22d513 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"] @@ -21,6 +23,8 @@ pub mod mapping; #[path = "ParsedSourceMap.rs"] pub mod parsed_source_map; +pub use input_source_map::{InputSourceMap, is_url_like_source_name}; + pub use bun_base64::vlq; pub use vlq::{VLQ, encode as encode_vlq}; use vlq::{decode as decode_vlq, decode_assume_valid as decode_vlq_assume_valid}; diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 4487b0e273a3..7a27cdc17ba9 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1391,3 +1391,334 @@ 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,(.+)/); + 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); + }); + + // 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,(.+)/); + 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); + 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); + + // 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); + }); + + // 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 + // 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(); + 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); + }); + + // 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`, + }); + + 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")); + // 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); + }); + + // 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,(.+)/); + 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 + // 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); + }); +}); diff --git a/test/regression/issue/26713.test.ts b/test/regression/issue/26713.test.ts new file mode 100644 index 000000000000..72429906087a --- /dev/null +++ b/test/regression/issue/26713.test.ts @@ -0,0 +1,206 @@ +// 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 { describe, expect, test } 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. Bun emits + // platform separators here, so normalize before comparing. + const prebuiltMap = await Bun.file(join(String(dir), "main.js.map")).json(); + expect(prebuiltMap.sources.map((s: string) => s.replaceAll("\\", "/"))).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("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")); + 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). + 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("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`, + }); + 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")); + expect(mapFile).toBeDefined(); + 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); + }); +});