diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index aa13706b0826..5dd24675c2ca 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -114,6 +114,10 @@ pub struct InputFile { pub unique_key_for_additional_file: Box<[u8], AstAlloc>, pub content_hash_for_additional_file: u64, pub flags: InputFileFlags, + /// Decoded inline `//# sourceMappingURL=data:...` map for this file; + /// the linker expands `sources[]`/`sourcesContent[]` with its entries + /// and `Chunk::Builder` remaps mappings through it. Usually `None`. + pub input_source_map: Option>, } impl Default for InputFile { @@ -127,6 +131,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, } } } @@ -144,6 +149,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 bc47073ee17d..f722a1384939 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1044,68 +1044,52 @@ 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's stitcher (`SourceMapStore::join_vlq`) assumes one + // `sources[]` slot per input, so chaining is gated to `Bun.build`. + 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: - // `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. + // Many-to-one: a source file can own several chunks. Maps each + // compilation `source_index` to its base index in the generated + // `sources[]`; a file with an inline map spans + // `base ..= base + external_source_names.len`. 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.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, + )?; } } @@ -1113,20 +1097,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.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\": \""); @@ -1166,7 +1167,9 @@ impl<'a> LinkerContext<'a> { )?; prev_end_state = chunk.end_state; - prev_end_state.source_index = mapping_source_index; + // `chunk.end_state.source_index` is chunk-relative (0 without an + // inline map); rebase it onto this file's slot base. + 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 { @@ -1210,6 +1213,85 @@ impl<'a> LinkerContext<'a> { } } +/// Emit one outer source's quoted path plus its chained inner paths, in +/// the slot layout `Chunk::Builder` emits against: slot 0 = the outer +/// file, slots 1..N = inner `sources[i]`. +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: resolve each against the intermediate's dir, then + // re-relativize to `chunk_abs_dir` for the emitted JSON. + if let Some(ism) = input_map { + let emit = |joiner: &mut StringJoiner, p: &[u8]| -> Result<(), BunError> { + let mut quote_buf = MutableString::init(p.len() + ", ".len() + 2)?; + quote_buf.append_assume_capacity(b", "); + js_printer::quote_for_json(p, &mut quote_buf, false)?; + joiner.push_owned(quote_buf.to_default_owned()); + Ok(()) + }; + // A non-file intermediate (plugin virtual module) has no directory + // to resolve against; emit inner names verbatim. + if !outer_path.is_file() { + for name in ism.map.external_source_names.iter() { + emit(joiner, name.as_ref())?; + } + return Ok(()); + } + let base_dir = bun_paths::resolve_path::dirname::( + outer_path.text, + ); + let mut join_buf = bun_paths::path_buffer_pool::get(); + for name in ism.map.external_source_names.iter() { + let name: &[u8] = name.as_ref(); + // The spec allows URLs in `sources[]` (e.g. `webpack:///src/a.ts`); + // path-joining would destroy the scheme, so pass them through. + if bun_core::strings::index_of(name, b"://").is_some() { + emit(joiner, name)?; + continue; + } + if bun_paths::resolve_path::Platform::AUTO.is_absolute(name) { + let rel = LinkerContext::source_map_relative_path(chunk_abs_dir, name)?; + emit(joiner, &rel)?; + continue; + } + // The checked join returns `None` on overflow (adversarial + // map); emit the raw, spec-valid name instead of panicking. + match bun_paths::resolve_path::join_abs_string_buf_checked::< + bun_paths::resolve_path::platform::Auto, + >(base_dir, join_buf.as_mut_slice(), &[name]) + { + Some(abs_path) => { + let rel = LinkerContext::source_map_relative_path(chunk_abs_dir, abs_path)?; + emit(joiner, &rel)?; + } + None => emit(joiner, name)?, + } + } + } + Ok(()) +} + #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum ScanCssImportsResult { Ok, @@ -2193,6 +2275,14 @@ 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 stitcher assumes one `sources[]` slot per file; + // chaining is gated to 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, @@ -2241,6 +2331,7 @@ impl<'a> LinkerContext<'a> { } else { None }, + input_source_map, mangled_props: Some(mangled_props), module_info, ..Default::default() diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index a4b7cc2acbaf..94eda26d88a8 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -187,6 +187,11 @@ pub(crate) struct Success { /// The package name from package.json, used for barrel optimization. pub(crate) package_name: ast::StoreStr, + + /// Decoded trailing inline `//# sourceMappingURL=data:...` map; `None` + /// when absent, disabled, or malformed. Moved into + /// `graph.input_files.input_source_map` by `on_parse_task_complete`. + pub(crate) input_source_map: Option>, } pub(crate) struct ResultError { @@ -2623,6 +2628,11 @@ 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; + // Copy these out before the tombstone: get_ast reborrows + // `(*transpiler).options` mutably, invalidating `topts` under + // Stacked Borrows. + let source_map_option = topts.source_map; + let has_dev_server = topts.has_dev_server(); // `topts` (a `&BundleOptions`) is dead past this point; the callees take // raw `*mut Transpiler` and reborrow `(*transpiler).options` mutably. let _ = topts; @@ -2683,6 +2693,20 @@ pub mod parse_worker { *step = Step::Resolve; + // Scan for an inline `//# sourceMappingURL=data:...` map to chain + // into the output sourcemap. Runs on `source.contents` regardless + // of origin (file read or plugin `onLoad`, covering #6173). Skipped + // under DevServer: its stitcher never consumes the result. + let input_source_map: Option> = if !has_dev_server + && source_map_option != 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(), @@ -2699,6 +2723,8 @@ pub mod parse_worker { } else { 0 }, + + input_source_map, }) } diff --git a/src/bundler/ServerComponentParseTask.rs b/src/bundler/ServerComponentParseTask.rs index b2f10b316297..610fe18f3691 100644 --- a/src/bundler/ServerComponentParseTask.rs +++ b/src/bundler/ServerComponentParseTask.rs @@ -203,6 +203,8 @@ fn task_callback( unique_key_for_additional_file: bun_ast::StoreStr::EMPTY, content_hash_for_additional_file: 0, package_name: bun_ast::StoreStr::EMPTY, + // Generated wrapper: nothing to chain. + input_source_map: None, }) } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 24999fd82b68..460893ee373b 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4955,6 +4955,13 @@ pub mod bv2_impl { // `memcpy` of `graph.ast`), and `CssChunk::asts` `forget()`s its // aliases, so this is the unique drop. { + // `input_source_map` slots are global-heap `Box`es; the + // slab-only `MultiArrayList::drop` would strand them, so + // drain explicitly (same pattern as `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; @@ -7085,6 +7092,14 @@ pub mod bv2_impl { // Record which loader we used for this file this.graph.input_files.items_loader_mut()[result_source_index] = result.loader; + // Move the decoded inline sourcemap onto the SoA slot, + // dropping any earlier occupant (incremental reparse). + { + 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 68ef9a6b2e9f..c1b53eefd781 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1190,6 +1190,11 @@ pub struct Options<'a> { /// builder as `LineOffsetTables::Borrowed`. pub line_offset_tables: Option<&'a SourceMap::line_offset_table::List>, + /// Inline `//# sourceMappingURL=data:...` map carried by the input + /// file; the chunk builder remaps emitted mappings through it. `None` + /// for the DevServer path (its stitcher assumes one slot per file). + pub input_source_map: Option<&'a SourceMap::InputSourceMap>, + pub mangled_props: Option<&'a crate::MangledProps>, } @@ -1243,6 +1248,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, } } @@ -7329,6 +7335,7 @@ pub(crate) fn get_source_map_builder<'a, const IS_BUN_PLATFORM: bool>( 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: opts.input_source_map.take(), line_offset_tables: match opts.line_offset_tables.take() { Some(table) => LineOffsetTables::Borrowed(table), None if generate_source_map == GenerateSourceMap::Lazy => LineOffsetTables::Deferred { diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index 899e5671c01f..8e874f1701c1 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -375,6 +375,17 @@ pub struct NewBuilder<'a, T: SourceMapFormatCtx> { /// `line_offset_table_byte_offset_list`. pub line_offset_table_first_non_ascii: RawSlice, + /// Inline `//# sourceMappingURL=data:...` map carried by the input + /// file; `add_source_mapping` remaps each mapping through it so the + /// emitted coordinates refer to the authored source. + pub input_source_map: Option<&'a crate::InputSourceMap>, + + /// Last intermediate-file line, seeding `find_line_with_hint`. Kept + /// separately because `prev_state.original_line` holds the remapped + /// *authored* line when chaining — the wrong coordinate space for the + /// intermediate's line-offset table. + 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 @@ -406,6 +417,8 @@ impl Default for NewBuilder<'_, T> { has_prev_state: false, line_offset_table_byte_offset_list: RawSlice::EMPTY, line_offset_table_first_non_ascii: RawSlice::EMPTY, + input_source_map: None, + prev_intermediate_line: 0, line_starts_with_mapping: false, cover_lines_without_mappings: false, approximate_input_line_count: 0, @@ -674,15 +687,16 @@ impl NewBuilder<'_, VLQSourceMap> { } let byte_offsets = self.line_offset_table_byte_offset_list.slice(); - // 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. + // Mappings arrive in (mostly) source order, so the previous call's + // intermediate line usually hits the O(1) fast path. Hint from + // `prev_intermediate_line`, not `prev_state.original_line`: the + // latter is the remapped authored line when chaining. 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)`. @@ -706,6 +720,24 @@ impl NewBuilder<'_, VLQSourceMap> { self.update_generated_line_and_column(output); + // Remap through the inline map if present, emitting chunk-relative + // `source_index` in the layout `LinkerContext` stitches: slot 0 = + // the intermediate, `1 + inner_idx` = inner `sources[inner_idx]`. + // Mappings the inner map doesn't cover fall back to slot 0. + 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(); + } + } + // 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 @@ -725,9 +757,9 @@ impl NewBuilder<'_, VLQSourceMap> { 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..a50cf2a391f3 --- /dev/null +++ b/src/sourcemap/InputSourceMap.rs @@ -0,0 +1,235 @@ +//! Inline `//# sourceMappingURL=data:...` sourcemap carried by a bundler +//! input file, stored on `Graph::InputFile`. `LinkerContext` expands its +//! `sources`/`sourcesContent` and `Chunk::Builder` remaps mappings through +//! it so the output map points at the authored source. + +use std::sync::Arc; + +use crate::ParsedSourceMap; + +/// `map.external_source_names` holds the chained-in `sources[]`; +/// `sources_content[i]` is `sourcesContent[i]` (`b""` when absent). +pub struct InputSourceMap { + pub map: Arc, + pub sources_content: Box<[Box<[u8]>]>, +} + +impl InputSourceMap { + /// `None` on malformed payloads — callers fall back to the raw file + /// bytes. Copies what it needs out of `json_bytes`. + pub fn parse(json_bytes: &[u8]) -> Option> { + parse_internal(json_bytes).ok() + } + + /// Parse the map from a trailing inline comment in `source`. `None` + /// for no/non-`data:` URL (external `.map` resolution is the + /// caller's) or a malformed payload. + pub fn parse_from_source(source: &[u8]) -> Option> { + let url = find_source_mapping_url(source)?; + parse_data_url(url) + } +} + +/// Malformed input behaves exactly like "no chain available". +struct InvalidSourceMap; + +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 root = bun_parsers::json::parse_json_into_arena(&json_src, &mut log, &arena) + .map_err(|_| InvalidSourceMap)?; + // Containers come back as `EObjectJSON`/`EArrayJSON` tape rows; read + // them through the tape accessors. + let obj: &bun_ast::E::ObjectJSON = match &root.data { + bun_ast::ExprData::EObjectJSON(o) => o.get(), + _ => return Err(InvalidSourceMap), + }; + use bun_ast::E::JsonValue; + + if let Some(version) = obj.get(b"version") { + match version { + JsonValue::Number(n) if n.value() == 3.0 => {} + _ => return Err(InvalidSourceMap), + } + } + + let mappings_slice: &[u8] = obj + .get(b"mappings") + .and_then(|v| v.as_str()) + .ok_or(InvalidSourceMap)?; + + let sources_paths = obj + .get(b"sources") + .and_then(|v| v.as_array()) + .ok_or(InvalidSourceMap)?; + + // `sourcesContent` is optional; when absent or null every slot is empty. + let sources_content_opt = match obj.get(b"sourcesContent") { + None => None, + Some(v) => match v.as_array() { + Some(arr) => Some(arr), + // `null` is tolerated; other non-array values are malformed. + None if matches!(v, JsonValue::Null) => None, + None => return Err(InvalidSourceMap), + }, + }; + + if let Some(arr) = sources_content_opt { + if arr.items().len() != sources_paths.items().len() { + return Err(InvalidSourceMap); + } + } + + let source_count = sources_paths.items().len(); + + // A `sources[i]` longer than `MAX_PATH_BYTES` rejects the whole map: + // the linker resolves it through fixed-size path buffers that panic on + // oversized (adversarial) input. + let mut source_paths_slice: Vec> = Vec::with_capacity(source_count); + for item in sources_paths.items() { + let s = item.as_str().ok_or(InvalidSourceMap)?; + if s.len() > bun_paths::MAX_PATH_BYTES { + return Err(InvalidSourceMap); + } + 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() { + let slot: Box<[u8]> = match item.as_str() { + Some(s) => Box::<[u8]>::from(s), + None => Box::<[u8]>::from(&b""[..]), + }; + sources_content_slice.push(slot); + } + } else { + for _ in 0..source_count { + sources_content_slice.push(Box::<[u8]>::from(&b""[..])); + } + } + + // Pass the real source count: downstream slot math doesn't clamp, so + // an out-of-range VLQ `source_index` must reject the map here instead + // of aliasing a neighboring file's `sources[]` slot. + let sources_count_i32: i32 = i32::try_from(source_count).map_err(|_| InvalidSourceMap)?; + let map_data = crate::mapping::parse( + mappings_slice, + None, + sources_count_i32, + i32::MAX as usize, + crate::mapping::ParseOptions { + allow_names: false, + sort: true, + }, + ) + .map_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. Anchored to the +/// final line (spec: the comment MUST be the last line) so a string literal +/// containing the needle can't 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 bun_core::strings::last_index_of_char(body, 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 spaces/tabs/CR around the URL: `= data:...` is spec-invalid but + // some toolchains emit it. + 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; + } + + // Tolerate any `;name[=value]` parameters (e.g. `;charset=utf-8`) + // before 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 = bun_core::strings::index_of_any(after, 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 5aaf6e92f0f4..8df79757610d 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -12,6 +12,8 @@ pub use error::{Error, Result}; #[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; + pub use bun_base64::vlq; pub use vlq::VLQ; use vlq::{decode as decode_vlq, decode_assume_valid as decode_vlq_assume_valid}; diff --git a/test/bundler/bun-build-inline-sourcemap-chain.test.ts b/test/bundler/bun-build-inline-sourcemap-chain.test.ts new file mode 100644 index 000000000000..f08111abaaf5 --- /dev/null +++ b/test/bundler/bun-build-inline-sourcemap-chain.test.ts @@ -0,0 +1,474 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { join } from "path"; + +// Regression coverage for https://github.com/oven-sh/bun/issues/30536 and +// https://github.com/oven-sh/bun/issues/6173: the bundler must chain 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. +// +// Kept in a dedicated file (rather than bun-build-api.test.ts) so the suite +// stays fast and deterministic: bun-build-api.test.ts carries a ~160s +// repeated-build stress test whose runtime sits close to its timeout under +// load, which is unrelated to this feature. +describe.concurrent("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); + }); + + // The Source Map spec allows `sources[i]` to be a URL + // (e.g. webpack's `webpack:///./src/x.ts`); path-joining such a name + // would destroy the scheme, so it must pass through verbatim. + test("URL-schemed inner source name passes through verbatim", async () => { + const authoredSrc = "export const w = 9;\n"; + const innerMap = { + version: 3, + sources: ["webpack:///./src/original.ts"], + sourcesContent: [authoredSrc], + names: [], + mappings: "AAAA;", + }; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-url", { + "intermediate.js": authoredSrc + inline, + "entry.ts": `import { w } from './intermediate.js';\nconsole.log(w);\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 scheme-prefixed name survives untouched (no join/relativize). + expect(parsed.sources).toContain("webpack:///./src/original.ts"); + }); + + // 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); + }); + + // An inner `sources[i]` longer than MAX_PATH_BYTES is resolved against + // the intermediate's directory via fixed-size path buffers; an + // adversarial inline map with such a source name must be rejected at + // parse time (clean fallback to the intermediate) rather than panicking + // the build in the path normalizer. MAX_PATH_BYTES is platform-dependent + // (4096 on Linux, ~96 KB on Windows), so use a name past the largest. + test("oversized inner source name — map rejected, build falls back", async () => { + const hugeName = Buffer.alloc(128 * 1024, "a").toString() + ".ts"; + const innerMap = { + version: 3, + sources: [hugeName], + sourcesContent: ["// authored\n"], + names: [], + mappings: "AAAA;", + }; + const inline = `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(innerMap)).toString("base64")}\n`; + + const dir = tempDirWithFiles("bun-build-chained-sourcemap-huge", { + "intermediate.js": "export const x = 7;\n" + inline, + "entry.ts": `import { x } from './intermediate.js';\nconsole.log(x);\n`, + }); + + // Spawn so an abort in the path normalizer would surface as a nonzero + // exit rather than a thrown JS error. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const r = await Bun.build({ entrypoints: [${JSON.stringify(join(dir, "entry.ts"))}], outdir: ${JSON.stringify(join(dir, "out"))}, format: "esm", target: "bun", sourcemap: "inline" }); + if (!r.success) { console.error("build failed"); process.exit(2); } + const text = await Bun.file(r.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")); + console.log(JSON.stringify({ hasHuge: parsed.sources.some(s => s.length > 4096), hasIntermediate: parsed.sources.some(s => s.endsWith("intermediate.js")) }));`, + ], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // A crash in the path normalizer aborts the child: empty stdout and a + // nonzero exit. Surfacing stderr here gives a useful message on failure + // (it is not asserted empty — debug/ASAN builds emit warnings). + expect(stdout.trim() === "" ? stderr : "ok").toBe("ok"); + expect(exitCode).toBe(0); + // The oversized map is rejected: no multi-KB source surfaces, and the + // intermediate remains as the deepest source. + expect(JSON.parse(stdout.trim())).toEqual({ hasHuge: false, hasIntermediate: 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); + }); + + // 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,(.+)/); + 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); + }); + + // A virtual module (onResolve custom namespace) has no on-disk directory + // to resolve inner names against; they must surface verbatim instead of + // being joined with a bogus base. + test("virtual-namespace module with inline sourcemap keeps inner names verbatim", async () => { + const dir = tempDirWithFiles("bun-build-virtual-chained-sourcemap", { + "entry.ts": `import { v } from 'virt:mod';\nconsole.log(v);\n`, + }); + + const authoredContent = "export const v = 7;\n"; + const innerMap = { + version: 3, + sources: ["virtual-authored.src"], + 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: "virtual", + setup(build) { + build.onResolve({ filter: /^virt:/ }, args => ({ namespace: "virt", path: args.path.slice(5) })); + build.onLoad({ filter: /.*/, namespace: "virt" }, () => ({ + contents: "export const v = 7;\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 inner name survives untouched (no join against a bogus base). + expect(parsed.sources).toContain("virtual-authored.src"); + }); +});