diff --git a/src/bun_core/string/mod.rs b/src/bun_core/string/mod.rs index 2e7a65b21b08..223278ac8f60 100644 --- a/src/bun_core/string/mod.rs +++ b/src/bun_core/string/mod.rs @@ -2158,9 +2158,20 @@ pub mod printer { } } - /// Same algorithm as `bun_js_printer::write_pre_quoted_string`. - /// PERF: (quote_char, ascii_only, json, encoding) are runtime params — - /// profile if it shows up on a hot path. + /// `strings::Encoding` stand-in that derives `ConstParamTy` so it can be + /// used as a const-generic parameter (`const ENCODING: Encoding`). The + /// variant set is identical; convert at the boundary if a + /// `strings::Encoding` is ever needed. + #[derive(Clone, Copy, Debug, PartialEq, Eq, core::marker::ConstParamTy)] + pub enum Encoding { + Ascii, + Utf8, + Latin1, + Utf16, + } + + /// Runtime-encoding adapter: selects the matching monomorphized + /// [`write_pre_quoted_string_inner`] instance. pub fn write_pre_quoted_string( text_in: &[u8], writer: &mut W, @@ -2169,68 +2180,122 @@ pub mod printer { json: bool, encoding: StrEncoding, ) -> crate::CrateResult<()> { - debug_assert!(!json || quote_char == b'"'); - // utf16 view over the same bytes (only used when encoding == Utf16). - // Callers pass 2-byte-aligned even-length input for Utf16; `cast_slice` - // panics (rather than UB) if that contract is violated. - let text16: &[u16] = if encoding == StrEncoding::Utf16 { - crate::cast_slice::(text_in) - } else { - &[] - }; - let n: usize = if encoding == StrEncoding::Utf16 { - text16.len() - } else { - text_in.len() - }; + match encoding { + StrEncoding::Ascii => write_pre_quoted_string_inner::( + text_in, writer, quote_char, ascii_only, json, + ), + StrEncoding::Utf8 => write_pre_quoted_string_inner::( + text_in, writer, quote_char, ascii_only, json, + ), + StrEncoding::Latin1 => write_pre_quoted_string_inner::( + text_in, writer, quote_char, ascii_only, json, + ), + StrEncoding::Utf16 => write_pre_quoted_string_inner::( + text_in, writer, quote_char, ascii_only, json, + ), + } + } + + /// `quote_char` / `ascii_only` / `json` are runtime args: the branches on + /// them are cheap and well-predicted, and collapsing the monomorphizations + /// keeps the hot transpile pages dense. `ENCODING` stays `const` because it + /// changes the code-unit indexing structure of the loop, so a per-encoding + /// copy is genuinely different code. + #[inline(never)] + pub fn write_pre_quoted_string_inner( + text_in: &[u8], + writer: &mut W, + quote_char: u8, + ascii_only: bool, + json: bool, + ) -> crate::CrateResult<()> + where + W: PrinterWriter + ?Sized, + { + debug_assert!( + !(json && quote_char != b'"'), + "for json, quote_char must be '\"'" + ); + + // this is a large hot-path function; logic is ported 1:1 but the + // utf16 path needs &[u16] handling. + let text = text_in; let mut i: usize = 0; + let n: usize = match ENCODING { + Encoding::Utf16 => text.len() / 2, + _ => text.len(), + }; + + macro_rules! code_unit_at { + ($idx:expr) => { + match ENCODING { + Encoding::Utf16 => { + let lo = text[$idx * 2]; + let hi = text[$idx * 2 + 1]; + u16::from_le_bytes([lo, hi]) as i32 + } + _ => text[$idx] as i32, + } + }; + } while i < n { - let width: u8 = match encoding { - StrEncoding::Latin1 | StrEncoding::Ascii | StrEncoding::Utf16 => 1, - StrEncoding::Utf8 => strings::wtf8_byte_sequence_length_with_invalid(text_in[i]), + let width: u8 = match ENCODING { + Encoding::Latin1 | Encoding::Ascii => 1, + Encoding::Utf8 => strings::wtf8_byte_sequence_length_with_invalid(text[i]), + Encoding::Utf16 => 1, }; let clamped_width = (width as usize).min(n.saturating_sub(i)); - let c: i32 = match encoding { - StrEncoding::Utf8 => { - let mut buf = [0u8; 4]; - buf[..clamped_width].copy_from_slice(&text_in[i..i + clamped_width]); - strings::decode_wtf8_rune_t::(buf, width, 0) + let c: i32 = match ENCODING { + Encoding::Utf8 => { + let bytes: [u8; 4] = match clamped_width { + 1 => [text[i], 0, 0, 0], + 2 => [text[i], text[i + 1], 0, 0], + 3 => [text[i], text[i + 1], text[i + 2], 0], + 4 => [text[i], text[i + 1], text[i + 2], text[i + 3]], + _ => unreachable!(), + }; + strings::decode_wtf8_rune_t::(bytes, width, 0) + } + Encoding::Ascii => { + debug_assert!(text[i] <= 0x7F); + text[i] as i32 } - StrEncoding::Ascii => { - debug_assert!(text_in[i] <= 0x7F); - text_in[i] as i32 + Encoding::Latin1 => text[i] as i32, + Encoding::Utf16 => { + // Surrogate halves are processed one code unit at a time, + // so a pair prints as \uD800\uDF34 instead of the raw + // supplementary character; the paired escape is equivalent + // JS, just longer. + code_unit_at!(i) } - StrEncoding::Latin1 => text_in[i] as i32, - StrEncoding::Utf16 => text16[i] as i32, }; if can_print_without_escape(c, ascii_only) { - match encoding { - StrEncoding::Ascii | StrEncoding::Utf8 => { - let remain = &text_in[i + clamped_width..]; + match ENCODING { + Encoding::Ascii | Encoding::Utf8 => { + let remain = &text[i + clamped_width..]; if let Some(j) = strings::index_of_needs_escape_for_java_script_string( remain, quote_char, ) { - writer.write_all(&text_in[i..i + clamped_width])?; - i += clamped_width; - writer.write_all(&remain[..j as usize])?; - i += j as usize; + let j = j as usize; + writer.write_all(&text[i..i + clamped_width + j])?; + i += clamped_width + j; } else { - writer.write_all(&text_in[i..])?; + writer.write_all(&text[i..])?; break; } } - StrEncoding::Latin1 | StrEncoding::Utf16 => { - let mut cp = [0u8; 4]; - let cp_len = strings::encode_wtf8_rune(&mut cp, c as u32); - writer.write_all(&cp[..cp_len])?; + Encoding::Latin1 | Encoding::Utf16 => { + let mut codepoint_bytes = [0u8; 4]; + let codepoint_len = + strings::encode_wtf8_rune(&mut codepoint_bytes, c as u32); + writer.write_all(&codepoint_bytes[..codepoint_len])?; i += clamped_width; } } continue; } - match c { 0x07 => { writer.write_all(if json { b"\\u0007" } else { b"\\x07" })?; @@ -2245,51 +2310,79 @@ pub mod printer { i += 1; } 0x0A => { - writer.write_all(if quote_char == b'`' { b"\n" } else { b"\\n" })?; + if quote_char == b'`' { + writer.write_all(b"\n")?; + } else { + writer.write_all(b"\\n")?; + } i += 1; } 0x0D => { writer.write_all(b"\\r")?; i += 1; } + // \v 0x0B => { writer.write_all(if json { b"\\u000B" } else { b"\\v" })?; i += 1; } + // "\\" 0x5C => { writer.write_all(b"\\\\")?; i += 1; } 0x22 => { - writer.write_all(if quote_char == b'"' { b"\\\"" } else { b"\"" })?; + if quote_char == b'"' { + writer.write_all(b"\\\"")?; + } else { + writer.write_all(b"\"")?; + } i += 1; } 0x27 => { - writer.write_all(if quote_char == b'\'' { b"\\'" } else { b"'" })?; + if quote_char == b'\'' { + writer.write_all(b"\\'")?; + } else { + writer.write_all(b"'")?; + } i += 1; } 0x60 => { - writer.write_all(if quote_char == b'`' { b"\\`" } else { b"`" })?; + if quote_char == b'`' { + writer.write_all(b"\\`")?; + } else { + writer.write_all(b"`")?; + } i += 1; } 0x24 => { if quote_char == b'`' { - let next_is_brace = match encoding { - StrEncoding::Utf16 => i + 1 < n && text16[i + 1] == b'{' as u16, - _ => i + 1 < n && text_in[i + 1] == b'{', + let next = if i + clamped_width < n { + Some(code_unit_at!(i + clamped_width)) + } else { + None }; - writer.write_all(if next_is_brace { b"\\$" } else { b"$" })?; + if next == Some(b'{' as i32) { + writer.write_all(b"\\$")?; + } else { + writer.write_all(b"$")?; + } } else { writer.write_all(b"$")?; } i += 1; } 0x09 => { - writer.write_all(if quote_char == b'`' { b"\t" } else { b"\\t" })?; + if quote_char == b'`' { + writer.write_all(b"\t")?; + } else { + writer.write_all(b"\\t")?; + } i += 1; } _ => { i += width as usize; + if c <= 0xFF && !json { let h = hex2_upper(c as u8); writer.write_all(&[b'\\', b'x', h[0], h[1]])?; @@ -2311,9 +2404,16 @@ pub mod printer { bytes: &mut MutableString, ascii_only: bool, ) -> crate::CrateResult<()> { - // PERF: consider pre-growing via an estimated UTF-8 length — profile if it shows up on a hot path. + // Heuristic reservation (~12.5% slack) instead of a full escaped-length + // pre-scan, which would repeat the work the escaper below does anyway. + // Tab-indented JS (e.g. three.js) has ~9.4% of bytes needing 2-byte + // escapes, so 6.25% slack would under-shoot and force a 2x doubling + // memcpy of the whole source. The writer still grows on demand. + bytes.grow_if_needed(text.len() + (text.len() >> 3) + 8)?; bytes.append_char(b'"')?; - write_pre_quoted_string(text, bytes, b'"', ascii_only, true, StrEncoding::Utf8)?; + write_pre_quoted_string_inner::<_, { Encoding::Utf8 }>( + text, bytes, b'"', ascii_only, true, + )?; bytes.append_char(b'"').expect("unreachable"); Ok(()) } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 24999fd82b68..999ca855a372 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2599,6 +2599,45 @@ pub mod bv2_impl { } } + /// Shared tail of every parse-task enqueue site: set the scheduling + /// fields, then either hand the task to an onLoad plugin or schedule it + /// on the worker pool, registering copy-for-bundling loaders (`file`, + /// etc.) as additional files with no side effects. + /// + /// `task.jsx` is deliberately left to the caller — the enqueue sites + /// differ in whether they keep the resolver's tsconfig-derived pragma + /// (syncing only `development`) or clone the target transpiler's pragma + /// wholesale. + fn configure_and_dispatch_parse_task( + &mut self, + task: &mut ParseTask, + loader: Loader, + target: options::Target, + is_entry_point: bool, + ) { + task.loader = Some(loader); + task.task.node.next = core::ptr::null_mut(); + task.io_task.node.next = core::ptr::null_mut(); + task.tree_shaking = self.linker.options.tree_shaking; + task.is_entry_point = is_entry_point; + task.known_target = target; + + if !self.enqueue_on_load_plugin_if_needed(task) { + if loader.should_copy_for_bundling() { + let source_index = task.source_index.get(); + let additional_files: &mut bun_alloc::AstVec = + &mut self.graph.input_files.items_additional_files_mut() + [source_index as usize]; + additional_files.push(crate::AdditionalFile::SourceIndex(source_index)); + self.graph.input_files.items_side_effects_mut()[source_index as usize] = + bun_ast::SideEffects::NoSideEffectsPureData; + self.graph.estimated_file_loader_count += 1; + } + + self.graph.pool().schedule(task); + } + } + pub fn enqueue_file_from_dev_server_incremental_graph_invalidation( &mut self, path_slice: &[u8], @@ -2654,30 +2693,11 @@ pub mod bv2_impl { let task_val = ParseTask::init(&result, source_index, self); // SAFETY: arena outlives the bundle pass; reborrow `*mut` as `&mut`. let task: &mut ParseTask = self.arena_create(task_val); - task.loader = Some(loader); - task.task.node.next = core::ptr::null_mut(); - task.tree_shaking = self.linker.options.tree_shaking; - task.known_target = target; task.jsx.development = self .transpiler_for_target(target) .options .forced_jsx_development(); - - // Handle onLoad plugins as entry points - if !self.enqueue_on_load_plugin_if_needed(task) { - if loader.should_copy_for_bundling() { - let additional_files: &mut bun_alloc::AstVec = - &mut self.graph.input_files.items_additional_files_mut() - [source_index.get() as usize]; - additional_files - .push(crate::AdditionalFile::SourceIndex(task.source_index.get())); - self.graph.input_files.items_side_effects_mut()[source_index.get() as usize] = - bun_ast::SideEffects::NoSideEffectsPureData; - self.graph.estimated_file_loader_count += 1; - } - - self.graph.pool().schedule(task); - } + self.configure_and_dispatch_parse_task(task, loader, target, false); Ok(()) } @@ -2759,31 +2779,11 @@ pub mod bv2_impl { let task_val = ParseTask::init(result, source_index, self); // SAFETY: arena outlives the bundle pass; reborrow `*mut` as `&mut`. let task: &mut ParseTask = self.arena_create(task_val); - task.loader = Some(loader); - task.task.node.next = core::ptr::null_mut(); - task.tree_shaking = self.linker.options.tree_shaking; - task.is_entry_point = is_entry_point; - task.known_target = target; task.jsx.development = self .transpiler_for_target(target) .options .forced_jsx_development(); - - // Handle onLoad plugins as entry points - if !self.enqueue_on_load_plugin_if_needed(task) { - if loader.should_copy_for_bundling() { - let additional_files: &mut bun_alloc::AstVec = - &mut self.graph.input_files.items_additional_files_mut() - [source_index.get() as usize]; - additional_files - .push(crate::AdditionalFile::SourceIndex(task.source_index.get())); - self.graph.input_files.items_side_effects_mut()[source_index.get() as usize] = - bun_ast::SideEffects::NoSideEffectsPureData; - self.graph.estimated_file_loader_count += 1; - } - - self.graph.pool().schedule(task); - } + self.configure_and_dispatch_parse_task(task, loader, target, is_entry_point); self.graph .entry_points @@ -3594,30 +3594,11 @@ pub mod bv2_impl { ); // SAFETY: arena outlives the bundle pass; reborrow `*mut` as `&mut`. let task: &mut ParseTask = self.arena_create(task_val); - task.loader = Some(loader); task.jsx = self.transpiler_for_target(known_target).options.jsx.clone(); - task.task.node.next = core::ptr::null_mut(); - task.io_task.node.next = core::ptr::null_mut(); - task.tree_shaking = self.linker.options.tree_shaking; - task.known_target = known_target; self.increment_scan_counter(); - // Handle onLoad plugins - if !self.enqueue_on_load_plugin_if_needed(task) { - if loader.should_copy_for_bundling() { - let additional_files: &mut bun_alloc::AstVec = - &mut self.graph.input_files.items_additional_files_mut() - [source_index.get() as usize]; - additional_files - .push(crate::AdditionalFile::SourceIndex(task.source_index.get())); - self.graph.input_files.items_side_effects_mut()[source_index.get() as usize] = - bun_ast::SideEffects::NoSideEffectsPureData; - self.graph.estimated_file_loader_count += 1; - } - - self.graph.pool().schedule(task); - } + self.configure_and_dispatch_parse_task(task, loader, known_target, false); Ok(source_index.get()) } @@ -3671,7 +3652,6 @@ pub mod bv2_impl { } else { self.transpiler_for_target(known_target).options.jsx.clone() }; - let tree_shaking = self.linker.options.tree_shaking; // SAFETY: arena (`self.graph.heap`) outlives the bundle pass; coerce the // `&mut ParseTask` to `*mut` immediately so the `&self` borrow from // `arena()` ends before we take `&mut self` below. @@ -3684,9 +3664,6 @@ pub mod bv2_impl { module_type: options::ModuleType::Unknown, emit_decorator_metadata: false, // TODO package_version: bun_ast::StoreStr::EMPTY, - loader: Some(loader), - tree_shaking, - known_target, ..Default::default() }); // SAFETY: `task` was just arena-allocated above; no other references exist yet. @@ -3696,27 +3673,17 @@ pub mod bv2_impl { std::ptr::from_mut(self).cast::>(), ); (*task).ctx = Some(ctx_mut); - (*task).task.node.next = core::ptr::null_mut(); - (*task).io_task.node.next = core::ptr::null_mut(); } self.increment_scan_counter(); - // Handle onLoad plugins // SAFETY: `task` lives in the bundle-pass arena; sole reference until scheduled. - if !self.enqueue_on_load_plugin_if_needed(unsafe { &mut *task }) { - if loader.should_copy_for_bundling() { - let additional_files: &mut bun_alloc::AstVec = - &mut self.graph.input_files.items_additional_files_mut() - [source_index.get() as usize]; - additional_files.push(crate::AdditionalFile::SourceIndex(source_index.get())); - self.graph.input_files.items_side_effects_mut()[source_index.get() as usize] = - bun_ast::SideEffects::NoSideEffectsPureData; - self.graph.estimated_file_loader_count += 1; - } - - self.graph.pool().schedule(task); - } + self.configure_and_dispatch_parse_task( + unsafe { &mut *task }, + loader, + known_target, + false, + ); Ok(source_index.get()) } @@ -4816,35 +4783,19 @@ pub mod bv2_impl { .clone(), source_index: bun_ast::Index::init(source_index.get()), module_type: options::ModuleType::Unknown, - loader: Some(loader), - tree_shaking: this.linker.options.tree_shaking, - known_target: resolve.import_record.original_target, ..Default::default() }; // Arena-owned. // SAFETY: arena outlives the bundle pass. let task: &mut ParseTask = this.arena_create(task_val); - task.task.node.next = core::ptr::null_mut(); - task.io_task.node.next = core::ptr::null_mut(); this.increment_scan_counter(); - if !this.enqueue_on_load_plugin_if_needed(task) { - if loader.should_copy_for_bundling() { - let additional_files: &mut bun_alloc::AstVec< - crate::AdditionalFile, - > = &mut this.graph.input_files.items_additional_files_mut() - [source_index.get() as usize]; - additional_files.push(crate::AdditionalFile::SourceIndex( - task.source_index.get(), - )); - this.graph.input_files.items_side_effects_mut() - [source_index.get() as usize] = - bun_ast::SideEffects::NoSideEffectsPureData; - this.graph.estimated_file_loader_count += 1; - } - - this.graph.pool().schedule(task); - } + this.configure_and_dispatch_parse_task( + task, + loader, + resolve.import_record.original_target, + false, + ); } else { // SAFETY: map slot from `get_or_put` above; map not mutated since. out_source_index = Some(Index::init(unsafe { *value_ptr })); diff --git a/src/bundler/linker_context/generateChunksInParallel.rs b/src/bundler/linker_context/generateChunksInParallel.rs index 81f52306020d..0bbc6307bf78 100644 --- a/src/bundler/linker_context/generateChunksInParallel.rs +++ b/src/bundler/linker_context/generateChunksInParallel.rs @@ -31,12 +31,12 @@ use crate::linker_context::prepare_css_asts_for_chunk::{ PrepareCssAstTask, prepare_css_asts_for_chunk, }; use crate::linker_context::static_route_visitor::StaticRouteVisitor; -use crate::linker_context::write_output_files_to_disk::write_output_files_to_disk; +use crate::linker_context::write_output_files_to_disk::{ + BYTECODE_EXTENSION, append_linked_sourcemap_url, generate_chunk_bytecode, + standalone_placeholder_output_file, write_output_files_to_disk, +}; use crate::linker_context_mod::{GenerateChunkCtx, PendingPartRange}; -/// Bytecode output file extension (also defined in `writeOutputFilesToDisk.rs`). -const BYTECODE_EXTENSION: &str = ".jsc"; - // `Chunk.final_rel_path` / `metafile_chunk_json` are owned // `Box<[u8]>`; assignments // below move the boxed buffer directly — no lifetime promotion needed. @@ -843,28 +843,12 @@ pub(crate) fn generate_chunks_in_parallel( None }; - let _ = output_files.insert_for_chunk(options::OutputFile::init( - options::OutputFileInit { - data: options::OutputFileData::Buffer { - data: Box::default(), - }, - hash: None, - loader: chunks[chunk_index_in_chunks_list].content.loader(), - input_path: Box::default(), - display_size: 0, - output_kind: options::OutputKind::Chunk, - input_loader: Loader::Js, - output_path: Box::default(), - is_executable: false, - source_map_index, - bytecode_index: None, - module_info_index: None, - side: Some(options::Side::Client), - entry_point_index: None, - referenced_css_chunks: Box::default(), - bake_extra: BakeExtra::default(), - ..Default::default() + let _ = output_files.insert_for_chunk(standalone_placeholder_output_file( + chunks[chunk_index_in_chunks_list].content.loader(), + options::OutputFileData::Buffer { + data: Box::default(), }, + source_map_index, )); continue; } @@ -948,26 +932,11 @@ pub(crate) fn generate_chunks_in_parallel( source_map_final_rel_path.extend_from_slice(b".map"); if tag == SourceMapOption::Linked { - let [a, b]: [&[u8]; 2] = if public_path.len() > 0 { - cheap_prefix_normalizer(public_path, &source_map_final_rel_path) - } else { - [b"", path::basename(&source_map_final_rel_path)] - }; - - let source_map_start = b"//# sourceMappingURL="; - let total_len = code_result.buffer.len() - + source_map_start.len() - + a.len() - + b.len() - + b"\n".len(); - let mut buf: Vec = Vec::with_capacity(total_len); - buf.extend_from_slice(&code_result.buffer); - buf.extend_from_slice(source_map_start); - buf.extend_from_slice(a); - buf.extend_from_slice(b); - buf.push(b'\n'); - - code_result.buffer = buf.into_boxed_slice(); + append_linked_sourcemap_url( + &mut code_result.buffer, + public_path, + &source_map_final_rel_path, + ); } sourcemap_output_file = @@ -1040,7 +1009,6 @@ pub(crate) fn generate_chunks_in_parallel( if matches!(chunk.content, crate::chunk::Content::Javascript(_)) && loader.is_javascript_like() { - let mut fdpath = bun_paths::PathBuffer::uninit(); // For --compile builds, the bytecode URL must match the module name // that will be used at runtime. The module name is: // public_path + final_rel_path (e.g., "/$bunfs/root/app.js") @@ -1064,33 +1032,12 @@ pub(crate) fn generate_chunks_in_parallel( BYTECODE_EXTENSION )) }; - source_provider_url.ref_(); - // RAII: `defer source_provider_url.deref()` — `OwnedString::Drop` - // releases the ref bumped above on every exit path (incl. `break 'brk`). - let mut source_provider_url = - bun_core::OwnedString::new(source_provider_url); - - if let Some(bytecode) = crate::bundle_v2::dispatch::generate_cached_bytecode( + if let Some((bytecode, source_provider_url)) = generate_chunk_bytecode( c.options.output_format, &code_result.buffer, - &mut source_provider_url, + source_provider_url, ) { let source_provider_url_str = source_provider_url.to_utf8(); - debug!( - "Bytecode cache generated {}: {}", - bstr::BStr::new(source_provider_url_str.slice()), - bun_core::fmt::size( - bytecode.len(), - bun_core::fmt::SizeFormatterOptions { - space_between_number_and_unit: true - } - ) - ); - fdpath[..chunk.final_rel_path.len()] - .copy_from_slice(&chunk.final_rel_path); - fdpath[chunk.final_rel_path.len()..][..BYTECODE_EXTENSION.len()] - .copy_from_slice(BYTECODE_EXTENSION.as_bytes()); - let mut input_path_buf: Vec = Vec::new(); input_path_buf.extend_from_slice(&chunk.final_rel_path); input_path_buf.extend_from_slice(BYTECODE_EXTENSION.as_bytes()); diff --git a/src/bundler/linker_context/writeOutputFilesToDisk.rs b/src/bundler/linker_context/writeOutputFilesToDisk.rs index 8be1e88e3f5d..cc8bbfc43eb9 100644 --- a/src/bundler/linker_context/writeOutputFilesToDisk.rs +++ b/src/bundler/linker_context/writeOutputFilesToDisk.rs @@ -25,8 +25,81 @@ use bun_sys::{ write_file_with_path_buffer, }; -/// Bytecode output file extension (also defined in `generateChunksInParallel.rs`). -const BYTECODE_EXTENSION: &str = ".jsc"; +/// Bytecode output file extension (also used by `generateChunksInParallel.rs`). +pub(crate) const BYTECODE_EXTENSION: &str = ".jsc"; + +/// Append `//# sourceMappingURL=\n` to a chunk's code buffer for +/// `sourcemap: "linked"`, rebuilding the buffer at exact capacity. +pub(crate) fn append_linked_sourcemap_url( + buffer: &mut Box<[u8]>, + public_path: &[u8], + source_map_final_rel_path: &[u8], +) { + let [a, b]: [&[u8]; 2] = if !public_path.is_empty() { + cheap_prefix_normalizer(public_path, source_map_final_rel_path) + } else { + [b"", paths::basename(source_map_final_rel_path)] + }; + + let source_map_start = b"//# sourceMappingURL="; + let total_len = buffer.len() + source_map_start.len() + a.len() + b.len() + b"\n".len(); + let mut buf: Vec = Vec::with_capacity(total_len); + buf.extend_from_slice(buffer); + buf.extend_from_slice(source_map_start); + buf.extend_from_slice(a); + buf.extend_from_slice(b); + buf.push(b'\n'); + *buffer = buf.into_boxed_slice(); +} + +/// Generate the JSC bytecode cache for a chunk's code. Takes ownership of the +/// freshly created `source_provider_url` and returns it (still alive) next to +/// the bytecode so callers can read its UTF-8 form for the output path. +pub(crate) fn generate_chunk_bytecode( + format: options::Format, + code: &[u8], + source_provider_url: BunString, +) -> Option<(Box<[u8]>, bun_core::OwnedString)> { + // `source_provider_url` arrives with the +1 from `create_format`, and + // bytecode generation only borrows it, so `OwnedString` adopts that ref + // and releases it on every exit path. + let mut source_provider_url = bun_core::OwnedString::new(source_provider_url); + let bytecode = crate::bundle_v2::dispatch::generate_cached_bytecode( + format, + code, + &mut source_provider_url, + )?; + debug!( + "Bytecode cache generated {}: {}", + bstr::BStr::new(source_provider_url.to_utf8().slice()), + bun_core::fmt::size( + bytecode.len(), + bun_core::fmt::SizeFormatterOptions { + space_between_number_and_unit: true, + } + ), + ); + Some((bytecode, source_provider_url)) +} + +/// Placeholder output file inserted for non-HTML chunks in standalone mode to +/// keep chunk indices aligned. `source_map_index` links the chunk to its +/// separately emitted `.map` output file, if any. +pub(crate) fn standalone_placeholder_output_file( + loader: Loader, + data: OutputFileData, + source_map_index: Option, +) -> OutputFile { + OutputFile::init(OutputFileInit { + data, + loader, + input_loader: Loader::Js, + output_kind: options::OutputKind::Chunk, + side: Some(options::Side::Client), + source_map_index, + ..Default::default() + }) +} pub(crate) fn write_output_files_to_disk( c: &mut LinkerContext, @@ -171,26 +244,11 @@ pub(crate) fn write_output_files_to_disk( None }; - let _ = output_files.insert_for_chunk(OutputFile::init(OutputFileInit { - data: OutputFileData::Saved(0), - hash: None, - loader: chunk.content.loader(), - input_path: Box::default(), - display_size: 0, - output_kind: options::OutputKind::Chunk, - input_loader: Loader::Js, - output_path: Box::default(), - is_executable: false, + let _ = output_files.insert_for_chunk(standalone_placeholder_output_file( + chunk.content.loader(), + OutputFileData::Saved(0), source_map_index, - bytecode_index: None, - module_info_index: None, - side: Some(options::Side::Client), - entry_point_index: None, - referenced_css_chunks: Box::default(), - size: None, - source_index: IndexOptional::NONE, - bake_extra: BakeExtra::default(), - })); + )); continue; } @@ -302,25 +360,11 @@ pub(crate) fn write_output_files_to_disk( let source_map_final_rel_path = strings::concat(&[&chunk.final_rel_path, b".map"]); if tag == SourceMapOption::Linked { - let [a, b] = if !public_path.is_empty() { - cheap_prefix_normalizer(public_path, &source_map_final_rel_path) - } else { - [b"" as &[u8], paths::basename(&source_map_final_rel_path)] - }; - - let source_map_start = b"//# sourceMappingURL="; - let total_len = code_result.buffer.len() - + source_map_start.len() - + a.len() - + b.len() - + b"\n".len(); - let mut buf: Vec = Vec::with_capacity(total_len); - buf.extend_from_slice(&code_result.buffer); - buf.extend_from_slice(source_map_start); - buf.extend_from_slice(a); - buf.extend_from_slice(b); - buf.push(b'\n'); - code_result.buffer = buf.into_boxed_slice(); + append_linked_sourcemap_url( + &mut code_result.buffer, + public_path, + &source_map_final_rel_path, + ); } match bun_sys::File::write_file( @@ -404,26 +448,13 @@ pub(crate) fn write_output_files_to_disk( bstr::BStr::new(&chunk.final_rel_path), BYTECODE_EXTENSION, )); - source_provider_url.ref_(); - // `defer source_provider_url.deref()` handled by Drop on OwnedString. - let mut source_provider_url = bun_core::OwnedString::new(source_provider_url); - if let Some(bytecode) = crate::bundle_v2::dispatch::generate_cached_bytecode( + if let Some((bytecode, source_provider_url)) = generate_chunk_bytecode( c.options.output_format, &code_result.buffer, - &mut source_provider_url, + source_provider_url, ) { let source_provider_url_str = source_provider_url.to_utf8(); - debug!( - "Bytecode cache generated {}: {}", - bstr::BStr::new(source_provider_url_str.slice()), - bun_core::fmt::size( - bytecode.len(), - bun_core::fmt::SizeFormatterOptions { - space_between_number_and_unit: true, - } - ), - ); let frp: &[u8] = &chunk.final_rel_path; fdpath[..frp.len()].copy_from_slice(frp); fdpath[frp.len()..frp.len() + BYTECODE_EXTENSION.len()] diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index b34de7f8e6c2..eeac73ed045b 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -932,6 +932,25 @@ impl<'a> ParseResult<'a> { } } + #[inline] + fn with_ast( + ast: bun_ast::Ast<'a>, + source: &bun_ast::Source, + loader: options::Loader, + source_contents_backing: resolver::cache::Contents, + ) -> Self { + ParseResult { + ast, + source: source.clone(), + loader, + already_bundled: AlreadyBundled::None, + pending_imports: Default::default(), + runtime_transpiler_cache: None, + empty: false, + source_contents_backing, + } + } + pub(crate) fn is_pending_import(&self, id: u32) -> bool { // AoS scan (see field comment); SoA column iteration restored // when `PendingResolution: MultiArrayElement` lands. @@ -1830,6 +1849,42 @@ impl<'a> Transpiler<'a> { // instead of being interleaved (post-LTO) with the hot JS/TS parse path. // --------------------------------------------------------------------------- +fn export_default_stmt(expr: bun_ast::Expr) -> bun_ast::Stmt { + bun_ast::Stmt::alloc( + bun_ast::S::ExportDefault { + value: bun_ast::StmtOrExpr::Expr(expr), + default_name: bun_ast::LocRef { + loc: bun_ast::Loc::default(), + ref_: bun_ast::Ref::NONE, + }, + }, + bun_ast::Loc { start: 0 }, + ) +} + +/// Wrap `expr` as the sole `export default` statement of a single-part AST +/// and build the `ParseResult` around it. +#[cold] +fn export_default_parse_result<'a>( + expr: bun_ast::Expr, + source: &bun_ast::Source, + loader: options::Loader, + source_backing: resolver::cache::Contents, + arena: &'a Arena, +) -> Option> { + let stmts = bun_ast::StoreSlice::new_mut(arena.alloc_slice_copy(&[export_default_stmt(expr)])); + let parts: Box<[bun_ast::Part]> = Box::new([bun_ast::Part { + stmts, + ..Default::default() + }]); + Some(ParseResult::with_ast( + bun_ast::Ast::from_parts(parts, arena), + source, + loader, + source_backing, + )) +} + #[cold] #[inline(never)] fn parse_data_loader<'a>( @@ -2037,16 +2092,7 @@ fn parse_data_loader<'a>( }, bun_ast::Loc { start: 0 }, ); - let stmt2 = bun_ast::Stmt::alloc( - bun_ast::S::ExportDefault { - value: bun_ast::StmtOrExpr::Expr(expr), - default_name: bun_ast::LocRef { - loc: bun_ast::Loc::default(), - ref_: bun_ast::Ref::NONE, - }, - }, - bun_ast::Loc { start: 0 }, - ); + let stmt2 = export_default_stmt(expr); let stmts = bun_ast::StoreSlice::new_mut(arena.alloc_slice_copy(&[stmt0, stmt1, stmt2])); @@ -2057,38 +2103,14 @@ fn parse_data_loader<'a>( } } - { - let stmt = bun_ast::Stmt::alloc( - bun_ast::S::ExportDefault { - value: bun_ast::StmtOrExpr::Expr(expr), - default_name: bun_ast::LocRef { - loc: bun_ast::Loc::default(), - ref_: bun_ast::Ref::NONE, - }, - }, - bun_ast::Loc { start: 0 }, - ); - - let stmts = bun_ast::StoreSlice::new_mut(arena.alloc_slice_copy(&[stmt])); - break 'parts Box::new([bun_ast::Part { - stmts, - ..Default::default() - }]); - } + // `symbols` is only populated by the non-empty-object branch above, + // which always `break 'parts`s; this fallthrough has no symbols. + return export_default_parse_result(expr, source, loader, source_backing, arena); }; let mut ast = bun_ast::Ast::from_parts(parts, arena); ast.symbols = bun_alloc::vec_from_iter_in(symbols, arena); - return Some(ParseResult { - ast, - source: source.clone(), - loader, - already_bundled: AlreadyBundled::None, - pending_imports: Default::default(), - runtime_transpiler_cache: None, - empty: false, - source_contents_backing: source_backing, - }); + return Some(ParseResult::with_ast(ast, source, loader, source_backing)); } #[cold] @@ -2103,32 +2125,7 @@ fn parse_text_loader<'a>( bun_ast::E::EString::init(&source.contents), bun_ast::Loc::EMPTY, ); - let stmt = bun_ast::Stmt::alloc( - bun_ast::S::ExportDefault { - value: bun_ast::StmtOrExpr::Expr(expr), - default_name: bun_ast::LocRef { - loc: bun_ast::Loc::default(), - ref_: bun_ast::Ref::NONE, - }, - }, - bun_ast::Loc { start: 0 }, - ); - let stmts = bun_ast::StoreSlice::new_mut(arena.alloc_slice_copy(&[stmt])); - let parts: Box<[bun_ast::Part]> = Box::new([bun_ast::Part { - stmts, - ..Default::default() - }]); - - return Some(ParseResult { - ast: bun_ast::Ast::from_parts(parts, arena), - source: source.clone(), - loader, - already_bundled: AlreadyBundled::None, - pending_imports: Default::default(), - runtime_transpiler_cache: None, - empty: false, - source_contents_backing: source_backing, - }); + export_default_parse_result(expr, source, loader, source_backing, arena) } #[cold] @@ -2159,32 +2156,7 @@ fn parse_md_loader<'a>( } }; let expr = bun_ast::Expr::init(bun_ast::E::EString::init(html), bun_ast::Loc::EMPTY); - let stmt = bun_ast::Stmt::alloc( - bun_ast::S::ExportDefault { - value: bun_ast::StmtOrExpr::Expr(expr), - default_name: bun_ast::LocRef { - loc: bun_ast::Loc::default(), - ref_: bun_ast::Ref::NONE, - }, - }, - bun_ast::Loc { start: 0 }, - ); - let stmts = bun_ast::StoreSlice::new_mut(arena.alloc_slice_copy(&[stmt])); - let parts: Box<[bun_ast::Part]> = Box::new([bun_ast::Part { - stmts, - ..Default::default() - }]); - - return Some(ParseResult { - ast: bun_ast::Ast::from_parts(parts, arena), - source: source.clone(), - loader, - already_bundled: AlreadyBundled::None, - pending_imports: Default::default(), - runtime_transpiler_cache: None, - empty: false, - source_contents_backing: source_backing, - }); + export_default_parse_result(expr, source, loader, source_backing, arena) } #[cold] @@ -2211,16 +2183,12 @@ fn parse_wasm_loader<'a>( return None; } - return Some(ParseResult { - ast: bun_ast::Ast::empty_in(arena), - source: source.clone(), + return Some(ParseResult::with_ast( + bun_ast::Ast::empty_in(arena), + source, loader, - already_bundled: AlreadyBundled::None, - pending_imports: Default::default(), - runtime_transpiler_cache: None, - empty: false, - source_contents_backing: source_backing, - }); + source_backing, + )); } None } diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index a465407cf3f4..cd641ede5050 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -23,16 +23,9 @@ use bun_core::strings; use bun_core::strings::CodepointIterator; use bun_options_types::bundle_enums as bundle_opts; -/// Local stand-in for `bun_core::strings::Encoding` that derives `ConstParamTy` so it can -/// be used as a const-generic parameter (`const ENCODING: Encoding`). The variant set is -/// identical; convert at the boundary if a `strings::Encoding` is ever needed. -#[derive(Clone, Copy, Debug, PartialEq, Eq, core::marker::ConstParamTy)] -pub enum Encoding { - Ascii, - Utf8, - Latin1, - Utf16, -} +/// Const-generic-capable encoding enum; the canonical definition lives in +/// `bun_core::printer` next to the escaping loop. +pub use bun_core::printer::Encoding; /// Byte-sink trait used by the string-escape helpers and `StdWriterAdapter`. /// Re-exported from `bun_io` (canonical in `bun_core::io`); any `bun_io::Write` @@ -692,38 +685,11 @@ pub mod analyze_transpiled_module { /// link-interface); the printer just holds the raw pointer. pub type RuntimeTranspilerCacheRef = core::ptr::NonNull; -use bun_core::fmt::hex2_upper; // remaining `\xHH` site below -use bun_core::printer::{ - FIRST_ASCII, FIRST_HIGH_SURROGATE, LAST_ASCII, LAST_LOW_SURROGATE, bmp_escape, - surrogate_pair_escape, -}; +use bun_core::printer::{FIRST_ASCII, LAST_ASCII, bmp_escape, surrogate_pair_escape}; /// For support JavaScriptCore const ASCII_ONLY_ALWAYS_ON_UNLESS_MINIFYING: bool = true; -// Callers widen to i32 at the boundary. -// PERF: `ascii_only` is a *runtime* arg so the large -// callers (`write_pre_quoted_string_inner`, `estimate_length_for_utf8`) collapse to a -// single monomorphization instead of one per (ascii_only × quote_char × …) combo — -// see the comment on `write_pre_quoted_string`. -#[inline] -pub(crate) fn can_print_without_escape(c: i32, ascii_only: bool) -> bool { - if c <= LAST_ASCII as i32 { - c >= FIRST_ASCII as i32 - && c != i32::from(b'\\') - && c != i32::from(b'"') - && c != i32::from(b'\'') - && c != i32::from(b'`') - && c != i32::from(b'$') - } else { - !ascii_only - && c != 0xFEFF - && c != 0x2028 - && c != 0x2029 - && (c < FIRST_HIGH_SURROGATE as i32 || c > LAST_LOW_SURROGATE as i32) - } -} - const INDENTATION_SPACE_BUF: [u8; 128] = [b' '; 128]; const INDENTATION_TAB_BUF: [u8; 128] = [b'\t'; 128]; @@ -846,229 +812,12 @@ pub fn write_pre_quoted_string< where W: Write + ?Sized, { - write_pre_quoted_string_inner::(text_in, writer, QUOTE_CHAR, ASCII_ONLY, JSON) + Ok(write_pre_quoted_string_inner::( + text_in, writer, QUOTE_CHAR, ASCII_ONLY, JSON, + )?) } -/// `quote_char` / `ascii_only` / `json` are runtime args (were `const`): the -/// branches on them are cheap and well-predicted, and collapsing the -/// monomorphizations keeps the hot transpile pages dense (see the facade above). -/// `ENCODING` stays `const` — it changes the code-unit indexing structure of the -/// loop, so a per-encoding copy is genuinely different code. -#[inline(never)] -pub fn write_pre_quoted_string_inner( - text_in: &[u8], - writer: &mut W, - quote_char: u8, - ascii_only: bool, - json: bool, -) -> crate::Result<()> -where - W: Write + ?Sized, -{ - debug_assert!( - !(json && quote_char != b'"'), - "for json, quote_char must be '\"'" - ); - - // this is a large hot-path function; logic is ported 1:1 but the - // utf16 path needs &[u16] handling. - let text = text_in; - let mut i: usize = 0; - let n: usize = match ENCODING { - Encoding::Utf16 => text.len() / 2, - _ => text.len(), - }; - - macro_rules! code_unit_at { - ($idx:expr) => { - match ENCODING { - Encoding::Utf16 => { - let lo = text[$idx * 2]; - let hi = text[$idx * 2 + 1]; - u16::from_le_bytes([lo, hi]) as i32 - } - _ => text[$idx] as i32, - } - }; - } - - while i < n { - let width: u8 = match ENCODING { - Encoding::Latin1 | Encoding::Ascii => 1, - Encoding::Utf8 => strings::wtf8_byte_sequence_length_with_invalid(text[i]), - Encoding::Utf16 => 1, - }; - let clamped_width = (width as usize).min(n.saturating_sub(i)); - let c: i32 = match ENCODING { - Encoding::Utf8 => { - let bytes: [u8; 4] = match clamped_width { - 1 => [text[i], 0, 0, 0], - 2 => [text[i], text[i + 1], 0, 0], - 3 => [text[i], text[i + 1], text[i + 2], 0], - 4 => [text[i], text[i + 1], text[i + 2], text[i + 3]], - _ => unreachable!(), - }; - strings::decode_wtf8_rune_t::(bytes, width, 0) - } - Encoding::Ascii => { - debug_assert!(text[i] <= 0x7F); - text[i] as i32 - } - Encoding::Latin1 => text[i] as i32, - Encoding::Utf16 => { - // TODO: if this is a part of a surrogate pair, we could parse the whole codepoint in order - // to emit it as a single \u{result} rather than two paired \uLOW\uHIGH. - // eg: "\u{10334}" will convert to "𐌴" without this. - code_unit_at!(i) - } - }; - - if can_print_without_escape(c, ascii_only) { - match ENCODING { - Encoding::Ascii | Encoding::Utf8 => { - let remain = &text[i + clamped_width..]; - if let Some(j) = - strings::index_of_needs_escape_for_java_script_string(remain, quote_char) - { - let j = j as usize; - writer.write_all(&text[i..i + clamped_width + j])?; - i += clamped_width + j; - } else { - writer.write_all(&text[i..])?; - break; - } - } - Encoding::Latin1 | Encoding::Utf16 => { - let mut codepoint_bytes = [0u8; 4]; - let codepoint_len = strings::encode_wtf8_rune(&mut codepoint_bytes, c as u32); - writer.write_all(&codepoint_bytes[..codepoint_len])?; - i += clamped_width; - } - } - continue; - } - match c { - 0x07 => { - writer.write_all(if json { b"\\u0007" } else { b"\\x07" })?; - i += 1; - } - 0x08 => { - writer.write_all(b"\\b")?; - i += 1; - } - 0x0C => { - writer.write_all(b"\\f")?; - i += 1; - } - 0x0A => { - if quote_char == b'`' { - writer.write_all(b"\n")?; - } else { - writer.write_all(b"\\n")?; - } - i += 1; - } - 0x0D => { - writer.write_all(b"\\r")?; - i += 1; - } - // \v - 0x0B => { - writer.write_all(if json { b"\\u000B" } else { b"\\v" })?; - i += 1; - } - // "\\" - 0x5C => { - writer.write_all(b"\\\\")?; - i += 1; - } - 0x22 => { - if quote_char == b'"' { - writer.write_all(b"\\\"")?; - } else { - writer.write_all(b"\"")?; - } - i += 1; - } - 0x27 => { - if quote_char == b'\'' { - writer.write_all(b"\\'")?; - } else { - writer.write_all(b"'")?; - } - i += 1; - } - 0x60 => { - if quote_char == b'`' { - writer.write_all(b"\\`")?; - } else { - writer.write_all(b"`")?; - } - i += 1; - } - 0x24 => { - if quote_char == b'`' { - let next = if i + clamped_width < n { - Some(code_unit_at!(i + clamped_width)) - } else { - None - }; - if next == Some(b'{' as i32) { - writer.write_all(b"\\$")?; - } else { - writer.write_all(b"$")?; - } - } else { - writer.write_all(b"$")?; - } - i += 1; - } - 0x09 => { - if quote_char == b'`' { - writer.write_all(b"\t")?; - } else { - writer.write_all(b"\\t")?; - } - i += 1; - } - _ => { - i += width as usize; - - if c <= 0xFF && !json { - let h = hex2_upper(c as u8); - writer.write_all(&[b'\\', b'x', h[0], h[1]])?; - } else if c <= 0xFFFF { - writer.write_all(&bmp_escape(c as u32))?; - } else { - writer.write_all(&surrogate_pair_escape(c as u32))?; - } - } - } - } - Ok(()) -} - -pub fn quote_for_json( - text: &[u8], - bytes: &mut MutableString, - ascii_only: bool, -) -> crate::Result<()> { - // `ascii_only` is threaded at runtime so - // the heavy escaper isn't monomorphized per ascii_only/quote-char combo. - // - // Heuristic reservation (~12.5% slack) instead of `estimate_length_for_utf8`, - // which would do a full SIMD scan + per-escape rune decode over `text` just - // to size the buffer — the same work `write_pre_quoted_string_inner` repeats - // immediately below. Tab-indented JS (e.g. three.js) has ~9.4% of bytes - // needing 2-byte escapes (tabs + newlines + quotes/backslashes), so 6.25% - // slack would under-shoot and force a 2x doubling memcpy of the whole - // source. The writer still grows on demand if this under-shoots. - bytes.grow_if_needed(text.len() + (text.len() >> 3) + 8)?; - bytes.append_char(b'"')?; - write_pre_quoted_string_inner::<_, { Encoding::Utf8 }>(text, bytes, b'"', ascii_only, true)?; - bytes.append_char(b'"').expect("unreachable"); - Ok(()) -} +pub use bun_core::printer::{quote_for_json, write_pre_quoted_string_inner}; pub fn write_json_string( input: &[u8], diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 1e45ac9687a1..80dbf20d625b 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -2655,116 +2655,18 @@ impl<'a> Resolver<'a> { if let Some(package_json) = pkg_dir_info.package_json() { if let Some(exports_map) = package_json.exports.as_ref() { - // The condition set is determined by the kind of import let mut module_type = package_json.module_type; - // NOTE: keeping a single - // `ESModule` (which holds `&mut self.debug_logs`) alive across a - // `&mut self` call is aliased-&mut UB. Build a fresh short-lived - // `ESModule` per `resolve` call so its borrow ends before - // `self.handle_esm_resolution` re-borrows `self`. - // Resolve against the path "/", then join it with the absolute - // directory path. This is done because ESM package resolution uses - // URLs while our path resolution uses file system paths. We don't - // want problems due to Windows paths, which are very unlike URL - // paths. We also want to avoid any "%" characters in the absolute - // directory path accidentally being interpreted as URL escapes. - { - let esm_resolution = ESModule { - conditions: match kind { - ast::ImportKind::Require - | ast::ImportKind::RequireResolve => { - &self.opts.conditions.require - } - ast::ImportKind::At - | ast::ImportKind::AtConditional => { - &self.opts.conditions.style - } - _ => &self.opts.conditions.import, - }, - debug_logs: self.debug_logs.as_mut(), - module_type: &mut module_type, - } - .resolve(b"/", esm.subpath, &exports_map.root); - // ESModule temporary dropped here; `self` is unborrowed. - - if self - .handle_esm_resolution( - esm_resolution, - abs_package_path, - kind, - package_json, - esm.subpath, - out, - ) - .is_success() - { - out.is_node_module = true; - out.module_type = module_type; - self.extension_order = prev_extension_order; - if let Some(d) = self.debug_logs.as_mut() { - d.decrease_indent(); - } - return MatchStatus::Success; - } - } - - // Some popular packages forget to include the extension in their - // exports map, so we try again without the extension. - // - // This is useful for browser-like environments - // where you want a file extension in the URL - // pathname by convention. Vite does this. - // - // React is an example of a package that doesn't include file extensions. - // { - // "exports": { - // ".": "./index.js", - // "./jsx-runtime": "./jsx-runtime.js", - // } - // } - // - // We limit this behavior just to ".js" files. - let extname = bun_paths::extension(esm.subpath); - if extname == b".js" && esm.subpath.len() > 3 { - let esm_resolution = ESModule { - conditions: match kind { - ast::ImportKind::Require - | ast::ImportKind::RequireResolve => { - &self.opts.conditions.require - } - ast::ImportKind::At - | ast::ImportKind::AtConditional => { - &self.opts.conditions.style - } - _ => &self.opts.conditions.import, - }, - debug_logs: self.debug_logs.as_mut(), - module_type: &mut module_type, - } - .resolve( - b"/", - &esm.subpath[0..esm.subpath.len() - 3], - &exports_map.root, - ); - if self - .handle_esm_resolution( - esm_resolution, - abs_package_path, - kind, - package_json, - esm.subpath, - out, - ) - .is_success() - { - out.is_node_module = true; - out.module_type = module_type; - self.extension_order = prev_extension_order; - if let Some(d) = self.debug_logs.as_mut() { - d.decrease_indent(); - } - return MatchStatus::Success; - } + if self.resolve_esm_exports( + kind, + esm.subpath, + &exports_map.root, + abs_package_path, + package_json, + &mut module_type, + prev_extension_order, + out, + ) { + return MatchStatus::Success; } // if they hid "package.json" from "exports", still allow importing it. @@ -3160,7 +3062,8 @@ impl<'a> Resolver<'a> { if let Some(package_json) = pkg_dir_info.package_json() { if let Some(exports_map) = package_json.exports.as_ref() { // The condition set is determined by the kind of import - // NOTE: reshaped for borrowck — see identical note above. + // NOTE: reshaped for borrowck — see the note on + // `resolve_esm_exports`. // Resolve against the path "/", then join it with the absolute // directory path. This is done because ESM package resolution uses // URLs while our path resolution uses file system paths. We don't @@ -3624,6 +3527,93 @@ impl<'a> Resolver<'a> { unreachable!("TODO: implement enqueueDependencyToResolve for non-root packages") } + /// Resolves `subpath` against a package's `exports` map, picking the + /// condition set for `kind`. On success, fills `out`, restores + /// `prev_extension_order`, and unindents the debug logs. + /// + /// Resolve against the path "/", then join it with the absolute directory + /// path: ESM package resolution uses URLs while our path resolution uses + /// file system paths, and we want neither Windows-path problems nor "%" + /// characters being interpreted as URL escapes. + /// + /// NOTE: keeping a single `ESModule` (which holds `&mut self.debug_logs`) + /// alive across a `&mut self` call is aliased-&mut UB, so it must drop + /// before `self.handle_esm_resolution` re-borrows `self`. + #[allow(clippy::too_many_arguments)] + fn resolve_esm_exports( + &mut self, + kind: ast::ImportKind, + subpath: &[u8], + exports_root: &crate::package_json::Entry, + abs_package_path: &[u8], + package_json: &PackageJSON, + module_type: &mut options::ModuleType, + prev_extension_order: options::ExtOrder, + out: &mut MatchResult, + ) -> bool { + let mut resolve_subpath = subpath; + loop { + let esm_resolution = ESModule { + conditions: match kind { + ast::ImportKind::Require | ast::ImportKind::RequireResolve => { + &self.opts.conditions.require + } + ast::ImportKind::At | ast::ImportKind::AtConditional => { + &self.opts.conditions.style + } + _ => &self.opts.conditions.import, + }, + debug_logs: self.debug_logs.as_mut(), + module_type: &mut *module_type, + } + .resolve(b"/", resolve_subpath, exports_root); + + if self + .handle_esm_resolution( + esm_resolution, + abs_package_path, + kind, + package_json, + subpath, + out, + ) + .is_success() + { + out.is_node_module = true; + out.module_type = *module_type; + self.extension_order = prev_extension_order; + if let Some(d) = self.debug_logs.as_mut() { + d.decrease_indent(); + } + return true; + } + + // Some popular packages forget to include the extension in their + // exports map, so we try again without the extension. + // + // This is useful for browser-like environments + // where you want a file extension in the URL + // pathname by convention. Vite does this. + // + // React is an example of a package that doesn't include file extensions. + // { + // "exports": { + // ".": "./index.js", + // "./jsx-runtime": "./jsx-runtime.js", + // } + // } + // + // We limit this behavior just to ".js" files. + if resolve_subpath.len() < subpath.len() + || bun_paths::extension(subpath) != b".js" + || subpath.len() <= 3 + { + return false; + } + resolve_subpath = &subpath[0..subpath.len() - 3]; + } + } + fn handle_esm_resolution( &mut self, esm_resolution_: crate::package_json::Resolution, diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index 6a1a7b82c97b..5275930586c9 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -2142,97 +2142,51 @@ impl StandaloneModuleGraph { /// Loads the standalone module graph from the executable, allocates it on the heap, /// sets it globally, and returns the pointer. pub fn from_executable() -> crate::Result> { - #[cfg(target_os = "macos")] - { - let Some((base, len)) = macho::get_data() else { - return Ok(None); - }; - if len < size_of::() + TRAILER.len() { - bun_core::debug_warn!("bun standalone module graph is too small to be valid"); - return Ok(None); - } - // SAFETY: `[len - Offsets - TRAILER, len)` is in-bounds (checked above) and - // read-only; build short-lived views via raw `read_unaligned` so no `&[u8]` - // ever spans the writable bytecode region carried in `base`'s provenance. - let offsets_ptr = unsafe { base.add(len - size_of::() - TRAILER.len()) }; - // SAFETY: `[len - TRAILER.len(), len)` is in-bounds (length checked above) and read-only. - let trailer_bytes = unsafe { - core::slice::from_raw_parts(base.add(len - TRAILER.len()), TRAILER.len()) - }; - if trailer_bytes != TRAILER { - bun_core::debug_warn!("bun standalone module graph has invalid trailer"); - return Ok(None); - } - // SAFETY: offsets_ptr has at least size_of::() bytes. - let offsets: Offsets = - unsafe { core::ptr::read_unaligned(offsets_ptr.cast::()) }; - return from_bytes_alloc(base, len, offsets).map(Some); - } - - #[cfg(windows)] - { - let Some((base, len)) = pe::get_data() else { - return Ok(None); - }; - if len < size_of::() + TRAILER.len() { - bun_core::debug_warn!("bun standalone module graph is too small to be valid"); - return Ok(None); + let data = { + #[cfg(target_os = "macos")] + { + macho::get_data() } - // SAFETY: `[len - Offsets - TRAILER, len)` is in-bounds (checked above) and - // read-only; build short-lived views via raw `read_unaligned` so no `&[u8]` - // ever spans the writable bytecode region carried in `base`'s provenance. - let offsets_ptr = unsafe { base.add(len - size_of::() - TRAILER.len()) }; - // SAFETY: `[len - TRAILER.len(), len)` is in-bounds (length checked above) and read-only. - let trailer_bytes = unsafe { - core::slice::from_raw_parts(base.add(len - TRAILER.len()), TRAILER.len()) - }; - if trailer_bytes != TRAILER { - bun_core::debug_warn!("bun standalone module graph has invalid trailer"); - return Ok(None); + #[cfg(windows)] + { + pe::get_data() } - // SAFETY: offsets_ptr has at least size_of::() bytes. - let offsets: Offsets = - unsafe { core::ptr::read_unaligned(offsets_ptr.cast::()) }; - return from_bytes_alloc(base, len, offsets).map(Some); - } - - #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] - { - let Some((base, len)) = elf::get_data() else { - return Ok(None); - }; - if len < size_of::() + TRAILER.len() { - bun_core::debug_warn!("bun standalone module graph is too small to be valid"); - return Ok(None); + #[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))] + { + elf::get_data() } - // SAFETY: `[len - Offsets - TRAILER, len)` is in-bounds (checked above) and - // read-only; build short-lived views via raw `read_unaligned` so no `&[u8]` - // ever spans the writable bytecode region carried in `base`'s provenance. - let offsets_ptr = unsafe { base.add(len - size_of::() - TRAILER.len()) }; - // SAFETY: `[len - TRAILER.len(), len)` is in-bounds (length checked above) and read-only. - let trailer_bytes = unsafe { - core::slice::from_raw_parts(base.add(len - TRAILER.len()), TRAILER.len()) - }; - if trailer_bytes != TRAILER { - bun_core::debug_warn!("bun standalone module graph has invalid trailer"); - return Ok(None); + #[cfg(not(any( + target_os = "macos", + windows, + target_os = "linux", + target_os = "android", + target_os = "freebsd" + )))] + { + unreachable!() } - // SAFETY: offsets_ptr has at least size_of::() bytes. - let offsets: Offsets = - unsafe { core::ptr::read_unaligned(offsets_ptr.cast::()) }; - return from_bytes_alloc(base, len, offsets).map(Some); + }; + let Some((base, len)) = data else { + return Ok(None); + }; + if len < size_of::() + TRAILER.len() { + bun_core::debug_warn!("bun standalone module graph is too small to be valid"); + return Ok(None); } - - #[cfg(not(any( - target_os = "macos", - windows, - target_os = "linux", - target_os = "android", - target_os = "freebsd" - )))] - { - unreachable!() + // SAFETY: `[len - Offsets - TRAILER, len)` is in-bounds (checked above) and + // read-only; build short-lived views via raw `read_unaligned` so no `&[u8]` + // ever spans the writable bytecode region carried in `base`'s provenance. + let offsets_ptr = unsafe { base.add(len - size_of::() - TRAILER.len()) }; + // SAFETY: `[len - TRAILER.len(), len)` is in-bounds (length checked above) and read-only. + let trailer_bytes = + unsafe { core::slice::from_raw_parts(base.add(len - TRAILER.len()), TRAILER.len()) }; + if trailer_bytes != TRAILER { + bun_core::debug_warn!("bun standalone module graph has invalid trailer"); + return Ok(None); } + // SAFETY: offsets_ptr has at least size_of::() bytes. + let offsets: Offsets = unsafe { core::ptr::read_unaligned(offsets_ptr.cast::()) }; + from_bytes_alloc(base, len, offsets).map(Some) } /// Hint to the kernel that the embedded `__BUN`/`.bun` source pages are diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index 8be40eb1b8ae..f3d6c8cb8784 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -1035,6 +1035,25 @@ describe.concurrent("sourcemap boolean values", () => { const jsText = await jsOutput!.text(); expect(jsText).toContain("//# sourceMappingURL=index.js.map"); }); + + test("sourcemap: linked with publicPath prefixes the sourceMappingURL", async () => { + const dir = tempDirWithFiles("sourcemap-linked-public-path", { + "index.js": `console.log("hello");`, + }); + + const build = await Bun.build({ + entrypoints: [join(dir, "index.js")], + outdir: join(dir, "out"), + sourcemap: "linked", + publicPath: "https://cdn.example.com/assets/", + }); + + expect(build.success).toBe(true); + + const jsOutput = build.outputs.find(o => o.kind === "entry-point"); + const jsText = await jsOutput!.text(); + expect(jsText).toContain("//# sourceMappingURL=https://cdn.example.com/assets/index.js.map\n"); + }); }); describe.concurrent("sourcemap positions", () => { diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index 7fe388d3a623..91f23214d946 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -436,6 +436,61 @@ describe("bundler", () => { stdout: "123", }, }); + itBundled("edgecase/PackageExportsJSExtensionRetry", { + // A ".js" subpath missing from the exports map is retried without the + // extension, for packages (like React) that omit extensions in "exports". + files: { + "/entry.js": /* js */ ` + import value from 'boop/jsx-runtime.js' + console.log(value) + `, + "/node_modules/boop/package.json": /* json */ ` + { + "name": "boop", + "exports": { + ".": "./index.js", + "./jsx-runtime": "./lib/jsx-runtime.js" + } + } + `, + "/node_modules/boop/index.js": /* js */ ` + export default "index" + `, + "/node_modules/boop/lib/jsx-runtime.js": /* js */ ` + export default 456 + `, + }, + run: { + stdout: "456", + }, + }); + itBundled("edgecase/PackageExportsExtensionRetryOnlyForJS", { + // The extensionless retry is limited to ".js"; other extensions miss. + files: { + "/entry.js": /* js */ ` + import value from 'boop/util.mjs' + console.log(value) + `, + "/node_modules/boop/package.json": /* json */ ` + { + "name": "boop", + "exports": { + ".": "./index.js", + "./util": "./lib/util.mjs" + } + } + `, + "/node_modules/boop/index.js": /* js */ ` + export default "index" + `, + "/node_modules/boop/lib/util.mjs": /* js */ ` + export default 789 + `, + }, + bundleErrors: { + "/entry.js": ['Could not resolve: "boop/util.mjs". Maybe you need to "bun install"?'], + }, + }); itBundled("edgecase/TSConfigPathsStarOnlyInLeft", { files: { "/entry.ts": /* ts */ ` diff --git a/test/bundler/bundler_string.test.ts b/test/bundler/bundler_string.test.ts index 88efba7780bc..b6a3315255ce 100644 --- a/test/bundler/bundler_string.test.ts +++ b/test/bundler/bundler_string.test.ts @@ -80,6 +80,9 @@ const templateStringTests: Record = { FoldNested6: { expr: "`a\0${5}c\\${{$${`d`}e`", print: true }, EscapedDollar: { expr: "`\\${'a'}`", captureRaw: "\"${'a'}\"" }, EscapedDollar2: { expr: "`\\${'a'}\\${'b'}`", captureRaw: "\"${'a'}${'b'}\"" }, + // non-ASCII content forces the UTF-16 printer path; "$" before "{" must + // stay escaped when re-printed into a template literal + EscapedDollarUnicode: { expr: "`\u2796\\${${ident}`", captureRaw: "`\u2796\\${${ident}`" }, StringAddition: { expr: "`${1}\u2796` + 'rest'", print: true }, StringAddition2: { expr: "`\u2796${1}` + `a${Number(1)}b`", print: true }, StringAddition3: { expr: '`0${"\u2796"}` + `a${Number(1)}b`', print: true },