From 6be92b713060337968db92aa10708a04efeef055 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:28:14 +0000 Subject: [PATCH 01/11] bundler: compose output source maps through input sourceMappingURL maps When an input file ends in a sourceMappingURL comment (the output of a previous tsc, esbuild or compiler step), bun build now loads that map and translates every mapping it emits for the file through it. The output map lists that map's sources and sourcesContent in place of the intermediate file, so positions, error stacks and debuggers reach the original sources. Inline data: URLs (base64 or percent-encoded) and sidecar .map files are read, in //#, //@ and block comments; sourceRoot and sections (index maps) are applied; missing sourcesContent entries are read from disk. A map that cannot be loaded is a warning and the file maps to itself as before. The lexer recorded pragma argument locations with the comment's own offset added twice, so warnings about a pragma outside the file's first comment pointed past it. They now point at the argument. --- src/ast/ast_result.rs | 4 + src/bundler/Graph.rs | 6 + src/bundler/LinkerContext.rs | 106 +++--- src/bundler/ParseTask.rs | 24 +- src/bundler/ServerComponentParseTask.rs | 1 + src/bundler/bundle_v2.rs | 6 + src/bundler/input_source_map.rs | 134 ++++++++ src/bundler/lib.rs | 1 + src/js_parser/lexer.rs | 45 +-- src/js_parser/p.rs | 1 + src/js_printer/lib.rs | 13 +- src/sourcemap/Chunk.rs | 48 ++- src/sourcemap/InputSourceMap.rs | 428 ++++++++++++++++++++++++ src/sourcemap/lib.rs | 3 + test/bundler/bun-build-api.test.ts | 21 ++ test/bundler/bundler_edgecase.test.ts | 292 +++++++++++++++- test/bundler/expectBundled.ts | 35 ++ 17 files changed, 1070 insertions(+), 98 deletions(-) create mode 100644 src/bundler/input_source_map.rs create mode 100644 src/sourcemap/InputSourceMap.rs diff --git a/src/ast/ast_result.rs b/src/ast/ast_result.rs index dcc3b920b894..ac81d46a1966 100644 --- a/src/ast/ast_result.rs +++ b/src/ast/ast_result.rs @@ -54,6 +54,9 @@ pub struct Ast<'a> { // them under the same lifetime-erased contract as `StoreRef`. pub hashbang: StoreStr, pub directive: Option, + /// The last `//# sourceMappingURL=` (or `//@`, `/*# */`) comment in the + /// file: the URL text and where it is. + pub source_mapping_url: Option, /// `export default X` where `X` is an import binding in this file. When /// `X` resolves to a module namespace the linker binds importers of /// `default` through `X` (a namespace never changes identity, so the @@ -125,6 +128,7 @@ impl<'a> Ast<'a> { import_records: ImportRecordList::new_in(arena), hashbang: StoreStr::EMPTY, directive: None, + source_mapping_url: None, export_default_alias_of_import: Ref::NONE, parts: PartList::new_in(arena), symbols: SymbolList::new_in(arena), diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index b7adc95e436c..cec1fb4c00ea 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -112,6 +112,10 @@ pub struct InputFile { pub unique_key_for_additional_file: Box<[u8], AstAlloc>, pub content_hash_for_additional_file: u64, pub flags: InputFileFlags, + /// The source map the file names in its `sourceMappingURL` comment, when + /// source maps are on and it could be loaded. Global-heap; drained in + /// `BundleV2::deinit_without_freeing_arena`. + pub input_source_map: Option>, } impl Default for InputFile { @@ -125,6 +129,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, } } } @@ -142,6 +147,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 bbf0c81e36df..66e6b69f5461 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1221,6 +1221,7 @@ impl<'a> LinkerContext<'a> { let mut j = StringJoiner::default(); let sources = self.parse_graph().input_files.items_source(); + let input_source_maps = self.parse_graph().input_files.items_input_source_map(); let quoted_source_map_contents = self.graph.files.items_quoted_source_contents(); // Entries in `results` do not 1:1 map to source files, the mapping @@ -1232,80 +1233,69 @@ impl<'a> LinkerContext<'a> { // --> // Which source index in the generated sourcemap, referred to // as the "mapping source index" within this function to be distinct. + // A file with an input source map takes one slot per source of that + // map, starting here; its chunks carry slot offsets relative to it. let mut source_id_map: ArrayHashMap = ArrayHashMap::new(); let source_indices = results.items_source_index(); j.push_static(b"{\n \"version\": 3,\n \"sources\": ["); - 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)?; + let mut next_mapping_source_index: i32 = 0; + for &index in source_indices { + let gop = source_id_map.get_or_put(index)?; + if gop.found_existing { + continue; + } + *gop.value_ptr = next_mapping_source_index; - // Note: the relative path lives in a local owned buffer - // (drops at scope exit). + let mut push_source = |j: &mut StringJoiner, abs_path_or_name: &[u8], is_file: bool| { let rel_path_storage; - let pretty: &[u8] = if path.is_file() { - rel_path_storage = Self::source_map_relative_path(chunk_abs_dir, path.text)?; + let name: &[u8] = if is_file { + rel_path_storage = + Self::source_map_relative_path(chunk_abs_dir, abs_path_or_name)?; &rel_path_storage } else { - path.pretty + abs_path_or_name }; - - let mut quote_buf = MutableString::init(pretty.len() + 2)?; - js_printer::quote_for_json(pretty, &mut quote_buf, false)?; + let mut quote_buf = MutableString::init(name.len() + ", ".len() + 2)?; + if next_mapping_source_index > 0 { + quote_buf.append_assume_capacity(b", "); + } + js_printer::quote_for_json(name, &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..] { - 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; + Ok::<(), BunError>(()) + }; - 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 - }; - - 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()); + match input_source_maps[index as usize].as_deref() { + None => { + let path = &sources[index as usize].path; + if path.is_file() { + push_source(&mut j, path.text, true)?; + } else { + push_source(&mut j, path.pretty, false)?; + } + } + Some(input_source_map) => { + for source in input_source_map.sources.iter() { + push_source(&mut j, &source.path, source.is_file)?; + } + } } } j.push_static(b"],\n \"sourcesContent\": ["); - 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] + for (i, &index) in source_id_map.keys().iter().enumerate() { + j.push_static(if i == 0 { b"\n " } else { b",\n " }); + j.push_static(match input_source_maps[index as usize].as_deref() { + None => quoted_source_map_contents[index 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] - .as_deref() - .unwrap_or(b""), - ); - } + Some(input_source_map) => &input_source_map.quoted_sources_content, + }); } j.push_static(b"\n ],\n \"mappings\": \""); @@ -1344,7 +1334,9 @@ impl<'a> LinkerContext<'a> { )?; prev_end_state = chunk.end_state; - prev_end_state.source_index = mapping_source_index; + // A chunk's own source indices count from 0 (and stay 0 unless the + // file had an input source map with several sources). + prev_end_state.source_index += mapping_source_index; prev_column_offset = chunk.final_generated_column; if prev_end_state.generated_line == 0 { @@ -1733,6 +1725,10 @@ impl SourceMapData { if !loader.can_have_source_map() { return; } + // The output map carries the input map's `sourcesContent` instead. + if parse_graph.input_files.items_input_source_map()[source_index as usize].is_some() { + return; + } let source: &Source = &parse_graph.input_files.items_source()[source_index as usize]; // Allocate from the worker's AST allocation state (installed by @@ -2413,6 +2409,9 @@ 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) }; + 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, @@ -2459,6 +2458,7 @@ impl<'a> LinkerContext<'a> { require_or_import_meta_for_source_callback: js_printer::RequireOrImportMetaCallback::init(self), line_offset_tables: Some(line_offset_table), + input_source_map, target: self.options.target, hmr_ref: if self.options.output_format == Format::InternalBakeDev { diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index f6465633a5b5..9cf654855381 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -204,6 +204,9 @@ pub(crate) struct Success { /// The package name from package.json, used for barrel optimization. pub(crate) package_name: ast::StoreStr, + + /// See `InputFile::input_source_map`, where this moves on completion. + pub(crate) input_source_map: Option>, } pub(crate) struct ResultError { @@ -821,6 +824,7 @@ pub mod parse_worker { unique_key_prefix: u64, unique_key_for_additional_file: &mut FileLoaderHash, has_any_css_locals: &AtomicU32, + source_mapping_url: &mut Option, ) -> core::result::Result, AnyError> { use core::fmt::Write as _; @@ -846,7 +850,10 @@ pub mod parse_worker { // `Cached`/`AlreadyBundled` are runtime-loader // states that never reach the bundler's `getAST`, so unwrap. match res { - bun_js_parser::Result::Ast(ast) => Ok(JSAst::init(*ast)), + bun_js_parser::Result::Ast(ast) => { + *source_mapping_url = ast.source_mapping_url; + Ok(JSAst::init(*ast)) + } bun_js_parser::Result::Cached | bun_js_parser::Result::AlreadyBundled(_) => { unreachable!("bundler parse never yields Cached/AlreadyBundled") @@ -2687,8 +2694,13 @@ 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; + // The dev server joins per-file maps itself (`SourceMapStore`) and + // assumes one `sources` entry per file, so it gets no input maps. + let use_input_source_maps = + topts.source_map != options::SourceMapOption::None && !topts.has_dev_server(); // `topts` (a `&BundleOptions`) is dead past this point; the callees take // raw `*mut Transpiler` and reborrow `(*transpiler).options` mutably. + let mut source_mapping_url: Option = None; let ast_result: core::result::Result = if let Some(message) = unsupported_directive { log.add_range_error(Some(source), UseDirective::range(entry_contents), message); @@ -2705,6 +2717,7 @@ pub mod parse_worker { task_ctx.unique_key, &mut unique_key_for_additional_file, &task_ctx.linker.has_any_css_locals, + &mut source_mapping_url, ) } else if loader.is_css() { get_empty_css_ast(log, transpiler, opts, bump, source) @@ -2750,6 +2763,13 @@ pub mod parse_worker { *step = Step::Resolve; + let input_source_map = match source_mapping_url { + Some(comment) if use_input_source_maps => { + crate::input_source_map::load(log, source, comment) + } + _ => None, + }; + Ok(Success { ast, source: source.clone(), @@ -2762,6 +2782,8 @@ pub mod parse_worker { // Hash the files in here so that we do it in parallel. content_hash_for_additional_file: unique_key_for_additional_file.content_hash, + + input_source_map, }) } diff --git a/src/bundler/ServerComponentParseTask.rs b/src/bundler/ServerComponentParseTask.rs index 02e76cd73c8c..6de066c4567b 100644 --- a/src/bundler/ServerComponentParseTask.rs +++ b/src/bundler/ServerComponentParseTask.rs @@ -198,6 +198,7 @@ fn task_callback( unique_key_for_additional_file: bun_ast::StoreStr::EMPTY, content_hash_for_additional_file: 0, package_name: bun_ast::StoreStr::EMPTY, + input_source_map: None, }) } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index a0aba3c8f5e4..fd4eb2233f66 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5563,6 +5563,10 @@ pub mod bv2_impl { // macro takes only one side (`linker.graph.ast` is a bitwise SoA // `memcpy` of `graph.ast`), and `CssChunk::asts` `forget()`s its // aliases, so this is the unique drop. + // `InputFile.input_source_map` is likewise global-heap, not arena. + for input_source_map in self.graph.input_files.items_input_source_map_mut() { + drop(input_source_map.take()); + } { macro_rules! take_ast_cols { ($ast:expr) => {{ @@ -7754,6 +7758,8 @@ pub mod bv2_impl { // Record which loader we used for this file this.graph.input_files.items_loader_mut()[result_source_index] = result.loader; + this.graph.input_files.items_input_source_map_mut()[result_source_index] = + result.input_source_map.take(); bun_core::scoped_log!( Bundle, diff --git a/src/bundler/input_source_map.rs b/src/bundler/input_source_map.rs new file mode 100644 index 000000000000..609757db304b --- /dev/null +++ b/src/bundler/input_source_map.rs @@ -0,0 +1,134 @@ +//! Loads the source map an input file names in its `sourceMappingURL` +//! comment, so the output map can point through it at the original sources. + +use bun_ast::{Log, Source, Span}; +use bun_sourcemap::InputSourceMap; +use bun_sourcemap::input_source_map::{path_from_file_url, url_scheme}; + +/// `comment` is the URL of the file's last `sourceMappingURL` comment. A map +/// that cannot be loaded is reported as a warning on `log` and skipped: the +/// file then maps to itself, as if it had no comment. +pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option> { + let url: &[u8] = comment.text.slice(); + if url.is_empty() { + return None; + } + let source_dir: Option<&[u8]> = source + .path + .is_file() + .then(|| source.path.name().dir_with_trailing_slash()); + + let json: Vec; + let map_dir: Option>; + let map_name: Box<[u8]>; + match bun_resolver::DataURL::parse(url) { + Ok(Some(data_url)) => { + match data_url.decode_data() { + Ok(bytes) => json = bytes, + Err(_) => { + log.add_range_warning_fmt( + Some(source), + comment.range, + format_args!( + "Unsupported source map comment: could not decode the \"data:\" URL" + ), + ); + return None; + } + } + map_dir = source_dir.map(Box::from); + map_name = Box::from(&b"data: URL"[..]); + } + Ok(None) | Err(_) => { + let map_path: Box<[u8]> = if let Some(path) = path_from_file_url(url) { + path + } else if url_scheme(url).is_some() || url.starts_with(b"//") { + // `https://…` and the like: nothing a build can read, and not + // something the author of this build can fix, so stay quiet. + return None; + } else if let Some(source_dir) = source_dir { + let url = strip_query_and_fragment(url); + let decoded; + let url: &[u8] = if bun_core::strings::contains_char(url, b'%') { + match bun_url::PercentEncoding::decode_alloc(url) { + Ok(d) => { + decoded = d; + &decoded + } + Err(_) => url, + } + } else { + url + }; + let mut buf = bun_paths::path_buffer_pool::get(); + match bun_paths::resolve_path::join_abs_string_buf_checked::< + bun_paths::resolve_path::platform::Auto, + >(source_dir, &mut buf[..], &[url]) + { + Some(joined) => Box::from(joined), + None => return None, + } + } else { + // A virtual module (plugin namespace) has no directory to + // resolve a relative URL against. + return None; + }; + + json = match bun_sys::File::read_from(bun_sys::Fd::cwd(), &map_path) { + Ok(bytes) => bytes, + Err(err) => { + if err.get_errno() == bun_sys::E::ENOENT { + log.add_range_warning_fmt( + Some(source), + comment.range, + format_args!( + "Cannot find source map file: {}", + bstr::BStr::new(&map_path) + ), + ); + } else { + log.add_range_warning_fmt( + Some(source), + comment.range, + format_args!( + "Cannot read source map file \"{}\": {}", + bstr::BStr::new(&map_path), + bstr::BStr::new(err.name()) + ), + ); + } + return None; + } + }; + map_dir = Some(Box::from(bun_paths::resolve_path::dirname::< + bun_paths::resolve_path::platform::Auto, + >(&map_path))); + map_name = map_path; + } + } + + let mut read_file = |path: &[u8]| bun_sys::File::read_from(bun_sys::Fd::cwd(), path).ok(); + match InputSourceMap::parse(&json, map_dir.as_deref(), &mut read_file) { + Ok(Some(map)) => Some(Box::new(map)), + Ok(None) => None, + Err(err) => { + log.add_range_warning_fmt( + Some(source), + comment.range, + format_args!( + "Ignoring the source map \"{}\" of this file: {}", + bstr::BStr::new(&map_name), + err.0 + ), + ); + None + } + } +} + +fn strip_query_and_fragment(url: &[u8]) -> &[u8] { + match bun_core::strings::index_of_any(url, b"?#") { + Some(i) => &url[..i], + None => url, + } +} diff --git a/src/bundler/lib.rs b/src/bundler/lib.rs index 40d6557f436e..498e3dcd490b 100644 --- a/src/bundler/lib.rs +++ b/src/bundler/lib.rs @@ -106,6 +106,7 @@ pub mod linker_graph; // const without a cross-crate hook. Re-export for existing callers. pub use bun_js_parser::defines_table; pub mod bundle_v2; +pub(crate) mod input_source_map; #[path = "LinkerContext.rs"] pub mod linker_context_mod; #[path = "options.rs"] diff --git a/src/js_parser/lexer.rs b/src/js_parser/lexer.rs index 26f624f19c00..8950c4f8ffd8 100644 --- a/src/js_parser/lexer.rs +++ b/src/js_parser/lexer.rs @@ -1976,7 +1976,7 @@ impl<'a> Lexer<'a> { b'@' | b'#' => { let chunk = rest; let offset = self.scan_pragma( - self.start + i + (text.len() - rest.len()), + self.start + (end_comment_text - rest.len()), chunk, CommentKind::MultiLine, PureAnnotation::Allow, @@ -2118,7 +2118,7 @@ impl<'a> Lexer<'a> { first_marker = false; let chunk = js_ast::StoreStr::new(self.remaining()); self.current += self.scan_pragma( - pragma_trigger_pos, + self.current, chunk.slice(), CommentKind::SingleLine, pure, @@ -2144,11 +2144,12 @@ impl<'a> Lexer<'a> { } /// Scans the string for a pragma. - /// offset is used when there's an issue with the JSX pragma later on. + /// `chunk_start` is the offset of `chunk` in the file; the spans recorded + /// here carry it so a later diagnostic about the pragma points at it. /// Returns the byte length to advance by if found, otherwise 0. fn scan_pragma( &mut self, - offset_for_errors: usize, + chunk_start: usize, chunk: &[u8], comment: CommentKind, pure: PureAnnotation, @@ -2163,9 +2164,7 @@ impl<'a> Lexer<'a> { } if strings::has_prefix_with_word_boundary(chunk, b"jsx") { - if let Some(span) = - PragmaArg::scan(self.start + offset_for_errors, b"jsx", chunk, allow_newline) - { + if let Some(span) = PragmaArg::scan(chunk_start, b"jsx", chunk, allow_newline) { self.jsx_pragma._jsx = span; return "jsx".len() + if span.range.len > 0 { @@ -2175,12 +2174,7 @@ impl<'a> Lexer<'a> { }; } } else if strings::has_prefix_with_word_boundary(chunk, b"jsxFrag") { - if let Some(span) = PragmaArg::scan( - self.start + offset_for_errors, - b"jsxFrag", - chunk, - allow_newline, - ) { + if let Some(span) = PragmaArg::scan(chunk_start, b"jsxFrag", chunk, allow_newline) { self.jsx_pragma._jsx_frag = span; return "jsxFrag".len() + if span.range.len > 0 { @@ -2190,12 +2184,7 @@ impl<'a> Lexer<'a> { }; } } else if strings::has_prefix_with_word_boundary(chunk, b"jsxRuntime") { - if let Some(span) = PragmaArg::scan( - self.start + offset_for_errors, - b"jsxRuntime", - chunk, - allow_newline, - ) { + if let Some(span) = PragmaArg::scan(chunk_start, b"jsxRuntime", chunk, allow_newline) { self.jsx_pragma._jsx_runtime = span; return "jsxRuntime".len() + if span.range.len > 0 { @@ -2205,12 +2194,9 @@ impl<'a> Lexer<'a> { }; } } else if strings::has_prefix_with_word_boundary(chunk, b"jsxImportSource") { - if let Some(span) = PragmaArg::scan( - self.start + offset_for_errors, - b"jsxImportSource", - chunk, - allow_newline, - ) { + if let Some(span) = + PragmaArg::scan(chunk_start, b"jsxImportSource", chunk, allow_newline) + { self.jsx_pragma._jsx_import_source = span; return "jsxImportSource".len() + if span.range.len > 0 { @@ -2224,8 +2210,7 @@ impl<'a> Lexer<'a> { { // Check includes space for prefix return PragmaArg::scan_source_mapping_url_value( - self.start, - offset_for_errors, + chunk_start, chunk, &mut self.source_mapping_url, ); @@ -3476,8 +3461,7 @@ impl PragmaArg { /// "//# sourceMappingURL=data:/adspaoksdpkz" /// ^^^^^^^^^^^^^^^^^^ pub(crate) fn scan_source_mapping_url_value( - start: usize, - offset_for_errors: usize, + chunk_start: usize, chunk: &[u8], result: &mut Option, ) -> usize { @@ -3501,8 +3485,7 @@ impl PragmaArg { // Now we have the correct argument length (url_len) and the argument text. let url = &url_and_rest_of_code[0..url_len]; - // Calculate absolute start location of the argument - let absolute_arg_start = start + offset_for_errors + PREFIX as usize; + let absolute_arg_start = chunk_start + PREFIX as usize; *result = Some(js_ast::Span { range: Range { diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index b5795f0a802d..dd4f8dce014e 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -9393,6 +9393,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O has_import_meta: self.has_import_meta, hashbang: hashbang.into(), + source_mapping_url: self.lexer.source_mapping_url, export_default_alias_of_import: self.export_default_alias_of_import, // TODO: cross-module constant inlining // const_values: self.const_values, diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 6163a50c0eca..0b1814a226a3 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1379,6 +1379,11 @@ pub struct Options<'a> { /// builder as `LineOffsetTables::Borrowed`. pub line_offset_tables: Option<&'a SourceMap::line_offset_table::List>, + /// The source map the input file itself carried (its `sourceMappingURL` + /// comment). The builder maps every position through it, see + /// `SourceMap::chunk::Builder::input_source_map`. + pub input_source_map: Option<&'a SourceMap::InputSourceMap>, + pub mangled_props: Option<&'a crate::MangledProps>, } @@ -1434,6 +1439,7 @@ impl<'a> Default for Options<'a> { import_member_bindings: None, has_dynamic_import_items: false, line_offset_tables: None, + input_source_map: None, mangled_props: None, } } @@ -7644,9 +7650,14 @@ pub(crate) fn get_source_map_builder<'a, const IS_BUN_PLATFORM: bool>( // opts.source_map_allocator orelse opts.allocator — allocator dropped IS_BUN_PLATFORM && generate_source_map == GenerateSourceMap::Lazy, ), - cover_lines_without_mappings: true, + // Repeating the previous mapping at the start of an unmapped line only + // helps when that mapping is ours. Through an input source map it would + // point wherever that map last pointed, so leave such lines unmapped + // (esbuild does the same). + cover_lines_without_mappings: opts.input_source_map.is_none(), 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, 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 01a5ab7d78d8..c69d9c017e9b 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -369,6 +369,17 @@ pub struct NewBuilder<'a, T: SourceMapFormatCtx> { /// `line_offset_table_byte_offset_list`. pub line_offset_table_first_non_ascii: RawSlice, + /// The input file's own source map (from its `sourceMappingURL` comment). + /// When set, every mapping is translated through it and a position it does + /// not cover gets no mapping, so `source_index` and `original_*` in the + /// chunk refer to `input_source_map.sources`, not to the input file. + pub input_source_map: Option<&'a crate::InputSourceMap>, + + /// Line in the input file of the previous `add_source_mapping` call, the + /// seed for `find_line_with_hint`. (`prev_state.original_line` is a line in + /// some other file once an input source map has rewritten it.) + pub prev_input_line: u32, + // 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 @@ -400,6 +411,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_input_line: 0, line_starts_with_mapping: false, cover_lines_without_mappings: false, approximate_input_line_count: 0, @@ -607,9 +620,22 @@ impl NewBuilder<'_, VLQSourceMap> { self.last_generated_update = output.len() as u32; } + /// Returns `false` when the input source map does not cover the position, + /// in which case nothing was appended. #[inline(always)] - pub(crate) fn append_mapping(&mut self, current_state: SourceMapState) { + pub(crate) fn append_mapping(&mut self, mut current_state: SourceMapState) -> bool { + if let Some(input_source_map) = self.input_source_map { + let Some(mapping) = + input_source_map.find(current_state.original_line, current_state.original_column) + else { + return false; + }; + current_state.source_index = mapping.source_index; + current_state.original_line = mapping.original.lines.zero_based(); + current_state.original_column = mapping.original.columns.zero_based(); + } self.append_mapping_without_remapping(current_state); + true } #[inline(always)] @@ -669,15 +695,13 @@ 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. - let original_line = LineOffsetTable::find_line_with_hint( - byte_offsets, - loc, - self.prev_state.original_line as u32, - ); + // call's line is the right answer or one/two lines before it >95% of + // the time. Seed `find_line_with_hint` with it; the fallback is the + // same binary search as before. + let original_line = + LineOffsetTable::find_line_with_hint(byte_offsets, loc, self.prev_input_line); let idx = original_line.max(0) as usize; + self.prev_input_line = idx as u32; // PERF: read the three columns directly instead of `list.get(idx)`. // `MultiArrayList::get` builds a 272-byte `Slice` (`[*mut u8; 32]` + @@ -716,7 +740,7 @@ impl NewBuilder<'_, VLQSourceMap> { }); } - self.append_mapping(SourceMapState { + let appended = 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, @@ -725,7 +749,9 @@ impl NewBuilder<'_, VLQSourceMap> { }); // This line now has a mapping on it, so don't insert another one - self.line_starts_with_mapping = true; + if appended { + self.line_starts_with_mapping = true; + } } } diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs new file mode 100644 index 000000000000..45a0abcae30c --- /dev/null +++ b/src/sourcemap/InputSourceMap.rs @@ -0,0 +1,428 @@ +//! A source map that an input file points at through its `sourceMappingURL` +//! comment: the map a previous tool (tsc, esbuild, a Svelte compiler) emitted +//! for that file. The printer translates every output position through it, so +//! the emitted map names the files this map names instead of the input file. + +use std::borrow::Cow; + +use bun_ast::e::{JsonValue, ObjectJSON}; +use bun_ast::expr::Data as ExprData; +use bun_core::MutableString; + +use crate::mapping::{self, Mapping}; +use crate::{LineColumnOffset, Ordinal}; + +pub struct InputSourceMap { + mappings: mapping::List, + /// The map's `sources`, with `sourceRoot` applied and relative entries + /// resolved against the map's directory. + pub sources: Box<[InputSource]>, + /// The `sourcesContent` entry for each of `sources`, as JSON (`"..."` or + /// `null`), joined with `",\n "`: ready to splice into an output map. + pub quoted_sources_content: Box<[u8]>, +} + +pub struct InputSource { + pub path: Box<[u8]>, + /// `path` is an absolute file-system path. Otherwise it is a URL or an + /// opaque name and is emitted as written. + pub is_file: bool, +} + +/// Why an input source map was rejected. The input file is still bundled; its +/// output map then points at the input file itself. +pub struct ParseError(pub Cow<'static, str>); + +impl InputSourceMap { + /// Parses source-map `json` (ECMA-426, including `sections` index maps). + /// + /// `map_dir` is the directory relative `sources` entries resolve against: + /// the `.map` file's directory, or the input file's directory for a `data:` + /// URL. `None` leaves them as written. `read_file` supplies the text of a + /// file source whose `sourcesContent` entry is missing or `null`. + /// + /// `Ok(None)`: the map is well-formed but maps nothing. + pub fn parse( + json: &[u8], + map_dir: Option<&[u8]>, + read_file: &mut dyn FnMut(&[u8]) -> Option>, + ) -> Result, ParseError> { + let json_source = bun_ast::Source::init_path_string("input.js.map", json); + let mut log = bun_ast::Log::init(); + let parsed = match bun_parsers::json::ParsedJson::parse_json(&json_source, &mut log) { + Ok(parsed) => parsed, + Err(_) => { + let reason = log + .msgs + .iter() + .find(|msg| msg.kind == bun_ast::Kind::Err) + .map(|msg| String::from_utf8_lossy(&msg.data.text).into_owned()); + return Err(ParseError(match reason { + Some(text) => Cow::Owned(format!("invalid JSON: {text}")), + None => Cow::Borrowed("invalid JSON"), + })); + } + }; + let ExprData::EObjectJSON(root) = parsed.root.data else { + return Err(ParseError(Cow::Borrowed("expected a JSON object"))); + }; + let root: &ObjectJSON = root.get(); + + struct Section<'a> { + line_offset: i32, + column_offset: i32, + map: &'a ObjectJSON, + } + let mut sections: Vec> = Vec::new(); + match root.get(b"sections") { + None => sections.push(Section { + line_offset: 0, + column_offset: 0, + map: root, + }), + Some(JsonValue::Array(items)) => { + for item in items.get().items() { + let Some(section) = item.as_object() else { + continue; + }; + let Some(map) = section.get(b"map") else { + continue; + }; + let Some(map) = map.as_object() else { + return Err(ParseError(Cow::Borrowed( + "expected \"map\" in \"sections\" to be an object", + ))); + }; + let (mut line_offset, mut column_offset) = (0, 0); + if let Some(offset) = section.get(b"offset") { + let Some(offset) = offset.as_object() else { + return Err(ParseError(Cow::Borrowed( + "expected \"offset\" in \"sections\" to be an object", + ))); + }; + if let Some(JsonValue::Number(line)) = offset.get(b"line") { + line_offset = line.value() as i32; + } + if let Some(JsonValue::Number(column)) = offset.get(b"column") { + column_offset = column.value() as i32; + } + } + if line_offset < 0 || column_offset < 0 { + return Err(ParseError(Cow::Borrowed( + "negative \"offset\" in \"sections\"", + ))); + } + sections.push(Section { + line_offset, + column_offset, + map, + }); + } + } + Some(_) => { + return Err(ParseError(Cow::Borrowed( + "expected \"sections\" to be an array", + ))); + } + } + + let mut mappings = mapping::List::default(); + let mut sources: Vec = Vec::new(); + let mut sources_content: Vec> = Vec::new(); + let mut needs_sort = false; + let mut prev_offset = (0i32, 0i32); + + for section in §ions { + match section.map.get(b"version") { + Some(JsonValue::Number(n)) if n.value() == 3.0 => {} + // Silently skip a section with a missing or unknown version, + // like esbuild does. + _ => continue, + } + let vlq: &[u8] = match section.map.get(b"mappings") { + Some(JsonValue::String(s)) => s.slice(), + Some(_) => { + return Err(ParseError(Cow::Borrowed( + "expected \"mappings\" to be a string", + ))); + } + None => continue, + }; + let section_sources: &[JsonValue] = match section.map.get(b"sources") { + Some(JsonValue::Array(items)) => items.get().items(), + Some(_) => { + return Err(ParseError(Cow::Borrowed( + "expected \"sources\" to be an array", + ))); + } + None => continue, + }; + if vlq.is_empty() || section_sources.is_empty() { + continue; + } + let source_root: &[u8] = match section.map.get(b"sourceRoot") { + Some(JsonValue::String(s)) => s.slice(), + _ => b"", + }; + let section_sources_content: &[JsonValue] = match section.map.get(b"sourcesContent") { + Some(JsonValue::Array(items)) => items.get().items(), + _ => &[], + }; + + let Ok(section_sources_len) = i32::try_from(section_sources.len()) else { + return Err(ParseError(Cow::Borrowed("too many \"sources\""))); + }; + let Ok(source_offset) = i32::try_from(sources.len()) else { + return Err(ParseError(Cow::Borrowed("too many \"sources\""))); + }; + let parsed = match mapping::parse( + vlq, + None, + section_sources_len, + i32::MAX as usize, + mapping::ParseOptions { + allow_names: false, + sort: true, + }, + ) { + Ok(parsed) => parsed, + Err(fail) => { + return Err(ParseError(Cow::Owned(format!( + "bad \"mappings\" data at character {}: {}", + fail.loc.start.max(0), + fail.err.message() + )))); + } + }; + + if (section.line_offset, section.column_offset) < prev_offset { + needs_sort = true; + } + prev_offset = (section.line_offset, section.column_offset); + + if sections.len() == 1 && prev_offset == (0, 0) { + let mut parsed = parsed; + mappings = core::mem::take(&mut parsed.mappings); + } else { + let section_mappings = &parsed.mappings; + let generated = section_mappings.generated(); + let original = section_mappings.original(); + let source_index = section_mappings.source_index(); + for i in 0..generated.len() { + let on_first_line = generated[i].lines.zero_based() == 0; + mappings + .append(&Mapping { + generated: LineColumnOffset { + lines: Ordinal::from_zero_based( + generated[i] + .lines + .zero_based() + .saturating_add(section.line_offset), + ), + columns: Ordinal::from_zero_based( + generated[i].columns.zero_based().saturating_add( + if on_first_line { + section.column_offset + } else { + 0 + }, + ), + ), + }, + original: original[i], + source_index: source_index[i] + source_offset, + name_index: -1, + }) + .map_err(|_| ParseError(Cow::Borrowed("out of memory")))?; + } + } + + for name in section_sources { + sources.push(resolve_source( + name.as_str().unwrap_or(b""), + source_root, + map_dir, + )); + } + for i in 0..section_sources.len() { + sources_content.push(section_sources_content.get(i).and_then(JsonValue::as_str)); + } + } + + if sources.is_empty() || mappings.generated().is_empty() { + return Ok(None); + } + if needs_sort { + mappings.sort(); + } + + let mut quoted = MutableString::init_empty(); + for (i, (source, content)) in sources.iter().zip(&sources_content).enumerate() { + if i > 0 { + quoted + .append(b",\n ") + .map_err(|_| ParseError(Cow::Borrowed("out of memory")))?; + } + let from_disk; + let content: Option<&[u8]> = match content { + Some(content) => Some(content), + None if source.is_file => { + from_disk = read_file(&source.path); + from_disk.as_deref() + } + None => None, + }; + match content { + Some(content) => bun_core::quote_for_json(content, &mut quoted, false) + .map_err(|_| ParseError(Cow::Borrowed("out of memory")))?, + None => quoted + .append(b"null") + .map_err(|_| ParseError(Cow::Borrowed("out of memory")))?, + } + } + + Ok(Some(InputSourceMap { + mappings, + sources: sources.into_boxed_slice(), + quoted_sources_content: quoted.list.into_boxed_slice(), + })) + } + + /// The mapping that covers `line`:`column` (zero-based, columns in UTF-16 + /// code units) of the file this map was emitted for, if any. + #[inline] + pub fn find(&self, line: i32, column: i32) -> Option { + self.mappings.find( + Ordinal::from_zero_based(line), + Ordinal::from_zero_based(column), + ) + } +} + +/// Applies `sourceRoot` to one `sources` entry and resolves the result the way +/// ECMA-426 "Resolving Sources" does: a URL with a scheme other than `file:` +/// stays as written, a `file:` URL becomes a path, and anything else is a path +/// relative to `map_dir`. +fn resolve_source(name: &[u8], source_root: &[u8], map_dir: Option<&[u8]>) -> InputSource { + let name: Cow<'_, [u8]> = if source_root.is_empty() || url_scheme(name).is_some() { + Cow::Borrowed(name) + } else { + let mut joined = Vec::with_capacity(source_root.len() + 1 + name.len()); + joined.extend_from_slice(source_root); + if !joined.ends_with(b"/") { + joined.push(b'/'); + } + joined.extend_from_slice(name); + Cow::Owned(joined) + }; + + let verbatim = |name: &[u8]| InputSource { + path: Box::from(name), + is_file: false, + }; + + if let Some(path) = path_from_file_url(&name) { + return InputSource { + path: match map_dir { + Some(map_dir) => join_path(map_dir, &path).unwrap_or(path), + None => path, + }, + is_file: true, + }; + } + if url_scheme(&name).is_some() || name.starts_with(b"//") { + return verbatim(&name); + } + let Some(map_dir) = map_dir else { + return verbatim(&name); + }; + match join_path(map_dir, &percent_decode(&name)) { + Some(path) => InputSource { + path, + is_file: true, + }, + None => verbatim(&name), + } +} + +/// `path` resolved against the absolute directory `base` and normalized (an +/// absolute `path` is only normalized). `None` when the result does not fit a +/// path buffer. +fn join_path(base: &[u8], path: &[u8]) -> Option> { + use bun_paths::resolve_path::{join_abs_string_buf_checked, platform}; + let mut buf = bun_paths::path_buffer_pool::get(); + join_abs_string_buf_checked::(base, &mut buf[..], &[path]).map(Box::from) +} + +/// The scheme of `url` (`file` for `file:///a.js`), if it has one. A single +/// ASCII letter before `:\` or `:/` is a Windows drive letter, not a scheme. +pub fn url_scheme(url: &[u8]) -> Option<&[u8]> { + let (&first, rest) = url.split_first()?; + if !first.is_ascii_alphabetic() { + return None; + } + for (i, &c) in rest.iter().enumerate() { + match c { + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'+' | b'-' | b'.' => {} + b':' => { + let scheme = &url[..i + 1]; + if scheme.len() == 1 && matches!(url.get(i + 2), Some(b'/' | b'\\')) { + return None; + } + return Some(scheme); + } + _ => return None, + } + } + None +} + +/// The file-system path a `file:` URL names, percent-decoded. `None` when +/// `url` is not a `file:` URL or names a remote host. +pub fn path_from_file_url(url: &[u8]) -> Option> { + let scheme = url_scheme(url)?; + if !scheme.eq_ignore_ascii_case(b"file") { + return None; + } + let mut rest = &url[scheme.len() + 1..]; + if let Some(after_slashes) = rest.strip_prefix(b"//") { + let host_end = bun_core::strings::index_of_char_usize(after_slashes, b'/') + .unwrap_or(after_slashes.len()); + let host = &after_slashes[..host_end]; + if !host.is_empty() && !host.eq_ignore_ascii_case(b"localhost") { + return None; + } + rest = &after_slashes[host_end..]; + } + let decoded = percent_decode(rest); + // `file:///C:/dir/file.js` names `C:/dir/file.js` on Windows. + if cfg!(windows) + && decoded.len() >= 3 + && decoded[0] == b'/' + && decoded[1].is_ascii_alphabetic() + && decoded[2] == b':' + { + return Some(Box::from(&decoded[1..])); + } + Some(Box::from(&*decoded)) +} + +fn percent_decode(text: &[u8]) -> Cow<'_, [u8]> { + if !bun_core::strings::contains_char(text, b'%') { + return Cow::Borrowed(text); + } + let mut out = Vec::with_capacity(text.len()); + let mut i = 0; + while i < text.len() { + if text[i] == b'%' && i + 2 < text.len() { + if let Some(byte) = bun_core::fmt::hex_pair_value(text[i + 1], text[i + 2]) { + out.push(byte); + i += 3; + continue; + } + } + out.push(text[i]); + i += 1; + } + Cow::Owned(out) +} diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index de4d0c42ba61..b1e7343eb392 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -11,6 +11,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"] @@ -45,6 +47,7 @@ unsafe impl Sync for ParsedSourceMap {} pub use bun_core::Ordinal; pub use chunk::Chunk; +pub use input_source_map::InputSourceMap; pub use internal_source_map::InternalSourceMap; // ── leaf types that compile cleanly today ───────────────────────────────── diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index d4a227c6ec38..55e2dcccca21 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1008,6 +1008,27 @@ describe("Bun.build", () => { expect(x.logs[0].position).toEqual(null); }); + // The location of a pragma comment's argument had the comment's own offset + // added twice, so a warning about a pragma that was not in the file's first + // comment pointed somewhere past it. + test.concurrent("a pragma warning points at the pragma", async () => { + using dir = tempDir("build-api-pragma-location", { + "a.jsx": `// a first comment, so the pragma below is not at offset 0\n// @jsxRuntime bogus\nexport const x =
;\n`, + "b.jsx": `"some code";\n/* a block comment\n @jsxRuntime fake */\nexport const x =
;\n`, + }); + const logs = []; + for (const file of ["a.jsx", "b.jsx"]) { + const build = await Bun.build({ entrypoints: [join(String(dir), file)], external: ["*"] }); + logs.push( + ...build.logs.map(log => [log.message, log.position?.line, log.position?.column, log.position?.length]), + ); + } + expect(logs).toEqual([ + [`Unsupported JSX runtime: "bogus"`, 2, 16, 5], + [`Unsupported JSX runtime: "fake"`, 3, 16, 4], + ]); + }); + test.concurrent("fails instead of truncating when a module is too deeply nested to print", async () => { // A TOML dotted header builds an object nested arbitrarily deep without // recursing in the parser, so the printer's recursion guard is the first diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 1e421e3b7038..488ef701bc6b 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -2,13 +2,53 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; import { readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { decodeSourceMappingsLine, itBundled } from "./expectBundled"; +import { SourceMapConsumer } from "source-map"; +import { type BundlerTestBundleAPI, decodeSourceMappingsLine, encodeSourceMappings, itBundled } from "./expectBundled"; // A public path composes with the referenced file's path relative to the output // directory, never relative to the importing chunk (esbuild's semantics). const CDN_PUBLIC_PATH = "https://cdn.example/app/"; const cdnUrls = (source: string) => [...source.matchAll(/"(https:\/\/cdn\.example\/[^"]+)"/g)].map(match => match[1]); +// `js` + `map()` is what `esbuild lib.ts --sourcemap` emits for `ts`: a +// pre-compiled input whose own source map points back at the TypeScript. +const inputSourceMapFixture = { + ts: `interface P { x: number }\ntype T = P | null;\n\nexport function f(p: P): never {\n const q: T = p;\n throw new Error("MARK");\n}\n`, + js: `export function f(p) {\n const q = p;\n throw new Error("MARK");\n}\n`, + map(overrides: Record = {}) { + return JSON.stringify({ + version: 3, + sources: ["../src/lib.ts"], + sourcesContent: [inputSourceMapFixture.ts], + mappings: "AAGO,gBAAS,EAAE,GAAa;AAC7B,QAAM,IAAO;AACb,QAAM,IAAI,MAAM,MAAM;AACxB;", + names: [], + ...overrides, + }); + }, + dataUrl(encoding: "base64" | "percent", overrides: Record = {}) { + const json = inputSourceMapFixture.map(overrides); + return encoding === "base64" + ? `data:application/json;base64,${Buffer.from(json).toString("base64")}` + : `data:application/json;charset=utf-8,${encodeURIComponent(json)}`; + }, +}; + +/** Where the first occurrence of each token in `file` maps to, as `source:line:column` (1-based line, 0-based column). */ +async function originalPositionsFor(api: BundlerTestBundleAPI, file: string, tokens: string[]) { + const lines = api.readFile(file).split("\n"); + const map = JSON.parse(api.readFile(file + ".map")); + return await SourceMapConsumer.with(map, null, consumer => + Object.fromEntries( + tokens.map(token => { + const line = lines.findIndex(text => text.includes(token)); + if (line === -1) throw new Error(`${JSON.stringify(token)} is not in ${file}`); + const pos = consumer.originalPositionFor({ line: line + 1, column: lines[line].indexOf(token) }); + return [token, pos.source === null ? null : `${pos.source}:${pos.line}:${pos.column}`]; + }), + ), + ); +} + describe("bundler", () => { itBundled("edgecase/EmptyFile", { files: { @@ -1380,6 +1420,256 @@ describe("bundler", () => { expect(line1).toContainEqual({ gen: keepCol, src: 0, ol: 3, oc: 6 }); }, }); + // An input file that carries its own `sourceMappingURL` (the output of a + // previous tsc/esbuild/svelte step) has the bundle's source map composed + // through that map, so it points at the original sources. + itBundled("edgecase/InputSourceMapInline", { + files: { + "/lib/lib.js": inputSourceMapFixture.js + `//# sourceMappingURL=${inputSourceMapFixture.dataUrl("base64")}\n`, + "/entry.ts": `import { f } from "./lib/lib.js";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.ts"], + outdir: "/out", + target: "node", + sourceMap: "external", + async onAfterBundle(api) { + const map = JSON.parse(api.readFile("/out/entry.js.map")); + // `src/lib.ts` is not on disk: its text comes from the input map. + expect(map.sources).toEqual(["../src/lib.ts", "../entry.ts"]); + expect(map.sourcesContent).toEqual([inputSourceMapFixture.ts, api.readFile("/entry.ts")]); + expect( + await originalPositionsFor(api, "/out/entry.js", ["f(p)", "q = p", "new Error", "Error(", `"MARK"`, "f({"]), + ).toEqual({ + "f(p)": "../src/lib.ts:4:16", + "q = p": "../src/lib.ts:5:8", + "new Error": "../src/lib.ts:6:8", + "Error(": "../src/lib.ts:6:12", + '"MARK"': "../src/lib.ts:6:18", + "f({": "../entry.ts:2:0", + }); + }, + }); + itBundled("edgecase/InputSourceMapInlineMinified", { + files: { + "/lib/lib.js": inputSourceMapFixture.js + `//# sourceMappingURL=${inputSourceMapFixture.dataUrl("base64")}\n`, + "/entry.ts": `import { f } from "./lib/lib.js";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.ts"], + outdir: "/out", + sourceMap: "external", + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + async onAfterBundle(api) { + const map = JSON.parse(api.readFile("/out/entry.js.map")); + expect(map.sources).toEqual(["../src/lib.ts", "../entry.ts"]); + expect(await originalPositionsFor(api, "/out/entry.js", ["Error(", `"MARK"`])).toEqual({ + "Error(": "../src/lib.ts:6:12", + '"MARK"': "../src/lib.ts:6:18", + }); + }, + }); + itBundled("edgecase/InputSourceMapCommentFormsAndSidecar", { + files: { + "/src/one.ts": inputSourceMapFixture.ts.replaceAll("MARK", "ONE1"), + "/src/two.ts": inputSourceMapFixture.ts.replaceAll("MARK", "TWO2"), + // Legacy `//@` form, percent-encoded `data:` URL, no `sourcesContent`: + // the content is read from `/src/one.ts`. + "/lib/one.js": + inputSourceMapFixture.js.replaceAll("MARK", "ONE1") + + `//@ sourceMappingURL=${inputSourceMapFixture.dataUrl("percent", { sources: ["../src/one.ts"], sourcesContent: undefined })}\n`, + // Block-comment form pointing at a sidecar `.map` that uses `sourceRoot` + // and a `null` `sourcesContent` entry. + "/lib/two.js": inputSourceMapFixture.js.replaceAll("MARK", "TWO2") + `/*# sourceMappingURL=two.js.map */\n`, + "/lib/two.js.map": inputSourceMapFixture.map({ + sourceRoot: "../src", + sources: ["two.ts"], + sourcesContent: [null], + }), + "/entry.js": `import { f as one } from "./lib/one.js";\nimport { f as two } from "./lib/two.js";\n[one, two][Date.now() % 1]({ x: 1 });\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + sourceMap: "external", + async onAfterBundle(api) { + const map = JSON.parse(api.readFile("/out/entry.js.map")); + expect(map.sources).toEqual(["../src/one.ts", "../src/two.ts", "../entry.js"]); + // Both read from disk, since neither map carried the content. + expect(map.sourcesContent).toEqual([ + api.readFile("/src/one.ts"), + api.readFile("/src/two.ts"), + api.readFile("/entry.js"), + ]); + expect(await originalPositionsFor(api, "/out/entry.js", [`"ONE1"`, `"TWO2"`])).toEqual({ + '"ONE1"': "../src/one.ts:6:18", + '"TWO2"': "../src/two.ts:6:18", + }); + }, + }); + itBundled("edgecase/InputSourceMapNodeModulesSidecar", { + files: { + "/node_modules/pkg/package.json": JSON.stringify({ name: "pkg", main: "dist/index.js" }), + "/node_modules/pkg/dist/index.js": inputSourceMapFixture.js + `//# sourceMappingURL=index.js.map\n`, + "/node_modules/pkg/dist/index.js.map": inputSourceMapFixture.map({ sources: ["../src/index.ts"] }), + "/entry.js": `import { f } from "pkg";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + sourceMap: "linked", + async onAfterBundle(api) { + const map = JSON.parse(api.readFile("/out/entry.js.map")); + expect(map.sources).toEqual(["../node_modules/pkg/src/index.ts", "../entry.js"]); + expect(map.sourcesContent[0]).toBe(inputSourceMapFixture.ts); + expect(await originalPositionsFor(api, "/out/entry.js", [`"MARK"`])).toEqual({ + '"MARK"': "../node_modules/pkg/src/index.ts:6:18", + }); + }, + }); + itBundled("edgecase/InputSourceMapSections", { + files: { + "/a.js": `console.log("a1");\nconsole.log("a2");\n`, + "/b.js": `console.log("b1");\nconsole.log("b2");\n`, + // `ab.js` is `a.js` and `b.js` concatenated, described by an index map + // with one section per file. The second section has no `sourcesContent`. + "/ab.js": + `console.log("a1");\nconsole.log("a2");\nconsole.log("b1");\nconsole.log("b2");\n` + + `//# sourceMappingURL=data:application/json;base64,${Buffer.from( + JSON.stringify({ + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { + version: 3, + sources: ["a.js"], + sourcesContent: [`console.log("a1");\nconsole.log("a2");\n`], + names: [], + mappings: encodeSourceMappings([ + [ + [0, 0, 0, 0], + [8, 0, 0, 8], + [12, 0, 0, 12], + ], + [ + [0, 0, 1, 0], + [8, 0, 1, 8], + [12, 0, 1, 12], + ], + ]), + }, + }, + { + offset: { line: 2, column: 0 }, + map: { + version: 3, + sources: ["b.js"], + names: [], + mappings: encodeSourceMappings([ + [ + [0, 0, 0, 0], + [8, 0, 0, 8], + [12, 0, 0, 12], + ], + [ + [0, 0, 1, 0], + [8, 0, 1, 8], + [12, 0, 1, 12], + ], + ]), + }, + }, + ], + }), + ).toString("base64")}\n`, + "/entry.js": `import "./ab.js";\nconsole.log("entry");\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + sourceMap: "external", + async onAfterBundle(api) { + const map = JSON.parse(api.readFile("/out/entry.js.map")); + expect(map.sources).toEqual(["../a.js", "../b.js", "../entry.js"]); + expect(map.sourcesContent).toEqual([ + `console.log("a1");\nconsole.log("a2");\n`, + api.readFile("/b.js"), + api.readFile("/entry.js"), + ]); + expect( + await originalPositionsFor(api, "/out/entry.js", [`"a1"`, `"a2"`, `log("b1")`, `"b2"`, `"entry"`]), + ).toEqual({ + '"a1"': "../a.js:1:12", + '"a2"': "../a.js:2:12", + 'log("b1")': "../b.js:1:8", + '"b2"': "../b.js:2:12", + '"entry"': "../entry.js:2:12", + }); + }, + }); + itBundled("edgecase/InputSourceMapUncoveredCode", { + files: { + // The input map only covers the first line. Output that comes from the + // other lines gets no mapping rather than a wrong one. + "/lib.js": + inputSourceMapFixture.js + + `//# sourceMappingURL=data:application/json;base64,${Buffer.from( + JSON.stringify({ + version: 3, + sources: ["lib.coffee"], + sourcesContent: ["original();\n"], + names: [], + mappings: encodeSourceMappings([ + [ + [0, 0, 0, 0], + [16, 0, 0, 9], + ], + ]), + }), + ).toString("base64")}\n`, + "/entry.js": `import { f } from "./lib.js";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + sourceMap: "external", + async onAfterBundle(api) { + const map = JSON.parse(api.readFile("/out/entry.js.map")); + expect(map.sources).toEqual(["../lib.coffee", "../entry.js"]); + expect(await originalPositionsFor(api, "/out/entry.js", ["f(p)", "q = p", `"MARK"`])).toEqual({ + "f(p)": "../lib.coffee:1:9", + "q = p": null, + '"MARK"': null, + }); + }, + }); + itBundled("edgecase/InputSourceMapMissingOrInvalid", { + files: { + "/lib/missing.js": inputSourceMapFixture.js.replaceAll("MARK", "MISS") + `//# sourceMappingURL=missing.js.map\n`, + "/lib/invalid.js": + inputSourceMapFixture.js.replaceAll("MARK", "OOPS") + + `//# sourceMappingURL=data:application/json;base64,${Buffer.from("{oops").toString("base64")}\n`, + // Not something the build can read: skipped without a warning. + "/lib/remote.js": + inputSourceMapFixture.js.replaceAll("MARK", "HTTP") + + `//# sourceMappingURL=https://example.com/remote.js.map\n`, + "/entry.js": `import { f as a } from "./lib/missing.js";\nimport { f as b } from "./lib/invalid.js";\nimport { f as c } from "./lib/remote.js";\n[a, b, c][Date.now() % 1]({ x: 1 });\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + sourceMap: "external", + bundleWarnings: { + "/lib/missing.js": ["Cannot find source map file: "], + "/lib/invalid.js": [`Ignoring the source map "data: URL" of this file: invalid JSON`], + }, + async onAfterBundle(api) { + const map = JSON.parse(api.readFile("/out/entry.js.map")); + // Each file falls back to mapping to itself. + expect(map.sources).toEqual(["../lib/missing.js", "../lib/invalid.js", "../lib/remote.js", "../entry.js"]); + expect(await originalPositionsFor(api, "/out/entry.js", [`"MISS"`, `"OOPS"`, `"HTTP"`])).toEqual({ + '"MISS"': "../lib/missing.js:3:18", + '"OOPS"': "../lib/invalid.js:3:18", + '"HTTP"': "../lib/remote.js:3:18", + }); + }, + }); itBundled("edgecase/NoUselessConstructorTS", { files: { "/entry.ts": ` diff --git a/test/bundler/expectBundled.ts b/test/bundler/expectBundled.ts index ad5ccafccb1e..298f42d190b0 100644 --- a/test/bundler/expectBundled.ts +++ b/test/bundler/expectBundled.ts @@ -77,6 +77,41 @@ export function decodeSourceMappingsLine(line: string) { return segs; } +/** + * Encodes a source map "mappings" string. `lines[i]` holds the segments of + * generated line `i` as `[generatedColumn, sourceIndex, originalLine, originalColumn]`, + * every value absolute and zero-based. + */ +export function encodeSourceMappings(lines: [number, number, number, number][][]): string { + const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const vlq = (value: number) => { + let v = value < 0 ? (-value << 1) | 1 : value << 1; + let out = ""; + do { + let digit = v & 31; + v >>>= 5; + if (v > 0) digit |= 32; + out += B64[digit]; + } while (v > 0); + return out; + }; + let src = 0, + ol = 0, + oc = 0; + return lines + .map(segments => { + let gen = 0; + return segments + .map(([g, s, l, c]) => { + const text = vlq(g - gen) + vlq(s - src) + vlq(l - ol) + vlq(c - oc); + [gen, src, ol, oc] = [g, s, l, c]; + return text; + }) + .join(","); + }) + .join(";"); +} + let currentFile: string | undefined; function errorOrWarnParser(isError = true) { From 91a5028c1f442789875467af61b5a0118a3985a5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:37:02 +0000 Subject: [PATCH 02/11] sourcemap: keep InputSourceMap parse errors as bytes --- src/bundler/input_source_map.rs | 19 +++----- src/sourcemap/InputSourceMap.rs | 80 ++++++++++++++++----------------- 2 files changed, 47 insertions(+), 52 deletions(-) diff --git a/src/bundler/input_source_map.rs b/src/bundler/input_source_map.rs index 609757db304b..27fae03acee8 100644 --- a/src/bundler/input_source_map.rs +++ b/src/bundler/input_source_map.rs @@ -46,7 +46,10 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option Option(source_dir, &mut buf[..], &[url]) - { - Some(joined) => Box::from(joined), - None => return None, - } - } else { - // A virtual module (plugin namespace) has no directory to - // resolve a relative URL against. - return None; + >(source_dir, &mut buf[..], &[url])?) }; json = match bun_sys::File::read_from(bun_sys::Fd::cwd(), &map_path) { @@ -118,7 +113,7 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option); +/// Why an input source map was rejected (UTF-8 text). The input file is still +/// bundled; its output map then points at the input file itself. +pub struct ParseError(pub Cow<'static, [u8]>); + +impl ParseError { + const fn msg(text: &'static str) -> ParseError { + ParseError(Cow::Borrowed(text.as_bytes())) + } +} impl InputSourceMap { /// Parses source-map `json` (ECMA-426, including `sections` index maps). @@ -52,19 +58,18 @@ impl InputSourceMap { let parsed = match bun_parsers::json::ParsedJson::parse_json(&json_source, &mut log) { Ok(parsed) => parsed, Err(_) => { - let reason = log - .msgs - .iter() - .find(|msg| msg.kind == bun_ast::Kind::Err) - .map(|msg| String::from_utf8_lossy(&msg.data.text).into_owned()); - return Err(ParseError(match reason { - Some(text) => Cow::Owned(format!("invalid JSON: {text}")), - None => Cow::Borrowed("invalid JSON"), - })); + return Err( + match log.msgs.iter().find(|msg| msg.kind == bun_ast::Kind::Err) { + Some(msg) => { + ParseError(Cow::Owned([b"invalid JSON: ", &*msg.data.text].concat())) + } + None => ParseError::msg("invalid JSON"), + }, + ); } }; let ExprData::EObjectJSON(root) = parsed.root.data else { - return Err(ParseError(Cow::Borrowed("expected a JSON object"))); + return Err(ParseError::msg("expected a JSON object")); }; let root: &ObjectJSON = root.get(); @@ -89,16 +94,16 @@ impl InputSourceMap { continue; }; let Some(map) = map.as_object() else { - return Err(ParseError(Cow::Borrowed( + return Err(ParseError::msg( "expected \"map\" in \"sections\" to be an object", - ))); + )); }; let (mut line_offset, mut column_offset) = (0, 0); if let Some(offset) = section.get(b"offset") { let Some(offset) = offset.as_object() else { - return Err(ParseError(Cow::Borrowed( + return Err(ParseError::msg( "expected \"offset\" in \"sections\" to be an object", - ))); + )); }; if let Some(JsonValue::Number(line)) = offset.get(b"line") { line_offset = line.value() as i32; @@ -108,9 +113,7 @@ impl InputSourceMap { } } if line_offset < 0 || column_offset < 0 { - return Err(ParseError(Cow::Borrowed( - "negative \"offset\" in \"sections\"", - ))); + return Err(ParseError::msg("negative \"offset\" in \"sections\"")); } sections.push(Section { line_offset, @@ -120,9 +123,7 @@ impl InputSourceMap { } } Some(_) => { - return Err(ParseError(Cow::Borrowed( - "expected \"sections\" to be an array", - ))); + return Err(ParseError::msg("expected \"sections\" to be an array")); } } @@ -142,18 +143,14 @@ impl InputSourceMap { let vlq: &[u8] = match section.map.get(b"mappings") { Some(JsonValue::String(s)) => s.slice(), Some(_) => { - return Err(ParseError(Cow::Borrowed( - "expected \"mappings\" to be a string", - ))); + return Err(ParseError::msg("expected \"mappings\" to be a string")); } None => continue, }; let section_sources: &[JsonValue] = match section.map.get(b"sources") { Some(JsonValue::Array(items)) => items.get().items(), Some(_) => { - return Err(ParseError(Cow::Borrowed( - "expected \"sources\" to be an array", - ))); + return Err(ParseError::msg("expected \"sources\" to be an array")); } None => continue, }; @@ -170,10 +167,10 @@ impl InputSourceMap { }; let Ok(section_sources_len) = i32::try_from(section_sources.len()) else { - return Err(ParseError(Cow::Borrowed("too many \"sources\""))); + return Err(ParseError::msg("too many \"sources\"")); }; let Ok(source_offset) = i32::try_from(sources.len()) else { - return Err(ParseError(Cow::Borrowed("too many \"sources\""))); + return Err(ParseError::msg("too many \"sources\"")); }; let parsed = match mapping::parse( vlq, @@ -187,11 +184,14 @@ impl InputSourceMap { ) { Ok(parsed) => parsed, Err(fail) => { - return Err(ParseError(Cow::Owned(format!( - "bad \"mappings\" data at character {}: {}", - fail.loc.start.max(0), - fail.err.message() - )))); + return Err(ParseError(Cow::Owned( + format!( + "bad \"mappings\" data at character {}: {}", + fail.loc.start.max(0), + fail.err.message() + ) + .into_bytes(), + ))); } }; @@ -233,7 +233,7 @@ impl InputSourceMap { source_index: source_index[i] + source_offset, name_index: -1, }) - .map_err(|_| ParseError(Cow::Borrowed("out of memory")))?; + .map_err(|_| ParseError::msg("out of memory"))?; } } @@ -261,7 +261,7 @@ impl InputSourceMap { if i > 0 { quoted .append(b",\n ") - .map_err(|_| ParseError(Cow::Borrowed("out of memory")))?; + .map_err(|_| ParseError::msg("out of memory"))?; } let from_disk; let content: Option<&[u8]> = match content { @@ -274,10 +274,10 @@ impl InputSourceMap { }; match content { Some(content) => bun_core::quote_for_json(content, &mut quoted, false) - .map_err(|_| ParseError(Cow::Borrowed("out of memory")))?, + .map_err(|_| ParseError::msg("out of memory"))?, None => quoted .append(b"null") - .map_err(|_| ParseError(Cow::Borrowed("out of memory")))?, + .map_err(|_| ParseError::msg("out of memory"))?, } } From 8219b55eb1ec4c31906d38634899213570961962 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:41:55 +0000 Subject: [PATCH 03/11] trim comments --- src/ast/ast_result.rs | 3 +-- src/bundler/Graph.rs | 4 +-- src/bundler/LinkerContext.rs | 6 ++--- src/bundler/ParseTask.rs | 3 +-- src/bundler/input_source_map.rs | 13 +++------ src/js_parser/lexer.rs | 3 +-- src/js_printer/lib.rs | 9 ++----- src/sourcemap/Chunk.rs | 18 +++++-------- src/sourcemap/InputSourceMap.rs | 47 +++++++++------------------------ 9 files changed, 30 insertions(+), 76 deletions(-) diff --git a/src/ast/ast_result.rs b/src/ast/ast_result.rs index ac81d46a1966..eb6093644185 100644 --- a/src/ast/ast_result.rs +++ b/src/ast/ast_result.rs @@ -54,8 +54,7 @@ pub struct Ast<'a> { // them under the same lifetime-erased contract as `StoreRef`. pub hashbang: StoreStr, pub directive: Option, - /// The last `//# sourceMappingURL=` (or `//@`, `/*# */`) comment in the - /// file: the URL text and where it is. + /// URL text and location of the file's last `sourceMappingURL` comment. pub source_mapping_url: Option, /// `export default X` where `X` is an import binding in this file. When /// `X` resolves to a module namespace the linker binds importers of diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index cec1fb4c00ea..1b57ca732fe6 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -112,9 +112,7 @@ pub struct InputFile { pub unique_key_for_additional_file: Box<[u8], AstAlloc>, pub content_hash_for_additional_file: u64, pub flags: InputFileFlags, - /// The source map the file names in its `sourceMappingURL` comment, when - /// source maps are on and it could be loaded. Global-heap; drained in - /// `BundleV2::deinit_without_freeing_arena`. + /// From the file's `sourceMappingURL` comment. Global heap: drained in `deinit_without_freeing_arena`. pub input_source_map: Option>, } diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 66e6b69f5461..7888bc9dbadd 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1233,8 +1233,7 @@ impl<'a> LinkerContext<'a> { // --> // Which source index in the generated sourcemap, referred to // as the "mapping source index" within this function to be distinct. - // A file with an input source map takes one slot per source of that - // map, starting here; its chunks carry slot offsets relative to it. + // A file with an input source map takes one slot per source of that map. let mut source_id_map: ArrayHashMap = ArrayHashMap::new(); let source_indices = results.items_source_index(); @@ -1334,8 +1333,7 @@ impl<'a> LinkerContext<'a> { )?; prev_end_state = chunk.end_state; - // A chunk's own source indices count from 0 (and stay 0 unless the - // file had an input source map with several sources). + // Chunk-local source indices count from 0: rebase onto the file's first slot. prev_end_state.source_index += mapping_source_index; prev_column_offset = chunk.final_generated_column; diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 9cf654855381..7687ee8477cd 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2694,8 +2694,7 @@ 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; - // The dev server joins per-file maps itself (`SourceMapStore`) and - // assumes one `sources` entry per file, so it gets no input maps. + // The dev server's `SourceMapStore` assumes one `sources` entry per file: no input maps there. let use_input_source_maps = topts.source_map != options::SourceMapOption::None && !topts.has_dev_server(); // `topts` (a `&BundleOptions`) is dead past this point; the callees take diff --git a/src/bundler/input_source_map.rs b/src/bundler/input_source_map.rs index 27fae03acee8..8e8ca3d0d9af 100644 --- a/src/bundler/input_source_map.rs +++ b/src/bundler/input_source_map.rs @@ -1,13 +1,10 @@ -//! Loads the source map an input file names in its `sourceMappingURL` -//! comment, so the output map can point through it at the original sources. +//! Loads the source map an input file's `sourceMappingURL` comment names. use bun_ast::{Log, Source, Span}; use bun_sourcemap::InputSourceMap; use bun_sourcemap::input_source_map::{path_from_file_url, url_scheme}; -/// `comment` is the URL of the file's last `sourceMappingURL` comment. A map -/// that cannot be loaded is reported as a warning on `log` and skipped: the -/// file then maps to itself, as if it had no comment. +/// A map that cannot be loaded is a warning on `log`; the file then maps to itself. pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option> { let url: &[u8] = comment.text.slice(); if url.is_empty() { @@ -43,12 +40,10 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option = if let Some(path) = path_from_file_url(url) { path } else if url_scheme(url).is_some() || url.starts_with(b"//") { - // `https://…` and the like: nothing a build can read, and not - // something the author of this build can fix, so stay quiet. + // `https://…` and the like: unreadable here and not the user's to fix, so no warning. return None; } else { - // A virtual module (plugin namespace) has no directory to - // resolve a relative URL against. + // A virtual module (plugin namespace) has no directory to resolve against. let source_dir = source_dir?; let url = strip_query_and_fragment(url); let decoded; diff --git a/src/js_parser/lexer.rs b/src/js_parser/lexer.rs index 8950c4f8ffd8..0690509453e5 100644 --- a/src/js_parser/lexer.rs +++ b/src/js_parser/lexer.rs @@ -2144,8 +2144,7 @@ impl<'a> Lexer<'a> { } /// Scans the string for a pragma. - /// `chunk_start` is the offset of `chunk` in the file; the spans recorded - /// here carry it so a later diagnostic about the pragma points at it. + /// `chunk_start` is the file offset of `chunk`, for the spans recorded here. /// Returns the byte length to advance by if found, otherwise 0. fn scan_pragma( &mut self, diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 0b1814a226a3..1eac19fe91a2 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1379,9 +1379,7 @@ pub struct Options<'a> { /// builder as `LineOffsetTables::Borrowed`. pub line_offset_tables: Option<&'a SourceMap::line_offset_table::List>, - /// The source map the input file itself carried (its `sourceMappingURL` - /// comment). The builder maps every position through it, see - /// `SourceMap::chunk::Builder::input_source_map`. + /// See `SourceMap::chunk::Builder::input_source_map`. pub input_source_map: Option<&'a SourceMap::InputSourceMap>, pub mangled_props: Option<&'a crate::MangledProps>, @@ -7650,10 +7648,7 @@ pub(crate) fn get_source_map_builder<'a, const IS_BUN_PLATFORM: bool>( // opts.source_map_allocator orelse opts.allocator — allocator dropped IS_BUN_PLATFORM && generate_source_map == GenerateSourceMap::Lazy, ), - // Repeating the previous mapping at the start of an unmapped line only - // helps when that mapping is ours. Through an input source map it would - // point wherever that map last pointed, so leave such lines unmapped - // (esbuild does the same). + // Through an input source map the repeated mapping would point anywhere (esbuild does the same). cover_lines_without_mappings: opts.input_source_map.is_none(), approximate_input_line_count: tree.approximate_newline_count, prepend_count: IS_BUN_PLATFORM && generate_source_map == GenerateSourceMap::Lazy, diff --git a/src/sourcemap/Chunk.rs b/src/sourcemap/Chunk.rs index c69d9c017e9b..68f16160cc3d 100644 --- a/src/sourcemap/Chunk.rs +++ b/src/sourcemap/Chunk.rs @@ -369,15 +369,10 @@ pub struct NewBuilder<'a, T: SourceMapFormatCtx> { /// `line_offset_table_byte_offset_list`. pub line_offset_table_first_non_ascii: RawSlice, - /// The input file's own source map (from its `sourceMappingURL` comment). - /// When set, every mapping is translated through it and a position it does - /// not cover gets no mapping, so `source_index` and `original_*` in the - /// chunk refer to `input_source_map.sources`, not to the input file. + /// When set, mappings are translated through it (uncovered positions get none) and index its `sources`. pub input_source_map: Option<&'a crate::InputSourceMap>, - /// Line in the input file of the previous `add_source_mapping` call, the - /// seed for `find_line_with_hint`. (`prev_state.original_line` is a line in - /// some other file once an input source map has rewritten it.) + /// Input-file line of the previous mapping (`prev_state.original_line` may be post-remap). pub prev_input_line: u32, // This is a workaround for a bug in the popular "source-map" library: @@ -620,8 +615,7 @@ impl NewBuilder<'_, VLQSourceMap> { self.last_generated_update = output.len() as u32; } - /// Returns `false` when the input source map does not cover the position, - /// in which case nothing was appended. + /// `false`: the input source map does not cover the position; nothing was appended. #[inline(always)] pub(crate) fn append_mapping(&mut self, mut current_state: SourceMapState) -> bool { if let Some(input_source_map) = self.input_source_map { @@ -695,9 +689,9 @@ 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 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 `original_line` is the right answer or one/two lines before + // it >95% of the time. Seed `find_line_with_hint` with it; the + // fallback is the same binary search as before. let original_line = LineOffsetTable::find_line_with_hint(byte_offsets, loc, self.prev_input_line); let idx = original_line.max(0) as usize; diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index 54f82922adb9..68df13c06ed4 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -1,7 +1,4 @@ -//! A source map that an input file points at through its `sourceMappingURL` -//! comment: the map a previous tool (tsc, esbuild, a Svelte compiler) emitted -//! for that file. The printer translates every output position through it, so -//! the emitted map names the files this map names instead of the input file. +//! The source map an input file's `sourceMappingURL` names; output maps are composed through it. use std::borrow::Cow; @@ -14,23 +11,19 @@ use crate::{LineColumnOffset, Ordinal}; pub struct InputSourceMap { mappings: mapping::List, - /// The map's `sources`, with `sourceRoot` applied and relative entries - /// resolved against the map's directory. + /// `sources` with `sourceRoot` applied and relative entries resolved against the map's directory. pub sources: Box<[InputSource]>, - /// The `sourcesContent` entry for each of `sources`, as JSON (`"..."` or - /// `null`), joined with `",\n "`: ready to splice into an output map. + /// `sourcesContent` per source as JSON (`"..."` or `null`), joined with `",\n "`. pub quoted_sources_content: Box<[u8]>, } pub struct InputSource { pub path: Box<[u8]>, - /// `path` is an absolute file-system path. Otherwise it is a URL or an - /// opaque name and is emitted as written. + /// `path` is an absolute file-system path, as opposed to a URL or opaque name kept as written. pub is_file: bool, } -/// Why an input source map was rejected (UTF-8 text). The input file is still -/// bundled; its output map then points at the input file itself. +/// Why an input source map was rejected, as UTF-8 text. pub struct ParseError(pub Cow<'static, [u8]>); impl ParseError { @@ -40,14 +33,7 @@ impl ParseError { } impl InputSourceMap { - /// Parses source-map `json` (ECMA-426, including `sections` index maps). - /// - /// `map_dir` is the directory relative `sources` entries resolve against: - /// the `.map` file's directory, or the input file's directory for a `data:` - /// URL. `None` leaves them as written. `read_file` supplies the text of a - /// file source whose `sourcesContent` entry is missing or `null`. - /// - /// `Ok(None)`: the map is well-formed but maps nothing. + /// `map_dir`: base for relative `sources`; `read_file`: fills a missing `sourcesContent`; `Ok(None)`: maps nothing. pub fn parse( json: &[u8], map_dir: Option<&[u8]>, @@ -136,8 +122,7 @@ impl InputSourceMap { for section in §ions { match section.map.get(b"version") { Some(JsonValue::Number(n)) if n.value() == 3.0 => {} - // Silently skip a section with a missing or unknown version, - // like esbuild does. + // A missing or unknown version skips the section silently, as in esbuild. _ => continue, } let vlq: &[u8] = match section.map.get(b"mappings") { @@ -288,8 +273,7 @@ impl InputSourceMap { })) } - /// The mapping that covers `line`:`column` (zero-based, columns in UTF-16 - /// code units) of the file this map was emitted for, if any. + /// The mapping covering zero-based `line`:`column` (UTF-16 columns) of the generated file. #[inline] pub fn find(&self, line: i32, column: i32) -> Option { self.mappings.find( @@ -299,10 +283,7 @@ impl InputSourceMap { } } -/// Applies `sourceRoot` to one `sources` entry and resolves the result the way -/// ECMA-426 "Resolving Sources" does: a URL with a scheme other than `file:` -/// stays as written, a `file:` URL becomes a path, and anything else is a path -/// relative to `map_dir`. +/// ECMA-426 "Resolving Sources": non-`file:` URLs stay as written, the rest become paths under `map_dir`. fn resolve_source(name: &[u8], source_root: &[u8], map_dir: Option<&[u8]>) -> InputSource { let name: Cow<'_, [u8]> = if source_root.is_empty() || url_scheme(name).is_some() { Cow::Borrowed(name) @@ -345,17 +326,14 @@ fn resolve_source(name: &[u8], source_root: &[u8], map_dir: Option<&[u8]>) -> In } } -/// `path` resolved against the absolute directory `base` and normalized (an -/// absolute `path` is only normalized). `None` when the result does not fit a -/// path buffer. +/// `path` resolved against `base` and normalized; `None` if it does not fit a path buffer. fn join_path(base: &[u8], path: &[u8]) -> Option> { use bun_paths::resolve_path::{join_abs_string_buf_checked, platform}; let mut buf = bun_paths::path_buffer_pool::get(); join_abs_string_buf_checked::(base, &mut buf[..], &[path]).map(Box::from) } -/// The scheme of `url` (`file` for `file:///a.js`), if it has one. A single -/// ASCII letter before `:\` or `:/` is a Windows drive letter, not a scheme. +/// The scheme of `url`, if any. One ASCII letter before `:/` or `:\` is a drive letter, not a scheme. pub fn url_scheme(url: &[u8]) -> Option<&[u8]> { let (&first, rest) = url.split_first()?; if !first.is_ascii_alphabetic() { @@ -377,8 +355,7 @@ pub fn url_scheme(url: &[u8]) -> Option<&[u8]> { None } -/// The file-system path a `file:` URL names, percent-decoded. `None` when -/// `url` is not a `file:` URL or names a remote host. +/// The percent-decoded path of a `file:` URL; `None` for other URLs or a remote host. pub fn path_from_file_url(url: &[u8]) -> Option> { let scheme = url_scheme(url)?; if !scheme.eq_ignore_ascii_case(b"file") { From 3a8efb0cbd97595e881c662d7ed40c0e8ca171be Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:26:55 +0000 Subject: [PATCH 04/11] silent on a missing sidecar map like esbuild; --compile accepts null sourcesContent A published package that names a .map it does not ship is common, so a missing sidecar no longer warns (esbuild logs it at debug level too); unreadable files, bad data: URLs and malformed maps still warn. The standalone module graph rejected a null sourcesContent entry with InvalidSourceMap, which made bun build --compile --sourcemap fail for an input map whose original source is not on disk. It now stores such an entry as empty, which the loader already treats as no contents. --- src/bundler/input_source_map.rs | 40 +++++++++---------- src/standalone_graph/StandaloneModuleGraph.rs | 3 +- test/bundler/bundler_edgecase.test.ts | 24 ++++++++++- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/bundler/input_source_map.rs b/src/bundler/input_source_map.rs index 8e8ca3d0d9af..0b59cfab1079 100644 --- a/src/bundler/input_source_map.rs +++ b/src/bundler/input_source_map.rs @@ -4,6 +4,8 @@ use bun_ast::{Log, Source, Span}; use bun_sourcemap::InputSourceMap; use bun_sourcemap::input_source_map::{path_from_file_url, url_scheme}; +bun_core::declare_scope!(InputSourceMap, hidden); + /// A map that cannot be loaded is a warning on `log`; the file then maps to itself. pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option> { let url: &[u8] = comment.text.slice(); @@ -66,27 +68,25 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option bytes, + // Published packages routinely name a `.map` they do not ship; like esbuild, say nothing. + Err(err) if err.get_errno() == bun_sys::E::ENOENT => { + bun_core::scoped_log!( + InputSourceMap, + "missing source map {}", + bstr::BStr::new(&map_path) + ); + return None; + } Err(err) => { - if err.get_errno() == bun_sys::E::ENOENT { - log.add_range_warning_fmt( - Some(source), - comment.range, - format_args!( - "Cannot find source map file: {}", - bstr::BStr::new(&map_path) - ), - ); - } else { - log.add_range_warning_fmt( - Some(source), - comment.range, - format_args!( - "Cannot read source map file \"{}\": {}", - bstr::BStr::new(&map_path), - bstr::BStr::new(err.name()) - ), - ); - } + log.add_range_warning_fmt( + Some(source), + comment.range, + format_args!( + "Cannot read source map file \"{}\": {}", + bstr::BStr::new(&map_path), + bstr::BStr::new(err.name()) + ), + ); return None; } }; diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index c8eca72a59b8..892cd8aaf706 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -3510,7 +3510,8 @@ pub(crate) fn serialize_json_source_map_for_standalone( } for item in sources_content.items() { - let utf8 = item.as_str().ok_or(crate::Error::InvalidSourceMap)?; + // A `null` entry (content unavailable) is stored as empty, which the loader treats as "no contents". + let utf8 = item.as_str().unwrap_or(b""); let offset = string_payload.len(); diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 488ef701bc6b..c73d07b91c54 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -1642,11 +1642,12 @@ describe("bundler", () => { }); itBundled("edgecase/InputSourceMapMissingOrInvalid", { files: { + // A sidecar that does not exist (common in published packages) and a + // remote URL are skipped silently; a map that is there but unusable warns. "/lib/missing.js": inputSourceMapFixture.js.replaceAll("MARK", "MISS") + `//# sourceMappingURL=missing.js.map\n`, "/lib/invalid.js": inputSourceMapFixture.js.replaceAll("MARK", "OOPS") + `//# sourceMappingURL=data:application/json;base64,${Buffer.from("{oops").toString("base64")}\n`, - // Not something the build can read: skipped without a warning. "/lib/remote.js": inputSourceMapFixture.js.replaceAll("MARK", "HTTP") + `//# sourceMappingURL=https://example.com/remote.js.map\n`, @@ -1656,7 +1657,6 @@ describe("bundler", () => { outdir: "/out", sourceMap: "external", bundleWarnings: { - "/lib/missing.js": ["Cannot find source map file: "], "/lib/invalid.js": [`Ignoring the source map "data: URL" of this file: invalid JSON`], }, async onAfterBundle(api) { @@ -1670,6 +1670,26 @@ describe("bundler", () => { }); }, }); + itBundled("edgecase/InputSourceMapCompile", { + files: { + // The original is not on disk and the sidecar has no `sourcesContent`, + // so the composed map carries a `null` entry the executable must accept. + "/lib/lib.js": inputSourceMapFixture.js + `//# sourceMappingURL=lib.js.map\n`, + "/lib/lib.js.map": inputSourceMapFixture.map({ sourcesContent: undefined }), + "/entry.ts": `import { f } from "./lib/lib.js";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.ts"], + target: "bun", + compile: true, + sourceMap: "external", + run: { + exitCode: 1, + validate({ stderr }) { + expect(stderr).toInclude("error: MARK"); + expect(stderr).toMatch(/src[\\/]lib\.ts:6:9/); + }, + }, + }); itBundled("edgecase/NoUselessConstructorTS", { files: { "/entry.ts": ` From 93a964028962d3ee35e8ed4d59065f5dee8275d6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:46:53 +0000 Subject: [PATCH 05/11] lexer: a sourceMappingURL value ends at whitespace, not at the first non-ASCII byte Adds tests for a non-ASCII sidecar name, an onLoad result with an inline map, a build without --sourcemap next to a broken sidecar, and the two-step bundle and compile flow. --- src/js_parser/lexer.rs | 29 ++++--- .../bun-build-compile-sourcemap.test.ts | 79 +++++++++++++++++++ test/bundler/bundler_edgecase.test.ts | 63 +++++++++++++++ 3 files changed, 159 insertions(+), 12 deletions(-) diff --git a/src/js_parser/lexer.rs b/src/js_parser/lexer.rs index 0690509453e5..6d678a23f5fa 100644 --- a/src/js_parser/lexer.rs +++ b/src/js_parser/lexer.rs @@ -3467,19 +3467,24 @@ impl PragmaArg { const PREFIX: u32 = " sourceMappingURL=".len() as u32; let url_and_rest_of_code = &chunk[PREFIX as usize..]; // Slice containing only the potential argument - let url_len: usize = 'brk: { - if let Some(delimiter_pos_in_arg) = - strings::index_of_space_or_newline_or_non_ascii(url_and_rest_of_code, 0) - { - // SIMD found the delimiter at index 'delimiter_pos_in_arg' relative to url start. - // The argument's length is exactly this index. - break 'brk delimiter_pos_in_arg as usize; - } else { - // SIMD found no delimiter in the entire url. - // The argument is the whole chunk. - break 'brk url_and_rest_of_code.len(); + // The URL ends at whitespace. A non-ASCII code point (`ünï.js.map`) is part of it unless it is whitespace. + let mut url_len: usize = url_and_rest_of_code.len(); + let mut from: u32 = 0; + while let Some(pos) = + strings::index_of_space_or_newline_or_non_ascii(url_and_rest_of_code, from) + { + let pos = pos as usize; + if url_and_rest_of_code[pos] < 0x80 { + url_len = pos; + break; } - }; + let (c, width) = bun_core::lexer::char_and_size(url_and_rest_of_code, pos); + if is_whitespace(c) || Self::is_newline(c) { + url_len = pos; + break; + } + from = (pos + width) as u32; + } // Now we have the correct argument length (url_len) and the argument text. let url = &url_and_rest_of_code[0..url_len]; diff --git a/test/bundler/bun-build-compile-sourcemap.test.ts b/test/bundler/bun-build-compile-sourcemap.test.ts index f324234257ad..3a46d442acdb 100644 --- a/test/bundler/bun-build-compile-sourcemap.test.ts +++ b/test/bundler/bun-build-compile-sourcemap.test.ts @@ -305,4 +305,83 @@ main();`, // Verify it failed (the error was thrown) expect(exitCode).not.toBe(0); }); + + // The input of the second step is the output of a first `bun build` with + // its own linked map. The trace must go through that map to the original. + const twoStepFiles = { + "src/x.ts": `type T = { n: number } +const pad: T = { n: 1 } + +export function boom(): never { + // the throw is on line 6 of the source + throw new Error('boom ' + pad.n) +} +boom() +`, + }; + + async function buildStep1(dir: string) { + const step1 = await Bun.build({ + entrypoints: [join(dir, "src/x.ts")], + outdir: join(dir, "step1"), + target: "bun", + sourcemap: "linked", + }); + expect(step1.success).toBe(true); + expect(await Bun.file(join(dir, "step1/x.js.map")).exists()).toBe(true); + return join(dir, "step1/x.js"); + } + + test.concurrent("bundling the output of a previous build composes its source map", async () => { + using dir = tempDir("build-two-step-sourcemap", twoStepFiles); + const input = await buildStep1(String(dir)); + + const step2 = await Bun.build({ + entrypoints: [input], + outdir: join(String(dir), "step2"), + target: "bun", + sourcemap: "linked", + }); + expect(step2.success).toBe(true); + const map = JSON.parse(await Bun.file(join(String(dir), "step2/x.js.map")).text()); + expect(map.sources).toEqual(["../src/x.ts"]); + expect(map.sourcesContent).toEqual([twoStepFiles["src/x.ts"]]); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "step2/x.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]); + expect(stderr).toContain("error: boom 1"); + expect(stderr).toMatch(/at boom \(.*src[\\/]x\.ts:6:9\)/); + expect(exitCode).toBe(1); + }); + + test.concurrent("compiling the output of a previous build composes its source map", async () => { + using dir = tempDir("build-compile-two-step-sourcemap", twoStepFiles); + const input = await buildStep1(String(dir)); + + const app = await Bun.build({ + entrypoints: [input], + compile: { outfile: join(String(dir), "app") }, + sourcemap: true, + }); + expect(app.success).toBe(true); + + await using proc = Bun.spawn({ + cmd: [app.outputs.find(o => o.kind === "entry-point")!.path], + 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).toContain("error: boom 1"); + expect(stderr).toMatch(/at boom \(.*src[\\/]x\.ts:6:9\)/); + expect(stderr).not.toContain("step1"); + expect(exitCode).toBe(1); + }); }); diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index c73d07b91c54..149b0401f485 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -1690,6 +1690,69 @@ describe("bundler", () => { }, }, }); + itBundled("edgecase/InputSourceMapNonASCIIName", { + files: { + "/lib/ünï.js": inputSourceMapFixture.js + `//# sourceMappingURL=ünï.js.map\n`, + "/lib/ünï.js.map": inputSourceMapFixture.map(), + "/entry.js": `import { f } from "./lib/ünï.js";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + sourceMap: "external", + async onAfterBundle(api) { + const map = JSON.parse(api.readFile("/out/entry.js.map")); + expect(map.sources).toEqual(["../src/lib.ts", "../entry.js"]); + expect(await originalPositionsFor(api, "/out/entry.js", [`"MARK"`])).toEqual({ + '"MARK"': "../src/lib.ts:6:18", + }); + }, + }); + itBundled("edgecase/InputSourceMapPluginNamespace", { + files: { + "/entry.ts": `import { f } from "virtual:lib";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.ts"], + outdir: "/out", + sourceMap: "external", + plugins(builder) { + builder.onResolve({ filter: /^virtual:/ }, args => ({ + path: args.path.slice("virtual:".length), + namespace: "virtual", + })); + builder.onLoad({ filter: /.*/, namespace: "virtual" }, () => ({ + // The common `;charset=utf-8;base64,` form. A virtual module has no + // directory, so the map's `sources` are kept as written. + contents: + inputSourceMapFixture.js + + `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from( + inputSourceMapFixture.map({ sources: ["src/lib.ts"] }), + ).toString("base64")}\n`, + loader: "js", + })); + }, + async onAfterBundle(api) { + const map = JSON.parse(api.readFile("/out/entry.js.map")); + expect(map.sources).toEqual(["src/lib.ts", "../entry.ts"]); + expect(map.sourcesContent[0]).toBe(inputSourceMapFixture.ts); + expect(await originalPositionsFor(api, "/out/entry.js", [`"MARK"`, "f({"])).toEqual({ + '"MARK"': "src/lib.ts:6:18", + "f({": "../entry.ts:2:0", + }); + }, + }); + itBundled("edgecase/InputSourceMapNotReadWithoutSourceMap", { + files: { + // Without `--sourcemap` the sidecar is never read: a broken one is not a warning. + "/lib/lib.js": inputSourceMapFixture.js + `//# sourceMappingURL=lib.js.map\n`, + "/lib/lib.js.map": "{oops", + "/entry.js": `import { f } from "./lib/lib.js";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + onAfterBundle(api) { + expect(api.readFile("/out/entry.js")).not.toInclude("sourceMappingURL"); + }, + }); itBundled("edgecase/NoUselessConstructorTS", { files: { "/entry.ts": ` From e4e1c37b12d557b5a4e99f1cd08619e598d935ad Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:52:33 +0000 Subject: [PATCH 06/11] sourcemap: one URL policy for input maps, verbatim URL sources at runtime, no bundle preview under an original file name The sidecar comment and the map's sources entries now share one policy: a file: URL through bun_url (query and fragment dropped, absolute paths only), any other scheme kept as written, a relative path percent-decoded with the lenient WHATWG decoder. InputSourceMap keeps sourcesContent per source. The runtime lookup no longer joins a URL source (webpack://...) onto the bundle directory, and prints no code preview when the frame names an original file whose text it does not have. ENOTDIR on a sidecar is silent like ENOENT. Docs for the bundler and for --compile mention the composition. --- Cargo.lock | 1 + docs/bundler/executables.mdx | 2 +- docs/bundler/index.mdx | 2 + src/bundler/LinkerContext.rs | 29 +++++-- src/bundler/input_source_map.rs | 19 +---- src/js_parser/lexer.rs | 2 +- src/jsc/VirtualMachine.rs | 5 +- src/sourcemap/Cargo.toml | 1 + src/sourcemap/InputSourceMap.rs | 92 +++++++--------------- src/sourcemap/Mapping.rs | 10 ++- test/bundler/bun-build-api.test.ts | 37 +++++++++ test/bundler/bundler_edgecase.test.ts | 107 ++++++++++++++++++++++++-- 12 files changed, 208 insertions(+), 99 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16fc2e164945..7b2d0ef76de5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1716,6 +1716,7 @@ dependencies = [ "bun_ptr", "bun_semver", "bun_sys", + "bun_url", "bun_zstd", "smallvec", "thiserror", diff --git a/docs/bundler/executables.mdx b/docs/bundler/executables.mdx index a05760ff47be..be0c112a437d 100644 --- a/docs/bundler/executables.mdx +++ b/docs/bundler/executables.mdx @@ -306,7 +306,7 @@ Bytecode compilation moves parsing overhead for large input files from runtime t The `--minify` argument reduces the size of the transpiled output code. For a large application, this can save megabytes of space. For smaller applications, it might still improve start time a little. -The `--sourcemap` argument embeds a sourcemap compressed with zstd, so that errors & stacktraces point to their original locations instead of the transpiled location. Bun decompresses & resolves the sourcemap automatically when an error occurs. +The `--sourcemap` argument embeds a sourcemap compressed with zstd, so that errors & stacktraces point to their original locations instead of the transpiled location. Bun decompresses & resolves the sourcemap automatically when an error occurs. If an input file carries its own `//# sourceMappingURL=` comment (for example the output of an earlier build step), that map is composed into the embedded one, so the stack trace names the original source file. The `--bytecode` argument enables bytecode compilation. Every time you run JavaScript code in Bun, JavaScriptCore (the engine) compiles your source code into bytecode. `--bytecode` moves that parsing work from runtime to bundle time, which shortens startup. diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index dcbed53d79c0..31e8c857bdf0 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -766,6 +766,8 @@ Specifies the type of sourcemap to generate. The associated `*.js.map` sourcemap is a JSON file containing an equivalent `debugId` property. +When an input file carries its own `//# sourceMappingURL=` comment (the output of an earlier build step, or a package that ships a `.map` file next to its code), Bun composes that map into the output sourcemap, like esbuild does. The `sources` of the output map then point at the original files instead of the intermediate file, and stack traces from `bun run` and from a `--compile` executable name the original files. The comment can be a `data:` URL, a `file:` URL, or a path relative to the input file. A map file that does not exist is ignored. A map that cannot be read or parsed produces a warning, and the input file then maps to itself. + ### minify Whether to enable minification. Default `false`. diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 7888bc9dbadd..474f2b8708ff 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -1287,14 +1287,27 @@ impl<'a> LinkerContext<'a> { j.push_static(b"],\n \"sourcesContent\": ["); - for (i, &index) in source_id_map.keys().iter().enumerate() { - j.push_static(if i == 0 { b"\n " } else { b",\n " }); - j.push_static(match input_source_maps[index as usize].as_deref() { - None => quoted_source_map_contents[index as usize] - .as_deref() - .unwrap_or(b""), - Some(input_source_map) => &input_source_map.quoted_sources_content, - }); + let mut first_content = true; + macro_rules! push_content { + ($quoted:expr) => {{ + j.push_static(if first_content { b"\n " } else { b",\n " }); + first_content = false; + j.push_static($quoted); + }}; + } + for &index in source_id_map.keys() { + match input_source_maps[index as usize].as_deref() { + None => push_content!( + quoted_source_map_contents[index as usize] + .as_deref() + .unwrap_or(b"") + ), + Some(input_source_map) => { + for quoted in input_source_map.quoted_sources_content.iter() { + push_content!(quoted); + } + } + } } j.push_static(b"\n ],\n \"mappings\": \""); diff --git a/src/bundler/input_source_map.rs b/src/bundler/input_source_map.rs index 0b59cfab1079..13fe27ca97c7 100644 --- a/src/bundler/input_source_map.rs +++ b/src/bundler/input_source_map.rs @@ -39,6 +39,7 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option { + let url = strip_query_and_fragment(url); let map_path: Box<[u8]> = if let Some(path) = path_from_file_url(url) { path } else if url_scheme(url).is_some() || url.starts_with(b"//") { @@ -47,29 +48,17 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option { - decoded = d; - &decoded - } - Err(_) => url, - } - } else { - url - }; + let decoded = bun_url::PercentEncoding::decode_lenient_alloc(url); let mut buf = bun_paths::path_buffer_pool::get(); Box::from(bun_paths::resolve_path::join_abs_string_buf_checked::< bun_paths::resolve_path::platform::Auto, - >(source_dir, &mut buf[..], &[url])?) + >(source_dir, &mut buf[..], &[&decoded])?) }; json = match bun_sys::File::read_from(bun_sys::Fd::cwd(), &map_path) { Ok(bytes) => bytes, // Published packages routinely name a `.map` they do not ship; like esbuild, say nothing. - Err(err) if err.get_errno() == bun_sys::E::ENOENT => { + Err(err) if matches!(err.get_errno(), bun_sys::E::ENOENT | bun_sys::E::ENOTDIR) => { bun_core::scoped_log!( InputSourceMap, "missing source map {}", diff --git a/src/js_parser/lexer.rs b/src/js_parser/lexer.rs index 6d678a23f5fa..05fe948ba4f5 100644 --- a/src/js_parser/lexer.rs +++ b/src/js_parser/lexer.rs @@ -3479,7 +3479,7 @@ impl PragmaArg { break; } let (c, width) = bun_core::lexer::char_and_size(url_and_rest_of_code, pos); - if is_whitespace(c) || Self::is_newline(c) { + if strings::is_js_whitespace(c as u32) { url_len = pos; break; } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 007d95fdecb9..44794eb00ad4 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6633,6 +6633,7 @@ impl VirtualMachine { }; if let Some((mapping, display_url, external_code)) = resolved { + let names_original_file = display_url.is_some(); if let Some(src) = display_url { frames[top].source_url = src; } @@ -6664,7 +6665,9 @@ impl VirtualMachine { original_source.source_code.into_utf8() }; - if enable_source_code_preview.get() && code.slice().is_empty() { + // The executed text is not the original file's: a preview of it under the + // original file's name and position would mislead, so there is none. + if enable_source_code_preview.get() && code.slice().is_empty() && !names_original_file { exception.collect_source_lines(error_instance, global); } diff --git a/src/sourcemap/Cargo.toml b/src/sourcemap/Cargo.toml index 31ebf092e73f..1fff9c4a4800 100644 --- a/src/sourcemap/Cargo.toml +++ b/src/sourcemap/Cargo.toml @@ -28,6 +28,7 @@ bun_io.workspace = true # is now un-gated without the cycle. bun_ast.workspace = true bun_paths.workspace = true +bun_url.workspace = true bun_ptr.workspace = true bun_semver.workspace = true bun_sys.workspace = true diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index 68df13c06ed4..cc771e3f7890 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -13,8 +13,8 @@ pub struct InputSourceMap { mappings: mapping::List, /// `sources` with `sourceRoot` applied and relative entries resolved against the map's directory. pub sources: Box<[InputSource]>, - /// `sourcesContent` per source as JSON (`"..."` or `null`), joined with `",\n "`. - pub quoted_sources_content: Box<[u8]>, + /// `sourcesContent` per source as JSON: `"..."` or `null`. + pub quoted_sources_content: Box<[Box<[u8]>]>, } pub struct InputSource { @@ -241,13 +241,8 @@ impl InputSourceMap { mappings.sort(); } - let mut quoted = MutableString::init_empty(); - for (i, (source, content)) in sources.iter().zip(&sources_content).enumerate() { - if i > 0 { - quoted - .append(b",\n ") - .map_err(|_| ParseError::msg("out of memory"))?; - } + let mut quoted_sources_content: Vec> = Vec::with_capacity(sources.len()); + for (source, content) in sources.iter().zip(&sources_content) { let from_disk; let content: Option<&[u8]> = match content { Some(content) => Some(content), @@ -257,19 +252,22 @@ impl InputSourceMap { } None => None, }; - match content { - Some(content) => bun_core::quote_for_json(content, &mut quoted, false) - .map_err(|_| ParseError::msg("out of memory"))?, - None => quoted - .append(b"null") - .map_err(|_| ParseError::msg("out of memory"))?, - } + quoted_sources_content.push(match content { + Some(content) => { + let mut quoted = MutableString::init(content.len() + 2) + .map_err(|_| ParseError::msg("out of memory"))?; + bun_core::quote_for_json(content, &mut quoted, false) + .map_err(|_| ParseError::msg("out of memory"))?; + quoted.list.into_boxed_slice() + } + None => Box::from(&b"null"[..]), + }); } Ok(Some(InputSourceMap { mappings, sources: sources.into_boxed_slice(), - quoted_sources_content: quoted.list.into_boxed_slice(), + quoted_sources_content: quoted_sources_content.into_boxed_slice(), })) } @@ -304,10 +302,7 @@ fn resolve_source(name: &[u8], source_root: &[u8], map_dir: Option<&[u8]>) -> In if let Some(path) = path_from_file_url(&name) { return InputSource { - path: match map_dir { - Some(map_dir) => join_path(map_dir, &path).unwrap_or(path), - None => path, - }, + path, is_file: true, }; } @@ -317,7 +312,10 @@ fn resolve_source(name: &[u8], source_root: &[u8], map_dir: Option<&[u8]>) -> In let Some(map_dir) = map_dir else { return verbatim(&name); }; - match join_path(map_dir, &percent_decode(&name)) { + match join_path( + map_dir, + &bun_url::PercentEncoding::decode_lenient_alloc(&name), + ) { Some(path) => InputSource { path, is_file: true, @@ -355,51 +353,17 @@ pub fn url_scheme(url: &[u8]) -> Option<&[u8]> { None } -/// The percent-decoded path of a `file:` URL; `None` for other URLs or a remote host. +/// The absolute file-system path a `file:` URL names (query and fragment dropped); `None` for any other URL. pub fn path_from_file_url(url: &[u8]) -> Option> { let scheme = url_scheme(url)?; - if !scheme.eq_ignore_ascii_case(b"file") { + // `file:rel.map` (no `/` after the scheme) is not a file URL, as in esbuild. + if !scheme.eq_ignore_ascii_case(b"file") || url.get(scheme.len() + 1) != Some(&b'/') { return None; } - let mut rest = &url[scheme.len() + 1..]; - if let Some(after_slashes) = rest.strip_prefix(b"//") { - let host_end = bun_core::strings::index_of_char_usize(after_slashes, b'/') - .unwrap_or(after_slashes.len()); - let host = &after_slashes[..host_end]; - if !host.is_empty() && !host.eq_ignore_ascii_case(b"localhost") { - return None; - } - rest = &after_slashes[host_end..]; - } - let decoded = percent_decode(rest); - // `file:///C:/dir/file.js` names `C:/dir/file.js` on Windows. - if cfg!(windows) - && decoded.len() >= 3 - && decoded[0] == b'/' - && decoded[1].is_ascii_alphabetic() - && decoded[2] == b':' - { - return Some(Box::from(&decoded[1..])); - } - Some(Box::from(&*decoded)) -} - -fn percent_decode(text: &[u8]) -> Cow<'_, [u8]> { - if !bun_core::strings::contains_char(text, b'%') { - return Cow::Borrowed(text); - } - let mut out = Vec::with_capacity(text.len()); - let mut i = 0; - while i < text.len() { - if text[i] == b'%' && i + 2 < text.len() { - if let Some(byte) = bun_core::fmt::hex_pair_value(text[i + 1], text[i + 2]) { - out.push(byte); - i += 3; - continue; - } - } - out.push(text[i]); - i += 1; + let path = bun_url::path_from_file_url(&bun_core::String::borrow_utf8(url)); + if path.is_dead() { + return None; } - Cow::Owned(out) + let path = path.to_owned_slice(); + bun_paths::is_absolute(&path).then(|| path.into_boxed_slice()) } diff --git a/src/sourcemap/Mapping.rs b/src/sourcemap/Mapping.rs index e2ba5b4e344a..19a8abc00991 100644 --- a/src/sourcemap/Mapping.rs +++ b/src/sourcemap/Mapping.rs @@ -324,7 +324,7 @@ impl Lookup { let name: &[u8] = &source_map.external_source_names[source_idx]; - if source_map.is_standalone_module_graph { + if source_map.is_standalone_module_graph || is_url(name) { return Some(bun_core::String::clone_utf8(name)); } @@ -390,6 +390,9 @@ impl Lookup { } let name: &[u8] = &source_map.external_source_names[index]; + if is_url(name) { + return None; + } let mut buf = bun_paths::path_buffer_pool::get(); // `platform::Auto` is @@ -408,6 +411,11 @@ impl Lookup { } } +/// A `sources` entry such as `webpack://pkg/src/a.ts` names no file next to the bundle. +fn is_url(name: &[u8]) -> bool { + crate::input_source_map::url_scheme(name).is_some() || name.starts_with(b"//") +} + impl Mapping { #[inline] pub fn original_line(&self) -> i32 { diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 55e2dcccca21..52fba2b652b5 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -14,6 +14,7 @@ import { } from "harness"; import path, { join } from "path"; import { SourceMapConsumer } from "source-map"; +import { pathToFileURL } from "url"; import { buildNoThrow } from "./buildNoThrow"; describe("Bun.build", () => { @@ -1029,6 +1030,42 @@ describe("Bun.build", () => { ]); }); + // A `file:` URL in a `sourceMappingURL` comment names the sidecar by absolute + // path. Its query is dropped, like the query of a relative sidecar path. + test.concurrent("an input source map named by a file: URL is composed", async () => { + const map = JSON.stringify({ + version: 3, + sources: ["../src/lib.ts"], + sourcesContent: ["export function f(): never {\n throw new Error('MARK');\n}\n"], + names: [], + mappings: "AAAO,gBAAS,IAAU;AACxB,QAAM,IAAI,MAAM,MAAM;AACxB;", + }); + using dir = tempDir("build-api-file-url-input-map", { + "lib/lib.js.map": map, + "lib/localhost.js.map": map, + "entry.js": `import { f as a } from "./lib/lib.js";\nimport { f as b } from "./lib/localhost.js";\n[a, b][Date.now() % 1]();\n`, + }); + const root = String(dir); + const fileUrl = pathToFileURL(join(root, "lib/lib.js.map")).href; + const localhostUrl = fileUrl.replace("file://", "file://localhost").replace("lib.js.map", "localhost.js.map"); + writeFileSync( + join(root, "lib/lib.js"), + `export function f() {\n throw new Error("MARK");\n}\n//# sourceMappingURL=${fileUrl}?v=1\n`, + ); + writeFileSync( + join(root, "lib/localhost.js"), + `export function f() {\n throw new Error("MARK");\n}\n//# sourceMappingURL=${localhostUrl}\n`, + ); + const build = await Bun.build({ + entrypoints: [join(root, "entry.js")], + outdir: join(root, "out"), + sourcemap: "external", + }); + expect(build.logs).toEqual([]); + const output = JSON.parse(await Bun.file(join(root, "out/entry.js.map")).text()); + expect(output.sources).toEqual(["../src/lib.ts", "../src/lib.ts", "../entry.js"]); + }); + test.concurrent("fails instead of truncating when a module is too deeply nested to print", async () => { // A TOML dotted header builds an object nested arbitrarily deep without // recursing in the parser, so the printer's recursion guard is the first diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 149b0401f485..af1198e63f65 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -1530,7 +1530,9 @@ describe("bundler", () => { "/a.js": `console.log("a1");\nconsole.log("a2");\n`, "/b.js": `console.log("b1");\nconsole.log("b2");\n`, // `ab.js` is `a.js` and `b.js` concatenated, described by an index map - // with one section per file. The second section has no `sourcesContent`. + // with one section per file. The sections are listed out of offset + // order, so the first line of `ab.js` maps to source 1 and the parsed + // mappings need a sort. The `b.js` section has no `sourcesContent`. "/ab.js": `console.log("a1");\nconsole.log("a2");\nconsole.log("b1");\nconsole.log("b2");\n` + `//# sourceMappingURL=data:application/json;base64,${Buffer.from( @@ -1538,11 +1540,10 @@ describe("bundler", () => { version: 3, sections: [ { - offset: { line: 0, column: 0 }, + offset: { line: 2, column: 0 }, map: { version: 3, - sources: ["a.js"], - sourcesContent: [`console.log("a1");\nconsole.log("a2");\n`], + sources: ["b.js"], names: [], mappings: encodeSourceMappings([ [ @@ -1559,10 +1560,11 @@ describe("bundler", () => { }, }, { - offset: { line: 2, column: 0 }, + offset: { line: 0, column: 0 }, map: { version: 3, - sources: ["b.js"], + sources: ["a.js"], + sourcesContent: [`console.log("a1");\nconsole.log("a2");\n`], names: [], mappings: encodeSourceMappings([ [ @@ -1588,10 +1590,10 @@ describe("bundler", () => { sourceMap: "external", async onAfterBundle(api) { const map = JSON.parse(api.readFile("/out/entry.js.map")); - expect(map.sources).toEqual(["../a.js", "../b.js", "../entry.js"]); + expect(map.sources).toEqual(["../b.js", "../a.js", "../entry.js"]); expect(map.sourcesContent).toEqual([ - `console.log("a1");\nconsole.log("a2");\n`, api.readFile("/b.js"), + `console.log("a1");\nconsole.log("a2");\n`, api.readFile("/entry.js"), ]); expect( @@ -1753,6 +1755,95 @@ describe("bundler", () => { expect(api.readFile("/out/entry.js")).not.toInclude("sourceMappingURL"); }, }); + itBundled("edgecase/InputSourceMapQueryAndPercentEncoding", { + files: { + // `?`/`#` are dropped from the URL. `file:rel` is not a file URL and is + // ignored. A percent-encoded sidecar name decodes. + "/lib/rel.js": inputSourceMapFixture.js.replaceAll("MARK", "REL1") + `//# sourceMappingURL=rel.js.map#hash\n`, + "/lib/rel.js.map": inputSourceMapFixture.map({ sources: ["../src/rel.ts"] }), + "/lib/bad.js": inputSourceMapFixture.js.replaceAll("MARK", "BAD1") + `//# sourceMappingURL=file:bad.js.map\n`, + "/lib/bad.js.map": inputSourceMapFixture.map({ sources: ["../src/bad.ts"] }), + "/lib/with space.js": + inputSourceMapFixture.js.replaceAll("MARK", "SPC1") + `//# sourceMappingURL=with%20space.js.map\n`, + "/lib/with space.js.map": inputSourceMapFixture.map({ sources: ["../src/with%20space.ts"] }), + "/entry.js": `import { f as b } from "./lib/rel.js";\nimport { f as c } from "./lib/bad.js";\nimport { f as d } from "./lib/with space.js";\n[b, c, d][Date.now() % 1]({ x: 1 });\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + sourceMap: "external", + async onAfterBundle(api) { + const map = JSON.parse(api.readFile("/out/entry.js.map")); + expect(map.sources).toEqual(["../src/rel.ts", "../lib/bad.js", "../src/with space.ts", "../entry.js"]); + expect(await originalPositionsFor(api, "/out/entry.js", [`"REL1"`, `"BAD1"`, `"SPC1"`])).toEqual({ + '"REL1"': "../src/rel.ts:6:18", + '"BAD1"': "../lib/bad.js:3:18", + '"SPC1"': "../src/with space.ts:6:18", + }); + }, + }); + itBundled("edgecase/InputSourceMapNotADirectory", { + files: { + // `notdir` is a file, so the sidecar path cannot exist: silent, like a missing one. + "/lib/lib.js": inputSourceMapFixture.js + `//# sourceMappingURL=notdir/lib.js.map\n`, + "/lib/notdir": "", + "/entry.js": `import { f } from "./lib/lib.js";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + sourceMap: "external", + onAfterBundle(api) { + expect(JSON.parse(api.readFile("/out/entry.js.map")).sources).toEqual(["../lib/lib.js", "../entry.js"]); + }, + }); + itBundled("edgecase/InputSourceMapUrlSourcesAtRuntime", { + files: { + // A `sources` entry that is a URL (webpack, rollup) is kept as written. + // The runtime must print it as written too, not joined onto the bundle's directory. + "/node_modules/pkg/package.json": JSON.stringify({ name: "pkg", main: "dist/index.js" }), + "/node_modules/pkg/dist/index.js": inputSourceMapFixture.js + `//# sourceMappingURL=index.js.map\n`, + "/node_modules/pkg/dist/index.js.map": inputSourceMapFixture.map({ sources: ["webpack://pkg/./src/index.ts"] }), + "/entry.js": `import { f } from "pkg";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + target: "bun", + sourceMap: "linked", + run: { + exitCode: 1, + validate({ stderr }) { + expect(stderr).toInclude("error: MARK"); + expect(stderr).toInclude("webpack://pkg/./src/index.ts:6:9"); + expect(stderr).not.toInclude("out/webpack:"); + }, + }, + onAfterBundle(api) { + expect(JSON.parse(api.readFile("/out/entry.js.map")).sources).toEqual([ + "webpack://pkg/./src/index.ts", + "../entry.js", + ]); + }, + }); + itBundled("edgecase/InputSourceMapNoOriginalOnDiskAtRuntime", { + files: { + // The map has no `sourcesContent` and the original is not on disk, so + // the runtime has no text to preview. It must not show the bundle's + // lines under the original file's name and position. + "/lib/lib.js": inputSourceMapFixture.js + `//# sourceMappingURL=lib.js.map\n`, + "/lib/lib.js.map": inputSourceMapFixture.map({ sourcesContent: undefined }), + "/entry.js": `import { f } from "./lib/lib.js";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + target: "bun", + sourceMap: "linked", + run: { + exitCode: 1, + validate({ stderr }) { + expect(stderr).toMatch(/src[\\/]lib\.ts:6:9/); + expect(stderr).not.toInclude("| "); + }, + }, + }); itBundled("edgecase/NoUselessConstructorTS", { files: { "/entry.ts": ` From feee886f108cb4704794601d65214e5b66faae57 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:12:22 +0000 Subject: [PATCH 07/11] shorten a comment --- src/jsc/VirtualMachine.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 44794eb00ad4..bc53a9ff4be0 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6665,8 +6665,7 @@ impl VirtualMachine { original_source.source_code.into_utf8() }; - // The executed text is not the original file's: a preview of it under the - // original file's name and position would mislead, so there is none. + // No bundle lines under an original file's name and position. if enable_source_code_preview.get() && code.slice().is_empty() && !names_original_file { exception.collect_source_lines(error_instance, global); } From de78233c9b6f77cde3255c41109b2aa0cf59b624 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:32:15 +0000 Subject: [PATCH 08/11] ci: retrigger From beb91e39a726574b8fabc83343b0435f56e919bf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:53:22 +0000 Subject: [PATCH 09/11] sourcemap: read only regular files for input maps, watch the sidecar, sort merged sections A sidecar map or a missing sourcesContent entry is read only when the path names a regular file, and only up to the size fstat reports, so a device or a pipe named by file content cannot hang the build or grow memory without bound. bun build --watch registers the sidecar with the watcher, so a map written after its .js triggers a rebuild. Sections are always sorted after a merge. An allocation failure while a map is parsed goes through the OOM handler instead of a build warning. --compile rejects a sourcesContent entry that is neither a string nor null. --- src/bundler/bundle_v2.rs | 13 +++++ src/bundler/input_source_map.rs | 57 ++++++++++++++++--- src/sourcemap/InputSourceMap.rs | 26 ++++----- src/standalone_graph/StandaloneModuleGraph.rs | 6 +- test/bundler/bundler_edgecase.test.ts | 16 ++++++ test/cli/watch/watch.test.ts | 49 ++++++++++++++++ 6 files changed, 144 insertions(+), 23 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index fd4eb2233f66..9b34d6ce677d 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -7758,6 +7758,19 @@ pub mod bv2_impl { // Record which loader we used for this file this.graph.input_files.items_loader_mut()[result_source_index] = result.loader; + // A sidecar map changes with its file, often after it. Watch it too. + if let Some(map_path) = result + .input_source_map + .as_ref() + .and_then(|map| map.map_path.as_deref()) + && this.bun_watcher.is_some() + && this.should_add_watcher(map_path) + { + let _ = this + .bun_watcher_mut() + .unwrap() + .add_file_by_path_slow(map_path); + } this.graph.input_files.items_input_source_map_mut()[result_source_index] = result.input_source_map.take(); diff --git a/src/bundler/input_source_map.rs b/src/bundler/input_source_map.rs index 13fe27ca97c7..7beed3f4e860 100644 --- a/src/bundler/input_source_map.rs +++ b/src/bundler/input_source_map.rs @@ -20,6 +20,7 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option; let map_dir: Option>; let map_name: Box<[u8]>; + let mut map_path: Option> = None; match bun_resolver::DataURL::parse(url) { Ok(Some(data_url)) => { match data_url.decode_data() { @@ -40,7 +41,7 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option { let url = strip_query_and_fragment(url); - let map_path: Box<[u8]> = if let Some(path) = path_from_file_url(url) { + let sidecar_path: Box<[u8]> = if let Some(path) = path_from_file_url(url) { path } else if url_scheme(url).is_some() || url.starts_with(b"//") { // `https://…` and the like: unreadable here and not the user's to fix, so no warning. @@ -55,14 +56,25 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option(source_dir, &mut buf[..], &[&decoded])?) }; - json = match bun_sys::File::read_from(bun_sys::Fd::cwd(), &map_path) { - Ok(bytes) => bytes, + json = match read_regular_file(&sidecar_path) { + Ok(Some(bytes)) => bytes, + Ok(None) => { + log.add_range_warning_fmt( + Some(source), + comment.range, + format_args!( + "Cannot read source map file \"{}\": not a regular file", + bstr::BStr::new(&sidecar_path) + ), + ); + return None; + } // Published packages routinely name a `.map` they do not ship; like esbuild, say nothing. Err(err) if matches!(err.get_errno(), bun_sys::E::ENOENT | bun_sys::E::ENOTDIR) => { bun_core::scoped_log!( InputSourceMap, "missing source map {}", - bstr::BStr::new(&map_path) + bstr::BStr::new(&sidecar_path) ); return None; } @@ -72,7 +84,7 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option Option(&map_path))); - map_name = map_path; + >(&sidecar_path))); + map_name = sidecar_path.clone(); + map_path = Some(sidecar_path); } } - let mut read_file = |path: &[u8]| bun_sys::File::read_from(bun_sys::Fd::cwd(), path).ok(); + let mut read_file = |path: &[u8]| read_regular_file(path).ok().flatten(); match InputSourceMap::parse(&json, map_dir.as_deref(), &mut read_file) { - Ok(Some(map)) => Some(Box::new(map)), + Ok(Some(mut map)) => { + map.map_path = map_path; + Some(Box::new(map)) + } Ok(None) => None, Err(err) => { log.add_range_warning_fmt( @@ -105,6 +121,29 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option bun_sys::Maybe>> { + let file = bun_sys::File::openat( + bun_sys::Fd::cwd(), + path, + bun_sys::O::RDONLY | bun_sys::O::NONBLOCK, + 0, + )?; + if file.kind()? != bun_core::util::FileKind::File { + return Ok(None); + } + let size = file.get_end_pos()?; + let mut bytes: Vec = Vec::new(); + if bytes.try_reserve_exact(size).is_err() { + return Err(bun_sys::Error::oom()); + } + bytes.resize(size, 0); + let read = file.read_all(&mut bytes)?; + bytes.truncate(read); + Ok(Some(bytes)) +} + fn strip_query_and_fragment(url: &[u8]) -> &[u8] { match bun_core::strings::index_of_any(url, b"?#") { Some(i) => &url[..i], diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index cc771e3f7890..be94237b613b 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use bun_ast::e::{JsonValue, ObjectJSON}; use bun_ast::expr::Data as ExprData; -use bun_core::MutableString; +use bun_core::{MutableString, UnwrapOrOom}; use crate::mapping::{self, Mapping}; use crate::{LineColumnOffset, Ordinal}; @@ -15,6 +15,8 @@ pub struct InputSourceMap { pub sources: Box<[InputSource]>, /// `sourcesContent` per source as JSON: `"..."` or `null`. pub quoted_sources_content: Box<[Box<[u8]>]>, + /// The sidecar file the map was read from, for the watcher. `None` for a `data:` URL. + pub map_path: Option>, } pub struct InputSource { @@ -117,7 +119,6 @@ impl InputSourceMap { let mut sources: Vec = Vec::new(); let mut sources_content: Vec> = Vec::new(); let mut needs_sort = false; - let mut prev_offset = (0i32, 0i32); for section in §ions { match section.map.get(b"version") { @@ -168,6 +169,9 @@ impl InputSourceMap { }, ) { Ok(parsed) => parsed, + Err(fail) if matches!(fail.err, crate::Error::Alloc(_)) => { + bun_core::out_of_memory() + } Err(fail) => { return Err(ParseError(Cow::Owned( format!( @@ -180,15 +184,12 @@ impl InputSourceMap { } }; - if (section.line_offset, section.column_offset) < prev_offset { - needs_sort = true; - } - prev_offset = (section.line_offset, section.column_offset); - - if sections.len() == 1 && prev_offset == (0, 0) { + if sections.len() == 1 && (section.line_offset, section.column_offset) == (0, 0) { let mut parsed = parsed; mappings = core::mem::take(&mut parsed.mappings); } else { + // Sections can overlap or come out of offset order. `find` needs one sorted list. + needs_sort = true; let section_mappings = &parsed.mappings; let generated = section_mappings.generated(); let original = section_mappings.original(); @@ -218,7 +219,7 @@ impl InputSourceMap { source_index: source_index[i] + source_offset, name_index: -1, }) - .map_err(|_| ParseError::msg("out of memory"))?; + .unwrap_or_oom(); } } @@ -254,10 +255,8 @@ impl InputSourceMap { }; quoted_sources_content.push(match content { Some(content) => { - let mut quoted = MutableString::init(content.len() + 2) - .map_err(|_| ParseError::msg("out of memory"))?; - bun_core::quote_for_json(content, &mut quoted, false) - .map_err(|_| ParseError::msg("out of memory"))?; + let mut quoted = MutableString::init(content.len() + 2).unwrap_or_oom(); + bun_core::quote_for_json(content, &mut quoted, false).unwrap_or_oom(); quoted.list.into_boxed_slice() } None => Box::from(&b"null"[..]), @@ -268,6 +267,7 @@ impl InputSourceMap { mappings, sources: sources.into_boxed_slice(), quoted_sources_content: quoted_sources_content.into_boxed_slice(), + map_path: None, })) } diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index 892cd8aaf706..e1ab328d0c88 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -3511,7 +3511,11 @@ pub(crate) fn serialize_json_source_map_for_standalone( for item in sources_content.items() { // A `null` entry (content unavailable) is stored as empty, which the loader treats as "no contents". - let utf8 = item.as_str().unwrap_or(b""); + let utf8: &[u8] = match item { + bun_ast::e::JsonValue::String(s) => s.slice(), + bun_ast::e::JsonValue::Null => b"", + _ => return Err(crate::Error::InvalidSourceMap), + }; let offset = string_payload.len(); diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index af1198e63f65..88e0fed47dc2 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -1795,6 +1795,22 @@ describe("bundler", () => { expect(JSON.parse(api.readFile("/out/entry.js.map")).sources).toEqual(["../lib/lib.js", "../entry.js"]); }, }); + itBundled("edgecase/InputSourceMapNotARegularFile", { + files: { + // The comment names a directory. Only a regular file is read. + "/lib/lib.js": inputSourceMapFixture.js + `//# sourceMappingURL=.\n`, + "/entry.js": `import { f } from "./lib/lib.js";\nf({ x: 1 });\n`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + sourceMap: "external", + bundleWarnings: { + "/lib/lib.js": ["Cannot read source map file"], + }, + onAfterBundle(api) { + expect(JSON.parse(api.readFile("/out/entry.js.map")).sources).toEqual(["../lib/lib.js", "../entry.js"]); + }, + }); itBundled("edgecase/InputSourceMapUrlSourcesAtRuntime", { files: { // A `sources` entry that is a URL (webpack, rollup) is kept as written. diff --git a/test/cli/watch/watch.test.ts b/test/cli/watch/watch.test.ts index 8a871093c192..594de8c12b4f 100644 --- a/test/cli/watch/watch.test.ts +++ b/test/cli/watch/watch.test.ts @@ -650,3 +650,52 @@ it.skipIf(!isLinux)("watcher opens the watched directory and files with O_CLOEXE "/main.ts": [O_CLOEXEC], }); }); + +// `bun build --watch` composes an input file's sidecar source map. A tool +// that writes `x.js` and then `x.js.map` changes the map after the rebuild +// the `.js` write triggered, so the map is watched too. +it("bun build --watch rebuilds when an input's sidecar source map changes", async () => { + const map = (source: string) => + JSON.stringify({ + version: 3, + sources: [source], + sourcesContent: ["export function f(): never {\n throw new Error('MARK');\n}\n"], + names: [], + mappings: "AAAO,gBAAS,IAAU;AACxB,QAAM,IAAI,MAAM,MAAM;AACxB;", + }); + using dir = tempDir("build-watch-input-sourcemap", { + "lib/lib.js": `export function f() {\n throw new Error("MARK");\n}\n//# sourceMappingURL=lib.js.map\n`, + "lib/lib.js.map": map("../src/v1.ts"), + "entry.js": `import { f } from "./lib/lib.js";\nf();\n`, + }); + const outMap = join(String(dir), "out/entry.js.map"); + const sourcesOf = async () => { + try { + return JSON.parse(await Bun.file(outMap).text()).sources as string[]; + } catch { + return null; + } + }; + const waitForSources = async (expected: string[]) => { + const deadline = Date.now() + 4_000; + while (Date.now() < deadline) { + const sources = await sourcesOf(); + if (sources && Bun.deepEquals(sources, expected)) return; + await Bun.sleep(50); + } + throw new Error(`expected sources ${JSON.stringify(expected)}, got ${JSON.stringify(await sourcesOf())}`); + }; + + await using bundler = spawn({ + cmd: [bunExe(), "build", "--watch", "entry.js", "--outdir", "out", "--sourcemap=external"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "ignore", + stdin: "ignore", + }); + await waitForSources(["../src/v1.ts", "../entry.js"]); + await Bun.write(join(String(dir), "lib/lib.js.map"), map("../src/v2.ts")); + await waitForSources(["../src/v2.ts", "../entry.js"]); + bundler.kill(); +}); From 661041327d312992d682a2757a15b289d9fb70ab Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:54:23 +0000 Subject: [PATCH 10/11] shorten a comment --- src/bundler/input_source_map.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bundler/input_source_map.rs b/src/bundler/input_source_map.rs index 7beed3f4e860..b4bbfd470af6 100644 --- a/src/bundler/input_source_map.rs +++ b/src/bundler/input_source_map.rs @@ -121,8 +121,7 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option bun_sys::Maybe>> { let file = bun_sys::File::openat( bun_sys::Fd::cwd(), From a70f05f06fe4a0216163d88d530efd1d52755099 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:39:58 +0000 Subject: [PATCH 11/11] bundler: watch the sidecar map whether or not it loaded, OOM handler for the read buffer The sidecar path travels on the parse result, separate from the map, so a map that is missing or malformed on the first build still triggers a rebuild when it is written. --- src/bundler/ParseTask.rs | 7 +++-- src/bundler/ServerComponentParseTask.rs | 1 + src/bundler/bundle_v2.rs | 9 ++---- src/bundler/input_source_map.rs | 38 +++++++++++++++++-------- src/sourcemap/InputSourceMap.rs | 3 -- test/cli/watch/watch.test.ts | 5 +--- 6 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index abf62cecc829..c685c3618171 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -212,6 +212,8 @@ pub(crate) struct Success { /// See `InputFile::input_source_map`, where this moves on completion. pub(crate) input_source_map: Option>, + /// The sidecar map file the source names, loaded or not, for `--watch`. + pub(crate) input_source_map_sidecar: Option>, } pub(crate) struct ResultError { @@ -2793,7 +2795,7 @@ pub mod parse_worker { Some(comment) if use_input_source_maps => { crate::input_source_map::load(log, source, comment) } - _ => None, + _ => crate::input_source_map::Loaded::default(), }; Ok(Success { @@ -2809,7 +2811,8 @@ pub mod parse_worker { // Hash the files in here so that we do it in parallel. content_hash_for_additional_file: unique_key_for_additional_file.content_hash, - input_source_map, + input_source_map: input_source_map.map, + input_source_map_sidecar: input_source_map.sidecar, }) } diff --git a/src/bundler/ServerComponentParseTask.rs b/src/bundler/ServerComponentParseTask.rs index 6de066c4567b..371b975d58f8 100644 --- a/src/bundler/ServerComponentParseTask.rs +++ b/src/bundler/ServerComponentParseTask.rs @@ -199,6 +199,7 @@ fn task_callback( content_hash_for_additional_file: 0, package_name: bun_ast::StoreStr::EMPTY, input_source_map: None, + input_source_map_sidecar: None, }) } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 69f40ab4690e..fa90053a485d 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -7876,17 +7876,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; // A sidecar map changes with its file, often after it. Watch it too. - if let Some(map_path) = result - .input_source_map - .as_ref() - .and_then(|map| map.map_path.as_deref()) + if let Some(sidecar) = result.input_source_map_sidecar.take() && this.bun_watcher.is_some() - && this.should_add_watcher(map_path) + && this.should_add_watcher(&sidecar) { let _ = this .bun_watcher_mut() .unwrap() - .add_file_by_path_slow(map_path); + .add_file_by_path_slow(&sidecar); } this.graph.input_files.items_input_source_map_mut()[result_source_index] = result.input_source_map.take(); diff --git a/src/bundler/input_source_map.rs b/src/bundler/input_source_map.rs index b4bbfd470af6..f6f443cdb0b9 100644 --- a/src/bundler/input_source_map.rs +++ b/src/bundler/input_source_map.rs @@ -1,17 +1,37 @@ //! Loads the source map an input file's `sourceMappingURL` comment names. use bun_ast::{Log, Source, Span}; +use bun_core::UnwrapOrOom; use bun_sourcemap::InputSourceMap; use bun_sourcemap::input_source_map::{path_from_file_url, url_scheme}; bun_core::declare_scope!(InputSourceMap, hidden); +/// What a `sourceMappingURL` comment loads. `sidecar` is the file the comment names, for the watcher. +#[derive(Default)] +pub(crate) struct Loaded { + pub(crate) map: Option>, + pub(crate) sidecar: Option>, +} + /// A map that cannot be loaded is a warning on `log`; the file then maps to itself. -pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option> { +pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Loaded { let url: &[u8] = comment.text.slice(); if url.is_empty() { - return None; + return Loaded::default(); } + let mut loaded = Loaded::default(); + loaded.map = load_map(log, source, comment, url, &mut loaded.sidecar); + loaded +} + +fn load_map( + log: &mut Log, + source: &Source, + comment: Span, + url: &[u8], + sidecar: &mut Option>, +) -> Option> { let source_dir: Option<&[u8]> = source .path .is_file() @@ -20,7 +40,6 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option; let map_dir: Option>; let map_name: Box<[u8]>; - let mut map_path: Option> = None; match bun_resolver::DataURL::parse(url) { Ok(Some(data_url)) => { match data_url.decode_data() { @@ -55,6 +74,7 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option(source_dir, &mut buf[..], &[&decoded])?) }; + *sidecar = Some(sidecar_path.clone()); json = match read_regular_file(&sidecar_path) { Ok(Some(bytes)) => bytes, @@ -94,17 +114,13 @@ pub(crate) fn load(log: &mut Log, source: &Source, comment: Span) -> Option(&sidecar_path))); - map_name = sidecar_path.clone(); - map_path = Some(sidecar_path); + map_name = sidecar_path; } } let mut read_file = |path: &[u8]| read_regular_file(path).ok().flatten(); match InputSourceMap::parse(&json, map_dir.as_deref(), &mut read_file) { - Ok(Some(mut map)) => { - map.map_path = map_path; - Some(Box::new(map)) - } + Ok(Some(map)) => Some(Box::new(map)), Ok(None) => None, Err(err) => { log.add_range_warning_fmt( @@ -134,9 +150,7 @@ fn read_regular_file(path: &[u8]) -> bun_sys::Maybe>> { } let size = file.get_end_pos()?; let mut bytes: Vec = Vec::new(); - if bytes.try_reserve_exact(size).is_err() { - return Err(bun_sys::Error::oom()); - } + bytes.try_reserve_exact(size).unwrap_or_oom(); bytes.resize(size, 0); let read = file.read_all(&mut bytes)?; bytes.truncate(read); diff --git a/src/sourcemap/InputSourceMap.rs b/src/sourcemap/InputSourceMap.rs index 891ceb235ac5..a87ebfc5785f 100644 --- a/src/sourcemap/InputSourceMap.rs +++ b/src/sourcemap/InputSourceMap.rs @@ -15,8 +15,6 @@ pub struct InputSourceMap { pub sources: Box<[InputSource]>, /// `sourcesContent` per source as JSON: `"..."` or `null`. pub quoted_sources_content: Box<[Box<[u8]>]>, - /// The sidecar file the map was read from, for the watcher. `None` for a `data:` URL. - pub map_path: Option>, } pub struct InputSource { @@ -266,7 +264,6 @@ impl InputSourceMap { mappings, sources: sources.into_boxed_slice(), quoted_sources_content: quoted_sources_content.into_boxed_slice(), - map_path: None, })) } diff --git a/test/cli/watch/watch.test.ts b/test/cli/watch/watch.test.ts index 8e6938ff1703..985803eeefa0 100644 --- a/test/cli/watch/watch.test.ts +++ b/test/cli/watch/watch.test.ts @@ -785,13 +785,11 @@ it("bun build --watch rebuilds when an input's sidecar source map changes", asyn } }; const waitForSources = async (expected: string[]) => { - const deadline = Date.now() + 4_000; - while (Date.now() < deadline) { + while (true) { const sources = await sourcesOf(); if (sources && Bun.deepEquals(sources, expected)) return; await Bun.sleep(50); } - throw new Error(`expected sources ${JSON.stringify(expected)}, got ${JSON.stringify(await sourcesOf())}`); }; await using bundler = spawn({ @@ -805,5 +803,4 @@ it("bun build --watch rebuilds when an input's sidecar source map changes", asyn await waitForSources(["../src/v1.ts", "../entry.js"]); await Bun.write(join(String(dir), "lib/lib.js.map"), map("../src/v2.ts")); await waitForSources(["../src/v2.ts", "../entry.js"]); - bundler.kill(); });