From acd29933420ad83c11a91ac4977d3ee776febee9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:20:30 +0000 Subject: [PATCH 01/22] bundler: make the "browser" field behave like esbuild A module disabled with `"browser": {"./x": false}` printed as `(() => ({}))`, so `require("x")` returned a function. A disabled import is now an empty CommonJS module, `(disabled):`, so `require` returns `{}` and a star or default import gets a namespace object. A named import warns that it is always undefined. The resolver now checks a package's "browser" map for a subpath import (`require("pkg/foo/bar")`) before the file lookup, against the extensionless path, so `"./foo/bar"` matches whether or not `foo/bar.js` exists. Keys are kept as written instead of having `./` stripped. The Browserify bare-name rule (`"./sub"` matching `require("sub")`) is checked relative to the importer's directory inside the package and without implicit extensions. Externals are checked before the browser polyfills, so `--external=fs` keeps `fs` external in a browser build. The post-resolution browser-map check in `check_package_path` is removed. It replaced a disabled result with an empty path ("File not found"). Tests port esbuild's browser-field end-to-end matrix and enable the six browser tests that were marked todo. --- src/bundler/LinkerContext.rs | 17 +- src/bundler/PathToSourceIndexMap.rs | 18 +- src/bundler/bundle_v2.rs | 91 ++- .../linker_context/scanImportsAndExports.rs | 9 + src/js_printer/lib.rs | 4 +- src/resolver/dir_info.rs | 11 +- src/resolver/package_json.rs | 20 +- src/resolver/resolver.rs | 534 ++++++++++-------- src/resolver/result.rs | 3 - test/bundler/bundler_browser.test.ts | 25 +- test/bundler/esbuild/default.test.ts | 4 +- test/bundler/esbuild/extra.test.ts | 280 +++++++++ test/bundler/esbuild/packagejson.test.ts | 12 +- 13 files changed, 733 insertions(+), 295 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 22ee8a45425f..92d521693b86 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -3618,9 +3618,20 @@ impl<'a> LinkerContext<'a> { } } - // Warn about importing from a file that is known to not have any exports - if status == ImportTrackerStatus::CjsWithoutExports { + // Warn about importing from a file that is known to not have any exports. + // A disabled module is empty too, but its default import is the `{}` + // exports object, so only a named import reads `undefined`. + let is_always_undefined = match status { + ImportTrackerStatus::CjsWithoutExports => true, + ImportTrackerStatus::Disabled => { + !named_import.alias_is_star + && named_import.alias.is_some_and(|a| a.slice() != b"default") + } + _ => false, + }; + if is_always_undefined { let source = self.get_source(tracker.source_index.get()); + let imported = self.get_source(next_tracker.source_index.get()); // SAFETY: `alias` is an arena `*const [u8]` valid for the link pass. let alias = named_import .alias @@ -3634,7 +3645,7 @@ impl<'a> LinkerContext<'a> { format_args!( "Import \"{}\" will always be undefined because the file \"{}\" has no exports", bstr::BStr::new(alias), - bstr::BStr::new(&source.path.pretty), + bstr::BStr::new(&imported.path.pretty), ), ); } diff --git a/src/bundler/PathToSourceIndexMap.rs b/src/bundler/PathToSourceIndexMap.rs index f0e42df43810..1f792dffbcce 100644 --- a/src/bundler/PathToSourceIndexMap.rs +++ b/src/bundler/PathToSourceIndexMap.rs @@ -4,9 +4,14 @@ use crate::IndexStringMap::IndexInt; /// Abstracts over the two structurally-identical `Path` ports (`bun_paths::fs::Path` /// and `bun_resolver::fs::Path`) so the bundler can key the map with either while -/// the crates converge. Both expose `.text: &[u8]`, which is all we need. +/// the crates converge. pub trait PathLike { fn path_text(&self) -> &[u8]; + + /// The key this module is filed under. A module the "browser" field + /// disabled is keyed by its `(disabled):...` pretty path: the same file can + /// also be in the graph enabled, and the two must stay separate modules. + fn source_key(&self) -> &[u8]; } // `bun_resolver::fs::Path` is now a re-export of `bun_paths::fs::Path` (D090), @@ -16,6 +21,15 @@ impl PathLike for bun_paths::fs::Path<'_> { fn path_text(&self) -> &[u8] { self.text } + + #[inline] + fn source_key(&self) -> &[u8] { + if self.is_disabled { + self.pretty + } else { + self.text + } + } } /// The lifetime of the keys are not owned by this map. @@ -34,7 +48,7 @@ pub(crate) type GetOrPutResult<'a> = bun_collections::string_hash_map::GetOrPutR impl PathToSourceIndexMap { pub(crate) fn get_path(&self, path: &impl PathLike) -> Option { - self.get(path.path_text()) + self.get(path.source_key()) } pub(crate) fn get(&self, text: impl AsRef<[u8]>) -> Option { diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 0413d3fc5f31..373602a9fcec 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -1357,7 +1357,7 @@ pub mod bv2_impl { use crate::DeferredBatchTask::DeferredBatchTask; use crate::Graph::Graph; use crate::LinkerContext; - use crate::PathToSourceIndexMap::PathToSourceIndexMap; + use crate::PathToSourceIndexMap::{PathLike as _, PathToSourceIndexMap}; use crate::ServerComponentParseTask::ServerComponentParseTask; use crate::barrel_imports; @@ -5961,6 +5961,47 @@ pub mod bv2_impl { Ok(out) } + /// The pretty path of a module the "browser" field disabled: + /// `(disabled):`, the spelling esbuild uses. + /// `text` stays the resolved path (or the bare specifier when nothing + /// resolved), so the loader and the debugger still see where it came from. + fn disabled_path_with_pretty( + &self, + path: &Fs::Path<'static>, + ) -> Result, Error> { + use crate::bun_fs::PathResolverExt as _; + use bun_io::Write as _; + + debug_assert!(path.is_disabled); + // SAFETY: arena outlives the bundle pass; see `path_with_pretty_initialized`. + let bump: &'static bun_alloc::Arena = + unsafe { bun_ptr::detach_lifetime_ref::(self.arena()) }; + + let mut rel_buf = bun_paths::path_buffer_pool::get(); + let rel: &[u8] = if path.is_file() && bun_paths::is_absolute(path.text) { + bun_paths::resolve_path::relative_platform_buf::< + bun_paths::resolve_path::platform::Loose, + false, + >( + &mut **rel_buf, + self.transpiler.fs().top_level_dir, + path.text, + ) + } else { + path.text + }; + + let mut pretty_buf = bun_paths::path_buffer_pool::get(); + let mut fbs = bun_io::FixedBufferStream::new_mut(&mut pretty_buf.0[..]); + let _ = fbs.write_all(b"(disabled):"); + let _ = fbs.write_all(rel); + let written = fbs.pos; + + let mut with_pretty: Fs::Path<'_> = *path; + with_pretty.pretty = &pretty_buf.0[..written]; + with_pretty.dupe_alloc_fix_pretty(bump).map_err(Into::into) + } + fn reserve_source_indexes_for_bake(&mut self) -> Result<(), Error> { let Some(fw) = &self.framework else { return Ok(()); @@ -6615,11 +6656,25 @@ pub mod bv2_impl { // SAFETY: `resolve_result` outlives this borrow; see note above. Some(p) => unsafe { bun_ptr::detach_lifetime_mut::(p) }, None => { - import_record.path.is_disabled = true; - import_record.source_index = Index::INVALID; - continue; + // The "browser" field disabled this import (or it is a Node.js + // builtin with no browser polyfill). Like esbuild, it becomes an + // empty CommonJS module, so `require()` returns one `{}` and an + // `import` gets a namespace object. The DevServer keeps the + // external form, which its module runtime handles itself. + if self.dev_server.is_some() { + import_record.path.is_disabled = true; + import_record.source_index = Index::INVALID; + continue; + } + // SAFETY: same as the `Some` arm. + unsafe { + bun_ptr::detach_lifetime_mut::( + &mut resolve_result.path_pair.primary, + ) + } } }; + let is_disabled = path.is_disabled; if resolve_result.flags.is_external() { if resolve_result.flags.external_kind() @@ -6718,6 +6773,10 @@ pub mod bv2_impl { } let import_record_loader = 'brk: { + if is_disabled { + // Empty module, whatever the extension (or lack of one) says. + break 'brk Loader::Js; + } let resolved_loader = import_record.loader.unwrap_or_else(|| { path.loader(&transpiler.options.loaders) .unwrap_or(Loader::File) @@ -6744,7 +6803,14 @@ pub mod bv2_impl { && target.is_server_side() && self.dev_server.is_none(); - if let Some(id) = self.path_to_source_index_map(target).get(path.text) { + // A disabled module gets the pretty path esbuild uses, + // "(disabled):", which is also its `source_key()`. + if is_disabled { + *path = self.disabled_path_with_pretty(path).expect("oom"); + } + let source_key: &[u8] = path.source_key(); + + if let Some(id) = self.path_to_source_index_map(target).get(source_key) { if self.dev_server.is_some() && loader != Loader::Html { import_record.path = self.graph.input_files.items_source()[id as usize].path; @@ -6758,7 +6824,7 @@ pub mod bv2_impl { import_record.kind = ImportKind::HtmlManifest; } - let resolve_entry = resolve_queue.get_or_put(path.text).expect("oom"); + let resolve_entry = resolve_queue.get_or_put(source_key).expect("oom"); if resolve_entry.found_existing { // SAFETY: arena-allocated `ParseTask` stored in the queue; arena outlives the pass. import_record.path = @@ -6766,9 +6832,11 @@ pub mod bv2_impl { continue; } - *path = self - .path_with_pretty_initialized(path, target) - .expect("oom"); + if !is_disabled { + *path = self + .path_with_pretty_initialized(path, target) + .expect("oom"); + } import_record.path = path_as_static(path); // key already interned by get_or_put — no key_ptr on StringHashMapGetOrPut @@ -6789,6 +6857,11 @@ pub mod bv2_impl { resolve_task.jsx.development = transpiler.options.forced_jsx_development(); resolve_task.loader = Some(import_record_loader); + if is_disabled { + // Nothing is read from disk: the path may not exist, and when it + // does, its contents are exactly what the "browser" field excludes. + resolve_task.contents_or_fd = parse_task::ContentsOrFd::Contents(b""); + } *resolve_entry.value_ptr = resolve_task; if let Some(secondary) = &resolve_result.path_pair.secondary { if !secondary.is_disabled diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index 8250a498a1ed..4a164653333e 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -178,6 +178,15 @@ pub(crate) fn scan_imports_and_exports( match record.kind { ImportKind::Stmt => { + // A module the "browser" field disabled is an empty CommonJS + // module whatever the import syntax: star and default imports get + // the `__toESM` namespace, named imports read `undefined` off it. + if col_ref!(input_files)[other_file].path.is_disabled { + col!(exports_kind)[other_file] = ExportsKind::Cjs; + col!(flags)[other_file].wrap = WrapKind::Cjs; + continue; + } + // Importing using ES6 syntax from a file without any ES6 syntax // causes that module to be considered CommonJS-style, even if it // doesn't have any CommonJS exports. diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index f6aed3a8f7bf..d7ec385bd32e 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -6361,9 +6361,11 @@ pub(crate) mod __gated_printer { } } + /// A disabled module evaluates to an empty object, like the empty module + /// esbuild, webpack and Browserify substitute for `"browser": {"x": false}`. #[inline] fn print_disabled_import(&mut self) { - self.print_whitespacer(ws!(b"(() => ({}))")); + self.print_whitespacer(ws!(b"({})")); } pub(crate) fn print_for_loop_init(&mut self, init_st: Stmt, extra_flags: ExprFlagSet) { diff --git a/src/resolver/dir_info.rs b/src/resolver/dir_info.rs index 55503cf064d4..a066fea7aef7 100644 --- a/src/resolver/dir_info.rs +++ b/src/resolver/dir_info.rs @@ -28,7 +28,7 @@ pub type Index = IndexType; // the resolver mutex. // // `as_ptr()` exposes the raw `*mut` for the few callers that still need it -// (the `dir_info_uncached` fill path and `MatchResult.dir_info` round-trip). +// (the `dir_info_uncached` fill path). // ───────────────────────────────────────────────────────────────────────────── /// Non-owning, `Copy` handle to a `DirInfo` slot in the BSSMap singleton. @@ -91,6 +91,15 @@ impl core::fmt::Debug for DirInfoRef { } } +/// Two handles are equal when they name the same slot. +impl PartialEq for DirInfoRef { + fn eq(&self, other: &Self) -> bool { + core::ptr::eq(self.0.as_ptr(), other.0.as_ptr()) + } +} + +impl Eq for DirInfoRef {} + pub struct DirInfo { // These objects are immutable, so we can just point to the parent directory // and avoid having to lock the cache again diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index 79d736cc7f42..d64addd0baa3 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -598,22 +598,12 @@ impl PackageJSON { if let js_ast::ExprData::EObjectJSON(obj) = &browser_prop.expr.data { // The value is an object - // Remap all files in the browser field - let mut key_spill: Vec = Vec::new(); + // Remap all files in the browser field. Keys are stored as written: + // "./foo" and "foo" are different keys, and `check_browser_map` + // decides which spellings an import may match (Browserify lets + // "./foo" match `require("foo")`, but not "./foo.js"). for prop in obj.get().properties() { - // Normalize the path so we can compare against it without getting - // confused by "./". There is no distinction between package paths and - // relative paths for these values because some tools (i.e. Browserify) - // don't make such a distinction. - // - // This leads to weird things like a mapping for "./foo" matching an - // import of "foo", but that's actually not a bug. Or arguably it's a - // bug in Browserify but we have to replicate this bug because packages - // do this in the wild. - let key: &[u8] = resolve_path::resolve_path::normalize_string_spill::< - true, - resolve_path::platform::Auto, - >(&mut key_spill, prop.key.slice()); + let key: &[u8] = prop.key.slice(); match &prop.value { js_ast::E::JsonValue::String(str) => { diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 563403e7caee..af6a0b96db5c 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -117,16 +117,6 @@ mod bun_paths { pub(super) fn join(parts: &[&[u8]], platform: Platform) -> &'static [u8] { dispatch_platform!(platform, |P| ::bun_paths::resolve_path::join::

(parts)) } - pub(super) fn join_string_buf<'b>( - buf: &'b mut [u8], - parts: &[&[u8]], - platform: Platform, - ) -> &'b [u8] { - dispatch_platform!( - platform, - |P| ::bun_paths::resolve_path::join_string_buf::

(buf, parts) - ) - } /// Compile-time platform-separator literal (`/` → `\` on Windows). A /// const fn can't transform a borrowed `&'static [u8]`, so this is a /// macro that emits a fresh const array with the swap applied. Result is @@ -1886,6 +1876,40 @@ impl<'a> Resolver<'a> { } if check_package { + // Check for external packages first: a package the user marked external is + // never replaced by a browser polyfill or disabled builtin. + if self.opts.external.node_modules.count() > 0 + // Imports like "process/" need to resolve to the filesystem, not a builtin + && !import_path.ends_with(b"/") + { + let mut query = import_path; + loop { + if self.opts.external.node_modules.contains(query) { + if let Some(debug) = self.debug_logs.as_mut() { + debug.add_note_fmt(format_args!( + "The path \"{}\" was marked as external by the user", + bstr::BStr::new(query) + )); + } + return ResultUnion::Success(Result { + path_pair: PathPair { + primary: Path::init(query), + secondary: None, + }, + flags: ResultFlags::IS_EXTERNAL, + ..Default::default() + }); + } + + // If the module "foo" has been marked as external, we also want to treat + // paths into that module such as "foo/bar" as external too. + let Some(slash) = strings::last_index_of_char(query, b'/') else { + break; + }; + query = &query[0..slash]; + } + } + if self.opts.polyfill_node_globals { let had_node_prefix = import_path.starts_with(b"node:"); let import_path_without_node_prefix: &'static [u8] = if had_node_prefix { @@ -1899,15 +1923,12 @@ impl<'a> Resolver<'a> { // spellings the polyfill table below does. let browser_map_overrides_builtin = self.care_about_browser_field && matches!(self.dir_info_cached(source_dir), Ok(Some(info)) - if info - .get_enclosing_browser_scope() - .is_some_and(|scope| { - self.check_browser_map::<{ BrowserMapPathKind::PackagePath }>( - &scope, - import_path_without_node_prefix, - ) - .is_some() - })); + if self + .check_browser_map::<{ BrowserMapPathKind::PackagePath }>( + info, + import_path_without_node_prefix, + ) + .is_some()); if browser_map_overrides_builtin { // check_package_path re-runs this lookup; give it the bare name. @@ -1964,39 +1985,6 @@ impl<'a> Resolver<'a> { } } - // Check for external packages first - if self.opts.external.node_modules.count() > 0 - // Imports like "process/" need to resolve to the filesystem, not a builtin - && !import_path.ends_with(b"/") - { - let mut query = import_path; - loop { - if self.opts.external.node_modules.contains(query) { - if let Some(debug) = self.debug_logs.as_mut() { - debug.add_note_fmt(format_args!( - "The path \"{}\" was marked as external by the user", - bstr::BStr::new(query) - )); - } - return ResultUnion::Success(Result { - path_pair: PathPair { - primary: Path::init(query), - secondary: None, - }, - flags: ResultFlags::IS_EXTERNAL, - ..Default::default() - }); - } - - // If the module "foo" has been marked as external, we also want to treat - // paths into that module such as "foo/bar" as external too. - let Some(slash) = strings::last_index_of_char(query, b'/') else { - break; - }; - query = &query[0..slash]; - } - } - if let Some(custom_paths) = self.custom_dir_paths { bun_core::hint::cold(); for custom_path in custom_paths { @@ -2096,7 +2084,7 @@ impl<'a> Resolver<'a> { let pkg = import_dir_info.package_json().unwrap(); if let Some(remap) = self .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( - &import_dir_info, + import_dir_info_outer, abs_path, ) { @@ -2249,7 +2237,7 @@ impl<'a> Resolver<'a> { if let Some(package_json) = browser_scope.package_json() { if let Some(remapped) = self .check_browser_map::<{ BrowserMapPathKind::PackagePath }>( - &browser_scope, + source_dir_info, import_path, ) { @@ -2337,66 +2325,6 @@ impl<'a> Resolver<'a> { ExternalKind::NotExternal }); - if result.path_pair.primary.is_disabled && result.path_pair.secondary.is_none() { - return ResultUnion::Success(result); - } - - if res.package_json.is_some() && self.care_about_browser_field { - let base_dir_info = match res.dir_info { - Some(d) => d, - None => match self.read_dir_info(result.path_pair.primary.name().dir) { - Ok(Some(d)) => d, - _ => return ResultUnion::Success(result), - }, - }; - if let Some(browser_scope) = base_dir_info.get_enclosing_browser_scope() { - if let Some(remap) = self - .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( - &browser_scope, - result.path_pair.primary.text(), - ) - { - if remap.is_empty() { - result.path_pair.primary.is_disabled = true; - result.path_pair.primary = - Fs::Path::init_with_namespace(remap, b"file"); - } else { - let mut remapped = MatchResult::default(); - if self - .resolve_without_remapping( - browser_scope, - remap, - kind, - global_cache, - &mut remapped, - ) - .is_success() - { - result.path_pair = remapped.path_pair; - result.dirname_fd = remapped.dirname_fd; - result.file_fd = remapped.file_fd; - result.package_json = remapped.package_json; - result.module_type = remapped.module_type; - - // Potentially rewrite the import path if it's external that - // was remapped to a different path - result.flags.set_external_kind(if remapped.is_external { - ExternalKind::ExternalRewritePath - } else { - ExternalKind::NotExternal - }); - - result.flags.set_is_from_node_modules( - result.flags.is_from_node_modules() - || remapped.is_node_module, - ); - return ResultUnion::Success(result); - } - } - } - } - } - ResultUnion::Success(result) } MatchStatus::Pending(p) => ResultUnion::Pending(*p), @@ -2789,7 +2717,6 @@ impl<'a> Resolver<'a> { ) .is_node_module(), package_json: Some(std::ptr::from_ref(package_json)), - dir_info: Some(dir_info), ..Default::default() }; return MatchStatus::Success; @@ -2802,6 +2729,25 @@ impl<'a> Resolver<'a> { return MatchStatus::NotFound; } } + + // Check the "browser" map + if self.care_about_browser_field + && self + .load_package_subpath_from_browser_map( + pkg_dir_info, + abs_path, + kind, + global_cache, + out, + ) + .is_success() + { + self.extension_order = prev_extension_order; + if let Some(d) = self.debug_logs.as_mut() { + d.decrease_indent(); + } + return MatchStatus::Success; + } } } @@ -3257,7 +3203,6 @@ impl<'a> Resolver<'a> { .path .is_node_module(), package_json: Some(std::ptr::from_ref(package_json)), - dir_info: Some(dir_info), ..Default::default() }; return MatchStatus::Success; @@ -3804,7 +3749,6 @@ impl<'a> Resolver<'a> { }, dirname_fd, file_fd: entry_query.entry().cache().fd, - dir_info: Some(resolved_dir_info), is_node_module: true, package_json: Some( resolved_dir_info @@ -3980,7 +3924,6 @@ impl<'a> Resolver<'a> { }, dirname_fd, file_fd: query.entry().cache().fd, - dir_info: Some(resolved_dir_info), is_node_module: true, package_json: Some( resolved_dir_info @@ -5066,92 +5009,148 @@ impl<'a> Resolver<'a> { ) } + /// A package subpath (`require("pkg/foo/bar")`) is checked against the + /// package's "browser" map before node's file and directory lookup. + /// `abs_path` is the subpath joined onto the package directory, without an + /// implicit extension, so a key like "./foo/bar" matches even when + /// "foo/bar.js" does not exist. + fn load_package_subpath_from_browser_map( + &mut self, + pkg_dir_info: DirInfoRef, + abs_path: &[u8], + kind: ast::ImportKind, + global_cache: GlobalCache, + out: &mut MatchResult, + ) -> MatchStatus { + let Some(remap) = + self.check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>(pkg_dir_info, abs_path) + else { + return MatchStatus::NotFound; + }; + let browser_scope = pkg_dir_info + .get_enclosing_browser_scope() + .expect("check_browser_map matched, so a browser scope exists"); + + if remap.is_empty() { + let mut path = Path::init( + self.fs_ref() + .dirname_store + .append_slice(abs_path) + .expect("oom"), + ); + path.is_disabled = true; + *out = MatchResult { + path_pair: PathPair { + primary: path, + secondary: None, + }, + package_json: browser_scope.package_json().map(std::ptr::from_ref), + ..Default::default() + }; + return MatchStatus::Success; + } + + self.resolve_without_remapping(browser_scope, remap, kind, global_cache, out) + } + + /// Looks `input_path` up in the "browser" map of the package that encloses + /// `resolve_dir_info`. For `PackagePath`, `resolve_dir_info` is the importer's + /// directory and `input_path` the specifier as written. For `AbsolutePath`, + /// `input_path` is the absolute path that is about to be loaded (without + /// implicit extensions). + /// + /// Returns the remap target relative to the browser scope, or an empty slice + /// when the entry is `false` (the module is disabled). pub(crate) fn check_browser_map( &mut self, - dir_info: &DirInfo::DirInfo, + resolve_dir_info: DirInfoRef, input_path_: &[u8], ) -> Option<&'static [u8]> { - let package_json = dir_info.package_json()?; - let browser_map = &package_json.browser_map; + let browser_scope = resolve_dir_info.get_enclosing_browser_scope()?; + let package_json = browser_scope.package_json()?; - if browser_map.count() == 0 { + if package_json.browser_map.count() == 0 { return None; } - let mut input_path = input_path_; - - if KIND == BrowserMapPathKind::AbsolutePath { - let abs_path = dir_info.abs_path; + let input_path: &[u8] = match KIND { // Turn absolute paths into paths relative to the "browser" map location - if !input_path.starts_with(abs_path) { - return None; - } - - input_path = &input_path[abs_path.len()..]; - } - - if input_path.is_empty() - || (input_path.len() == 1 && (input_path[0] == b'.' || input_path[0] == SEP)) - { - // No bundler supports remapping ".", so we don't either - return None; - } - - // Normalize the path so we can compare against it without getting confused by "./" - let cleaned = self - .fs_ref() - .normalize_buf(bufs!(check_browser_map), input_path); + BrowserMapPathKind::AbsolutePath => browser_map_relative_path( + browser_scope.abs_path, + input_path_, + bufs!(check_browser_map), + )?, + BrowserMapPathKind::PackagePath => input_path_, + }; - if cleaned.len() == 1 && cleaned[0] == b'.' { + if input_path.is_empty() || input_path == b"." { // No bundler supports remapping ".", so we don't either return None; } let mut checker = BrowserMapPath { remapped: b"", - cleaned, - input_path, extension_order: self.opts.ext_order_slice(self.extension_order), map: &package_json.browser_map, }; - if checker.check_path(input_path) { + // First try the import path as a package path + if checker.check_path(input_path, ImplicitExtensions::Include) { return Some(checker.remapped); } - // First try the import path as a package path - if is_package_path(checker.input_path) { - let abs_to_rel = bufs!(abs_to_rel); - match KIND { - BrowserMapPathKind::AbsolutePath => { - abs_to_rel[0..2].copy_from_slice(b"./"); - abs_to_rel[2..2 + checker.input_path.len()].copy_from_slice(checker.input_path); - if checker.check_path(&abs_to_rel[0..checker.input_path.len() + 2]) { - return Some(checker.remapped); + if !is_package_path(input_path) { + return None; + } + + // If a package path didn't work, try the import path as a relative path + let buf = bufs!(abs_to_rel); + match KIND { + BrowserMapPathKind::AbsolutePath => { + let candidate = concat_into(buf, &[b"./", input_path])?; + if checker.check_path(candidate, ImplicitExtensions::Include) { + return Some(checker.remapped); + } + } + BrowserMapPathKind::PackagePath => { + // Browserify allows a browser map entry of "./pkg" to override a package + // path of "require('pkg')". This is weird, and arguably a bug. But we + // replicate this bug for compatibility. However, Browserify only allows + // this within the same package. It does not allow such an entry in a + // parent package to override this in a child package. So this behavior + // is disallowed if there is a "node_modules" folder in between the child + // package and the parent package. + let mut info = Some(resolve_dir_info); + while let Some(dir) = info { + if dir == browser_scope { + break; } + if dir.is_node_modules() { + return None; + } + info = dir.get_parent(); } - BrowserMapPathKind::PackagePath => { - // Browserify allows a browser map entry of "./pkg" to override a package - // path of "require('pkg')". This is weird, and arguably a bug. But we - // replicate this bug for compatibility. However, Browserify only allows - // this within the same package. It does not allow such an entry in a - // parent package to override this in a child package. So this behavior - // is disallowed if there is a "node_modules" folder in between the child - // package and the parent package. - let is_in_same_package = match dir_info.get_parent() { - Some(parent) => !parent.is_node_modules(), - None => true, - }; - if is_in_same_package { - abs_to_rel[0..2].copy_from_slice(b"./"); - abs_to_rel[2..2 + checker.input_path.len()] - .copy_from_slice(checker.input_path); + // Use the relative path from the file containing the import path to the + // enclosing package.json file. This includes any subdirectories within the + // package if there are any. + let mut rel_buf = bun_paths::path_buffer_pool::get(); + let rel_dir = browser_map_relative_path( + browser_scope.abs_path, + resolve_dir_info.abs_path, + &mut **rel_buf, + )?; + let candidate = if rel_dir.is_empty() { + concat_into(buf, &[b"./", input_path])? + } else { + concat_into(buf, &[b"./", rel_dir, b"/", input_path])? + }; - if checker.check_path(&abs_to_rel[0..checker.input_path.len() + 2]) { - return Some(checker.remapped); - } - } + // Browserify lets "require('pkg')" match "./pkg" but not "./pkg.js". + // So don't add implicit extensions specifically in this place so we + // match Browserify's behavior. + if checker.check_path(candidate, ImplicitExtensions::Skip) { + return Some(checker.remapped); } } } @@ -5201,7 +5200,7 @@ impl<'a> Resolver<'a> { if let Some(browser_json) = browser_scope.package_json() { if let Some(remap) = self .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( - &browser_scope, + dir_info, field_abs_path, ) { @@ -5452,7 +5451,7 @@ impl<'a> Resolver<'a> { let index_abs_path = self.fs_ref().abs_buf(&index_paths, bufs!(remap_path)); if let Some(remap) = self .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( - &browser_scope, + dir_info, index_abs_path, ) { @@ -6648,99 +6647,140 @@ pub enum BrowserMapPathKind { AbsolutePath, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum ImplicitExtensions { + Include, + Skip, +} + pub(crate) struct BrowserMapPath<'b> { pub(crate) remapped: &'static [u8], - pub(crate) cleaned: &'b [u8], - pub(crate) input_path: &'b [u8], pub(crate) extension_order: &'b [Box<[u8]>], pub(crate) map: &'b BrowserMap, } impl<'b> BrowserMapPath<'b> { - /// On a match only `self.remapped` is updated; the matched candidate may - /// borrow threadlocal scratch buffers and must never be stored back into - /// the checker. - pub(crate) fn check_path(&mut self, path_to_check: &[u8]) -> bool { - let map = self.map; + /// Looks up one key. On a match only `self.remapped` is updated; the + /// candidate may borrow threadlocal scratch buffers and must never be + /// stored back into the checker. + fn lookup(&mut self, key: &[u8]) -> bool { + let Some(result) = self.map.get(key) else { + return false; + }; + // SAFETY: ARENA — `BrowserMap` values are `Box<[u8]>` owned by a `'static` + // PackageJSON (allocated in `parse_package_json`, never freed — DirInfo + // cache is process-global); the `'b` borrow on `map` artificially shortens + // what is process-lifetime storage. `Interned` is the canonical proof type. + self.remapped = unsafe { bun_ptr::Interned::assume(result) }.as_bytes(); + true + } + + /// `path` followed by each implicit extension, in order. + fn lookup_with_extensions(&mut self, path: &[u8], buf: &mut [u8]) -> bool { + if path.len() >= buf.len() { + return false; + } + buf[..path.len()].copy_from_slice(path); + let extension_order = self.extension_order; + for ext in extension_order { + let ext: &[u8] = ext; + let len = path.len() + ext.len(); + if len > buf.len() { + continue; + } + buf[path.len()..len].copy_from_slice(ext); + if self.lookup(&buf[..len]) { + return true; + } + } + false + } - let cleaned = self.cleaned; + /// Probes the map the way Browserify and esbuild do: the path as written, + /// the path with an implicit extension, the path as a directory with an + /// "index" file, and that index with an implicit extension. + pub(crate) fn check_path( + &mut self, + path_to_check: &[u8], + implicit_extensions: ImplicitExtensions, + ) -> bool { // Check for equality - if let Some(result) = map.get(path_to_check) { - // SAFETY: ARENA — `BrowserMap` values are `Box<[u8]>` owned by a `'static` - // PackageJSON (allocated in `parse_package_json`, never freed — DirInfo - // cache is process-global); the `'b` borrow on `map` artificially shortens - // what is process-lifetime storage. `Interned` is the canonical proof type. - self.remapped = unsafe { bun_ptr::Interned::assume(result) }.as_bytes(); + if self.lookup(path_to_check) { return true; } let ext_buf = bufs!(extension_path); - if cleaned.len() <= ext_buf.len() { - ext_buf[..cleaned.len()].copy_from_slice(cleaned); - - // If that failed, try adding implicit extensions - for ext in self.extension_order.iter() { - let ext: &[u8] = ext; - if cleaned.len() + ext.len() > ext_buf.len() { - continue; - } - ext_buf[cleaned.len()..cleaned.len() + ext.len()].copy_from_slice(ext); - let new_path = &ext_buf[0..cleaned.len() + ext.len()]; - // if let Some(debug) = r.debug_logs.as_mut() { - // debug.add_note_fmt(format_args!("Checking for \"{}\" ", bstr::BStr::new(new_path))); - // } - if let Some(_remapped) = map.get(new_path) { - // SAFETY: ARENA — see `result` note above. - self.remapped = unsafe { bun_ptr::Interned::assume(_remapped) }.as_bytes(); - return true; - } - } + // If that failed, try adding implicit extensions + if implicit_extensions == ImplicitExtensions::Include + && self.lookup_with_extensions(path_to_check, ext_buf) + { + return true; } // If that failed, try assuming this is a directory and looking for an "index" file - - let index_path: &[u8] = { - let trimmed = strings::trim_right(path_to_check, &[SEP]); - let parts = [ - trimmed, - const_format::concatcp!(SEP_STR, "index").as_bytes(), - ]; - ResolvePath::join_string_buf( - bufs!(tsconfig_base_url), - &parts, - bun_paths::Platform::AUTO, - ) + let index_buf = bufs!(tsconfig_base_url); + let trimmed = strings::trim_right(path_to_check, b"/"); + let Some(index_path) = (if trimmed.is_empty() || trimmed == b"." { + concat_into(index_buf, &[b"./index"]) + } else { + concat_into(index_buf, &[trimmed, b"/index"]) + }) else { + return false; }; - if let Some(_remapped) = map.get(index_path) { - // SAFETY: ARENA — see `result` note above. - self.remapped = unsafe { bun_ptr::Interned::assume(_remapped) }.as_bytes(); + if self.lookup(index_path) { return true; } - if index_path.len() <= ext_buf.len() { - ext_buf[..index_path.len()].copy_from_slice(index_path); + // If that failed, try adding implicit extensions + implicit_extensions == ImplicitExtensions::Include + && self.lookup_with_extensions(index_path, ext_buf) + } +} - for ext in self.extension_order.iter() { - let ext: &[u8] = ext; - if index_path.len() + ext.len() > ext_buf.len() { - continue; - } - ext_buf[index_path.len()..index_path.len() + ext.len()].copy_from_slice(ext); - let new_path = &ext_buf[0..index_path.len() + ext.len()]; - // if let Some(debug) = r.debug_logs.as_mut() { - // debug.add_note_fmt(format_args!("Checking for \"{}\" ", bstr::BStr::new(new_path))); - // } - if let Some(_remapped) = map.get(new_path) { - // SAFETY: ARENA — see `result` note above. - self.remapped = unsafe { bun_ptr::Interned::assume(_remapped) }.as_bytes(); - return true; - } - } +/// Writes `parts` back to back into `buf`. `None` when they do not fit. +fn concat_into<'a>(buf: &'a mut [u8], parts: &[&[u8]]) -> Option<&'a [u8]> { + let mut len = 0usize; + for part in parts { + let end = len.checked_add(part.len())?; + if end > buf.len() { + return None; } + buf[len..end].copy_from_slice(part); + len = end; + } + Some(&buf[..len]) +} - false +/// `path` relative to the directory `base`, with forward slashes and no +/// trailing slash, so it can be compared against "browser" map keys. Empty when +/// `path` is `base` itself. `None` when `path` is not inside `base`. +fn browser_map_relative_path<'a>(base: &[u8], path: &[u8], buf: &'a mut [u8]) -> Option<&'a [u8]> { + if !path.starts_with(base) { + return None; + } + let rest = &path[base.len()..]; + // The remainder has to start at a path component boundary: `base` may or + // may not carry its trailing separator. + if !rest.is_empty() + && !strings::char_is_any_slash(rest[0]) + && !base.last().is_some_and(|&c| strings::char_is_any_slash(c)) + { + return None; + } + let separators: &[u8] = if cfg!(windows) { b"/\\" } else { b"/" }; + let rest = strings::trim_right(strings::trim_left(rest, separators), separators); + if rest.len() > buf.len() { + return None; + } + if cfg!(windows) { + Some(&*ResolvePath::resolve_path::path_to_posix_buf::( + rest, buf, + )) + } else { + buf[..rest.len()].copy_from_slice(rest); + Some(&buf[..rest.len()]) } } diff --git a/src/resolver/result.rs b/src/resolver/result.rs index 3518ee18eed0..d05427419bcf 100644 --- a/src/resolver/result.rs +++ b/src/resolver/result.rs @@ -11,7 +11,6 @@ use bun_alloc as allocators; use bun_core::MutableString; use bun_sys::Fd as FD; -use crate::dir_info::DirInfoRef; use crate::options; use crate::package_json::PackageJSON; use crate::resolver::Dependency; @@ -375,7 +374,6 @@ pub struct MatchResult { pub(crate) file_fd: FD, pub(crate) is_node_module: bool, pub(crate) package_json: Option<*const PackageJSON>, - pub(crate) dir_info: Option, pub(crate) module_type: options::ModuleType, pub(crate) is_external: bool, } @@ -388,7 +386,6 @@ impl Default for MatchResult { file_fd: FD::INVALID, is_node_module: false, package_json: None, - dir_info: None, module_type: options::ModuleType::Unknown, is_external: false, } diff --git a/test/bundler/bundler_browser.test.ts b/test/bundler/bundler_browser.test.ts index 167e100b40f8..57b5bd5ca84c 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -95,7 +95,7 @@ describe("bundler", () => { }, target: "browser", run: { - stdout: "function\nfunction\nundefined", + stdout: "object\nobject\nundefined", }, onAfterBundle(api) { api.expectFile("out.js").not.toInclude("import "); @@ -264,7 +264,7 @@ describe("bundler", () => { target: "browser", run: { stdout: - "{\n assert: {\n AssertionError: [Getter/Setter],\n CallTracker: [Getter/Setter],\n deepEqual: [Getter/Setter],\n deepStrictEqual: [Getter/Setter],\n default: [Getter/Setter],\n doesNotMatch: [Getter/Setter],\n doesNotReject: [Getter/Setter],\n doesNotThrow: [Getter/Setter],\n equal: [Getter/Setter],\n fail: [Getter/Setter],\n ifError: [Getter/Setter],\n match: [Getter/Setter],\n notDeepEqual: [Getter/Setter],\n notDeepStrictEqual: [Getter/Setter],\n notEqual: [Getter/Setter],\n notStrictEqual: [Getter/Setter],\n ok: [Getter/Setter],\n rejects: [Getter/Setter],\n strict: [Getter/Setter],\n strictEqual: [Getter/Setter],\n throws: [Getter/Setter],\n },\n buffer: {\n Blob: [Getter/Setter],\n Buffer: [Getter/Setter],\n File: [Getter/Setter],\n INSPECT_MAX_BYTES: [Getter/Setter],\n atob: [Getter/Setter],\n btoa: [Getter/Setter],\n constants: [Getter/Setter],\n default: [Getter/Setter],\n isAscii: [Getter/Setter],\n isUtf8: [Getter/Setter],\n kMaxLength: [Getter/Setter],\n kStringMaxLength: [Getter/Setter],\n resolveObjectURL: [Getter/Setter],\n transcode: [Getter/Setter],\n },\n child_process: [Function: child_process],\n cluster: [Function: cluster],\n console2: {\n default: [Getter/Setter],\n },\n constants: {\n DH_CHECK_P_NOT_PRIME: [Getter/Setter],\n DH_CHECK_P_NOT_SAFE_PRIME: [Getter/Setter],\n DH_NOT_SUITABLE_GENERATOR: [Getter/Setter],\n DH_UNABLE_TO_CHECK_GENERATOR: [Getter/Setter],\n E2BIG: [Getter/Setter],\n EACCES: [Getter/Setter],\n EADDRINUSE: [Getter/Setter],\n EADDRNOTAVAIL: [Getter/Setter],\n EAFNOSUPPORT: [Getter/Setter],\n EAGAIN: [Getter/Setter],\n EALREADY: [Getter/Setter],\n EBADF: [Getter/Setter],\n EBADMSG: [Getter/Setter],\n EBUSY: [Getter/Setter],\n ECANCELED: [Getter/Setter],\n ECHILD: [Getter/Setter],\n ECONNABORTED: [Getter/Setter],\n ECONNREFUSED: [Getter/Setter],\n ECONNRESET: [Getter/Setter],\n EDEADLK: [Getter/Setter],\n EDESTADDRREQ: [Getter/Setter],\n EDOM: [Getter/Setter],\n EDQUOT: [Getter/Setter],\n EEXIST: [Getter/Setter],\n EFAULT: [Getter/Setter],\n EFBIG: [Getter/Setter],\n EHOSTUNREACH: [Getter/Setter],\n EIDRM: [Getter/Setter],\n EILSEQ: [Getter/Setter],\n EINPROGRESS: [Getter/Setter],\n EINTR: [Getter/Setter],\n EINVAL: [Getter/Setter],\n EIO: [Getter/Setter],\n EISCONN: [Getter/Setter],\n EISDIR: [Getter/Setter],\n ELOOP: [Getter/Setter],\n EMFILE: [Getter/Setter],\n EMLINK: [Getter/Setter],\n EMSGSIZE: [Getter/Setter],\n EMULTIHOP: [Getter/Setter],\n ENAMETOOLONG: [Getter/Setter],\n ENETDOWN: [Getter/Setter],\n ENETRESET: [Getter/Setter],\n ENETUNREACH: [Getter/Setter],\n ENFILE: [Getter/Setter],\n ENGINE_METHOD_ALL: [Getter/Setter],\n ENGINE_METHOD_CIPHERS: [Getter/Setter],\n ENGINE_METHOD_DH: [Getter/Setter],\n ENGINE_METHOD_DIGESTS: [Getter/Setter],\n ENGINE_METHOD_DSA: [Getter/Setter],\n ENGINE_METHOD_ECDH: [Getter/Setter],\n ENGINE_METHOD_ECDSA: [Getter/Setter],\n ENGINE_METHOD_NONE: [Getter/Setter],\n ENGINE_METHOD_PKEY_ASN1_METHS: [Getter/Setter],\n ENGINE_METHOD_PKEY_METHS: [Getter/Setter],\n ENGINE_METHOD_RAND: [Getter/Setter],\n ENGINE_METHOD_STORE: [Getter/Setter],\n ENOBUFS: [Getter/Setter],\n ENODATA: [Getter/Setter],\n ENODEV: [Getter/Setter],\n ENOENT: [Getter/Setter],\n ENOEXEC: [Getter/Setter],\n ENOLCK: [Getter/Setter],\n ENOLINK: [Getter/Setter],\n ENOMEM: [Getter/Setter],\n ENOMSG: [Getter/Setter],\n ENOPROTOOPT: [Getter/Setter],\n ENOSPC: [Getter/Setter],\n ENOSR: [Getter/Setter],\n ENOSTR: [Getter/Setter],\n ENOSYS: [Getter/Setter],\n ENOTCONN: [Getter/Setter],\n ENOTDIR: [Getter/Setter],\n ENOTEMPTY: [Getter/Setter],\n ENOTSOCK: [Getter/Setter],\n ENOTSUP: [Getter/Setter],\n ENOTTY: [Getter/Setter],\n ENXIO: [Getter/Setter],\n EOPNOTSUPP: [Getter/Setter],\n EOVERFLOW: [Getter/Setter],\n EPERM: [Getter/Setter],\n EPIPE: [Getter/Setter],\n EPROTO: [Getter/Setter],\n EPROTONOSUPPORT: [Getter/Setter],\n EPROTOTYPE: [Getter/Setter],\n ERANGE: [Getter/Setter],\n EROFS: [Getter/Setter],\n ESPIPE: [Getter/Setter],\n ESRCH: [Getter/Setter],\n ESTALE: [Getter/Setter],\n ETIME: [Getter/Setter],\n ETIMEDOUT: [Getter/Setter],\n ETXTBSY: [Getter/Setter],\n EWOULDBLOCK: [Getter/Setter],\n EXDEV: [Getter/Setter],\n F_OK: [Getter/Setter],\n NPN_ENABLED: [Getter/Setter],\n O_APPEND: [Getter/Setter],\n O_CREAT: [Getter/Setter],\n O_DIRECTORY: [Getter/Setter],\n O_EXCL: [Getter/Setter],\n O_NOCTTY: [Getter/Setter],\n O_NOFOLLOW: [Getter/Setter],\n O_NONBLOCK: [Getter/Setter],\n O_RDONLY: [Getter/Setter],\n O_RDWR: [Getter/Setter],\n O_SYMLINK: [Getter/Setter],\n O_SYNC: [Getter/Setter],\n O_TRUNC: [Getter/Setter],\n O_WRONLY: [Getter/Setter],\n POINT_CONVERSION_COMPRESSED: [Getter/Setter],\n POINT_CONVERSION_HYBRID: [Getter/Setter],\n POINT_CONVERSION_UNCOMPRESSED: [Getter/Setter],\n RSA_NO_PADDING: [Getter/Setter],\n RSA_PKCS1_OAEP_PADDING: [Getter/Setter],\n RSA_PKCS1_PADDING: [Getter/Setter],\n RSA_PKCS1_PSS_PADDING: [Getter/Setter],\n RSA_SSLV23_PADDING: [Getter/Setter],\n RSA_X931_PADDING: [Getter/Setter],\n R_OK: [Getter/Setter],\n SIGABRT: [Getter/Setter],\n SIGALRM: [Getter/Setter],\n SIGBUS: [Getter/Setter],\n SIGCHLD: [Getter/Setter],\n SIGCONT: [Getter/Setter],\n SIGFPE: [Getter/Setter],\n SIGHUP: [Getter/Setter],\n SIGILL: [Getter/Setter],\n SIGINT: [Getter/Setter],\n SIGIO: [Getter/Setter],\n SIGIOT: [Getter/Setter],\n SIGKILL: [Getter/Setter],\n SIGPIPE: [Getter/Setter],\n SIGPROF: [Getter/Setter],\n SIGQUIT: [Getter/Setter],\n SIGSEGV: [Getter/Setter],\n SIGSTOP: [Getter/Setter],\n SIGSYS: [Getter/Setter],\n SIGTERM: [Getter/Setter],\n SIGTRAP: [Getter/Setter],\n SIGTSTP: [Getter/Setter],\n SIGTTIN: [Getter/Setter],\n SIGTTOU: [Getter/Setter],\n SIGURG: [Getter/Setter],\n SIGUSR1: [Getter/Setter],\n SIGUSR2: [Getter/Setter],\n SIGVTALRM: [Getter/Setter],\n SIGWINCH: [Getter/Setter],\n SIGXCPU: [Getter/Setter],\n SIGXFSZ: [Getter/Setter],\n SSL_OP_ALL: [Getter/Setter],\n SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION: [Getter/Setter],\n SSL_OP_CIPHER_SERVER_PREFERENCE: [Getter/Setter],\n SSL_OP_CISCO_ANYCONNECT: [Getter/Setter],\n SSL_OP_COOKIE_EXCHANGE: [Getter/Setter],\n SSL_OP_CRYPTOPRO_TLSEXT_BUG: [Getter/Setter],\n SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS: [Getter/Setter],\n SSL_OP_EPHEMERAL_RSA: [Getter/Setter],\n SSL_OP_LEGACY_SERVER_CONNECT: [Getter/Setter],\n SSL_OP_MICROSOFT_BIG_SSLV3_BUFFER: [Getter/Setter],\n SSL_OP_MICROSOFT_SESS_ID_BUG: [Getter/Setter],\n SSL_OP_MSIE_SSLV2_RSA_PADDING: [Getter/Setter],\n SSL_OP_NETSCAPE_CA_DN_BUG: [Getter/Setter],\n SSL_OP_NETSCAPE_CHALLENGE_BUG: [Getter/Setter],\n SSL_OP_NETSCAPE_DEMO_CIPHER_CHANGE_BUG: [Getter/Setter],\n SSL_OP_NETSCAPE_REUSE_CIPHER_CHANGE_BUG: [Getter/Setter],\n SSL_OP_NO_COMPRESSION: [Getter/Setter],\n SSL_OP_NO_QUERY_MTU: [Getter/Setter],\n SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION: [Getter/Setter],\n SSL_OP_NO_SSLv2: [Getter/Setter],\n SSL_OP_NO_SSLv3: [Getter/Setter],\n SSL_OP_NO_TICKET: [Getter/Setter],\n SSL_OP_NO_TLSv1: [Getter/Setter],\n SSL_OP_NO_TLSv1_1: [Getter/Setter],\n SSL_OP_NO_TLSv1_2: [Getter/Setter],\n SSL_OP_PKCS1_CHECK_1: [Getter/Setter],\n SSL_OP_PKCS1_CHECK_2: [Getter/Setter],\n SSL_OP_SINGLE_DH_USE: [Getter/Setter],\n SSL_OP_SINGLE_ECDH_USE: [Getter/Setter],\n SSL_OP_SSLEAY_080_CLIENT_DH_BUG: [Getter/Setter],\n SSL_OP_SSLREF2_REUSE_CERT_TYPE_BUG: [Getter/Setter],\n SSL_OP_TLS_BLOCK_PADDING_BUG: [Getter/Setter],\n SSL_OP_TLS_D5_BUG: [Getter/Setter],\n SSL_OP_TLS_ROLLBACK_BUG: [Getter/Setter],\n S_IFBLK: [Getter/Setter],\n S_IFCHR: [Getter/Setter],\n S_IFDIR: [Getter/Setter],\n S_IFIFO: [Getter/Setter],\n S_IFLNK: [Getter/Setter],\n S_IFMT: [Getter/Setter],\n S_IFREG: [Getter/Setter],\n S_IFSOCK: [Getter/Setter],\n S_IRGRP: [Getter/Setter],\n S_IROTH: [Getter/Setter],\n S_IRUSR: [Getter/Setter],\n S_IRWXG: [Getter/Setter],\n S_IRWXO: [Getter/Setter],\n S_IRWXU: [Getter/Setter],\n S_IWGRP: [Getter/Setter],\n S_IWOTH: [Getter/Setter],\n S_IWUSR: [Getter/Setter],\n S_IXGRP: [Getter/Setter],\n S_IXOTH: [Getter/Setter],\n S_IXUSR: [Getter/Setter],\n UV_UDP_REUSEADDR: [Getter/Setter],\n W_OK: [Getter/Setter],\n X_OK: [Getter/Setter],\n },\n crypto: {\n Cipher: [Getter/Setter],\n Cipheriv: [Getter/Setter],\n DEFAULT_ENCODING: [Getter/Setter],\n Decipher: [Getter/Setter],\n Decipheriv: [Getter/Setter],\n DiffieHellman: [Getter/Setter],\n DiffieHellmanGroup: [Getter/Setter],\n Hash: [Getter/Setter],\n Hmac: [Getter/Setter],\n Sign: [Getter/Setter],\n Verify: [Getter/Setter],\n constants: [Getter/Setter],\n createCipher: [Getter/Setter],\n createCipheriv: [Getter/Setter],\n createCredentials: [Getter/Setter],\n createDecipher: [Getter/Setter],\n createDecipheriv: [Getter/Setter],\n createDiffieHellman: [Getter/Setter],\n createDiffieHellmanGroup: [Getter/Setter],\n createECDH: [Getter/Setter],\n createHash: [Getter/Setter],\n createHmac: [Getter/Setter],\n createSign: [Getter/Setter],\n createVerify: [Getter/Setter],\n default: [Getter/Setter],\n getCiphers: [Getter/Setter],\n getCurves: [Getter/Setter],\n getDiffieHellman: [Getter/Setter],\n getHashes: [Getter/Setter],\n getRandomValues: [Getter/Setter],\n listCiphers: [Getter/Setter],\n pbkdf2: [Getter/Setter],\n pbkdf2Sync: [Getter/Setter],\n privateDecrypt: [Getter/Setter],\n privateEncrypt: [Getter/Setter],\n prng: [Getter/Setter],\n pseudoRandomBytes: [Getter/Setter],\n publicDecrypt: [Getter/Setter],\n publicEncrypt: [Getter/Setter],\n randomBytes: [Getter/Setter],\n randomFill: [Getter/Setter],\n randomFillSync: [Getter/Setter],\n randomUUID: [Getter/Setter],\n rng: [Getter/Setter],\n webcrypto: [Getter/Setter],\n },\n dgram: [Function: dgram],\n dns: [Function: dns],\n domain: {\n create: [Getter/Setter],\n createDomain: [Getter/Setter],\n },\n events: {\n EventEmitter: [Getter/Setter],\n addAbortListener: [Getter/Setter],\n captureRejectionSymbol: [Getter/Setter],\n default: [Getter/Setter],\n getEventListeners: [Getter/Setter],\n getMaxListeners: [Getter/Setter],\n init: [Getter/Setter],\n listenerCount: [Getter/Setter],\n once: [Getter/Setter],\n setMaxListeners: [Getter/Setter],\n },\n fs: [Function: fs],\n http: {\n Agent: [Getter/Setter],\n ClientRequest: [Getter/Setter],\n IncomingMessage: [Getter/Setter],\n METHODS: [Getter/Setter],\n STATUS_CODES: [Getter/Setter],\n default: [Getter/Setter],\n get: [Getter/Setter],\n globalAgent: [Getter/Setter],\n request: [Getter/Setter],\n },\n https: {\n Agent: [Getter/Setter],\n ClientRequest: [Getter/Setter],\n IncomingMessage: [Getter/Setter],\n METHODS: [Getter/Setter],\n OutgoingMessage: [Getter/Setter],\n STATUS_CODES: [Getter/Setter],\n Server: [Getter/Setter],\n ServerResponse: [Getter/Setter],\n createServer: [Getter/Setter],\n default: [Getter/Setter],\n get: [Getter/Setter],\n globalAgent: [Getter/Setter],\n maxHeaderSize: [Getter/Setter],\n request: [Getter/Setter],\n setMaxIdleHTTPParsers: [Getter/Setter],\n validateHeaderName: [Getter/Setter],\n validateHeaderValue: [Getter/Setter],\n },\n module: [Function: module2],\n net: {\n default: [Getter/Setter],\n isIP: [Getter/Setter],\n isIPv4: [Getter/Setter],\n isIPv6: [Getter/Setter],\n },\n os: {\n EOL: [Getter/Setter],\n arch: [Getter/Setter],\n cpus: [Getter/Setter],\n endianness: [Getter/Setter],\n freemem: [Getter/Setter],\n getNetworkInterfaces: [Getter/Setter],\n homedir: [Getter/Setter],\n hostname: [Getter/Setter],\n loadavg: [Getter/Setter],\n networkInterfaces: [Getter/Setter],\n platform: [Getter/Setter],\n release: [Getter/Setter],\n tmpDir: [Getter/Setter],\n tmpdir: [Getter/Setter],\n totalmem: [Getter/Setter],\n type: [Getter/Setter],\n uptime: [Getter/Setter],\n },\n path: {\n _makeLong: [Getter/Setter],\n basename: [Getter/Setter],\n default: [Getter/Setter],\n delimiter: [Getter/Setter],\n dirname: [Getter/Setter],\n extname: [Getter/Setter],\n format: [Getter/Setter],\n isAbsolute: [Getter/Setter],\n join: [Getter/Setter],\n normalize: [Getter/Setter],\n parse: [Getter/Setter],\n posix: [Getter/Setter],\n relative: [Getter/Setter],\n resolve: [Getter/Setter],\n sep: [Getter/Setter],\n },\n perf_hooks: [Function: perf_hooks],\n process: {\n addListener: [Getter/Setter],\n argv: [Getter/Setter],\n binding: [Getter/Setter],\n browser: [Getter/Setter],\n chdir: [Getter/Setter],\n cwd: [Getter/Setter],\n emit: [Getter/Setter],\n env: [Getter/Setter],\n listeners: [Getter/Setter],\n nextTick: [Getter/Setter],\n off: [Getter/Setter],\n on: [Getter/Setter],\n once: [Getter/Setter],\n prependListener: [Getter/Setter],\n prependOnceListener: [Getter/Setter],\n removeAllListeners: [Getter/Setter],\n removeListener: [Getter/Setter],\n title: [Getter/Setter],\n umask: [Getter/Setter],\n version: [Getter/Setter],\n versions: [Getter/Setter],\n },\n punycode: {\n default: [Getter/Setter],\n },\n querystring: {\n decode: [Getter/Setter],\n default: [Getter/Setter],\n encode: [Getter/Setter],\n escape: [Getter/Setter],\n parse: [Getter/Setter],\n stringify: [Getter/Setter],\n unescape: [Getter/Setter],\n unescapeBuffer: [Getter/Setter],\n },\n readline: [Function: readline],\n repl: [Function: repl],\n stream: Function {\n default: [Stream: Readable],\n length: [Getter],\n name: [Getter],\n prototype: [Getter],\n ReadableState: [Getter],\n _fromList: [Getter],\n from: [Getter],\n fromWeb: [Getter],\n toWeb: [Getter],\n wrap: [Getter],\n _uint8ArrayToBuffer: [Getter],\n _isUint8Array: [Getter],\n isDisturbed: [Getter],\n isErrored: [Getter],\n isReadable: [Getter],\n Readable: [Getter],\n Writable: [Getter],\n Duplex: [Getter],\n Transform: [Getter],\n PassThrough: [Getter],\n addAbortSignal: [Getter],\n finished: [Getter],\n destroy: [Getter],\n pipeline: [Getter],\n compose: [Getter],\n Stream: [Getter],\n isDestroyed: [Function: isDestroyed],\n isWritable: [Function: isWritable],\n setDefaultHighWaterMark: [Function: setDefaultHighWaterMark],\n getDefaultHighWaterMark: [Function: getDefaultHighWaterMark],\n promises: [Getter],\n },\n string_decoder: {\n StringDecoder: [Getter/Setter],\n default: [Getter/Setter],\n },\n sys: {\n TextDecoder: [Getter/Setter],\n TextEncoder: [Getter/Setter],\n _extend: [Getter/Setter],\n callbackify: [Getter/Setter],\n callbackifyOnRejected: [Getter/Setter],\n debuglog: [Getter/Setter],\n default: [Getter/Setter],\n deprecate: [Getter/Setter],\n format: [Getter/Setter],\n inherits: [Getter/Setter],\n inspect: [Getter/Setter],\n isArray: [Getter/Setter],\n isBoolean: [Getter/Setter],\n isBuffer: [Getter/Setter],\n isDate: [Getter/Setter],\n isError: [Getter/Setter],\n isFunction: [Getter/Setter],\n isNull: [Getter/Setter],\n isNullOrUndefined: [Getter/Setter],\n isNumber: [Getter/Setter],\n isObject: [Getter/Setter],\n isPrimitive: [Getter/Setter],\n isRegExp: [Getter/Setter],\n isString: [Getter/Setter],\n isSymbol: [Getter/Setter],\n isUndefined: [Getter/Setter],\n log: [Getter/Setter],\n promisify: [Getter/Setter],\n types: [Getter/Setter],\n },\n timers: {\n _unrefActive: [Getter/Setter],\n clearImmediate: [Getter/Setter],\n clearInterval: [Getter/Setter],\n clearTimeout: [Getter/Setter],\n promises: [Getter/Setter],\n setImmediate: [Getter/Setter],\n setInterval: [Getter/Setter],\n setTimeout: [Getter/Setter],\n },\n tls: [Function: tls],\n tty: {\n ReadStream: [Getter/Setter],\n WriteStream: [Getter/Setter],\n default: [Getter/Setter],\n isatty: [Getter/Setter],\n },\n url: {\n URL: [Getter/Setter],\n URLSearchParams: [Getter/Setter],\n Url: [Getter/Setter],\n default: [Getter/Setter],\n format: [Getter/Setter],\n parse: [Getter/Setter],\n resolve: [Getter/Setter],\n resolveObject: [Getter/Setter],\n },\n util: {\n TextDecoder: [Getter/Setter],\n TextEncoder: [Getter/Setter],\n _extend: [Getter/Setter],\n callbackify: [Getter/Setter],\n callbackifyOnRejected: [Getter/Setter],\n debuglog: [Getter/Setter],\n default: [Getter/Setter],\n deprecate: [Getter/Setter],\n format: [Getter/Setter],\n inherits: [Getter/Setter],\n inspect: [Getter/Setter],\n isArray: [Getter/Setter],\n isBoolean: [Getter/Setter],\n isBuffer: [Getter/Setter],\n isDate: [Getter/Setter],\n isError: [Getter/Setter],\n isFunction: [Getter/Setter],\n isNull: [Getter/Setter],\n isNullOrUndefined: [Getter/Setter],\n isNumber: [Getter/Setter],\n isObject: [Getter/Setter],\n isPrimitive: [Getter/Setter],\n isRegExp: [Getter/Setter],\n isString: [Getter/Setter],\n isSymbol: [Getter/Setter],\n isUndefined: [Getter/Setter],\n log: [Getter/Setter],\n promisify: [Getter/Setter],\n types: [Getter/Setter],\n },\n v8: [Function: v8],\n vm: [Function: vm],\n zlib: {\n default: [Getter/Setter],\n },\n}", + "{\n assert: {\n AssertionError: [Getter/Setter],\n CallTracker: [Getter/Setter],\n deepEqual: [Getter/Setter],\n deepStrictEqual: [Getter/Setter],\n default: [Getter/Setter],\n doesNotMatch: [Getter/Setter],\n doesNotReject: [Getter/Setter],\n doesNotThrow: [Getter/Setter],\n equal: [Getter/Setter],\n fail: [Getter/Setter],\n ifError: [Getter/Setter],\n match: [Getter/Setter],\n notDeepEqual: [Getter/Setter],\n notDeepStrictEqual: [Getter/Setter],\n notEqual: [Getter/Setter],\n notStrictEqual: [Getter/Setter],\n ok: [Getter/Setter],\n rejects: [Getter/Setter],\n strict: [Getter/Setter],\n strictEqual: [Getter/Setter],\n throws: [Getter/Setter],\n },\n buffer: {\n Blob: [Getter/Setter],\n Buffer: [Getter/Setter],\n File: [Getter/Setter],\n INSPECT_MAX_BYTES: [Getter/Setter],\n atob: [Getter/Setter],\n btoa: [Getter/Setter],\n constants: [Getter/Setter],\n default: [Getter/Setter],\n isAscii: [Getter/Setter],\n isUtf8: [Getter/Setter],\n kMaxLength: [Getter/Setter],\n kStringMaxLength: [Getter/Setter],\n resolveObjectURL: [Getter/Setter],\n transcode: [Getter/Setter],\n },\n child_process: {\n default: {},\n },\n cluster: {\n default: {},\n },\n console2: {\n default: [Getter/Setter],\n },\n constants: {\n DH_CHECK_P_NOT_PRIME: [Getter/Setter],\n DH_CHECK_P_NOT_SAFE_PRIME: [Getter/Setter],\n DH_NOT_SUITABLE_GENERATOR: [Getter/Setter],\n DH_UNABLE_TO_CHECK_GENERATOR: [Getter/Setter],\n E2BIG: [Getter/Setter],\n EACCES: [Getter/Setter],\n EADDRINUSE: [Getter/Setter],\n EADDRNOTAVAIL: [Getter/Setter],\n EAFNOSUPPORT: [Getter/Setter],\n EAGAIN: [Getter/Setter],\n EALREADY: [Getter/Setter],\n EBADF: [Getter/Setter],\n EBADMSG: [Getter/Setter],\n EBUSY: [Getter/Setter],\n ECANCELED: [Getter/Setter],\n ECHILD: [Getter/Setter],\n ECONNABORTED: [Getter/Setter],\n ECONNREFUSED: [Getter/Setter],\n ECONNRESET: [Getter/Setter],\n EDEADLK: [Getter/Setter],\n EDESTADDRREQ: [Getter/Setter],\n EDOM: [Getter/Setter],\n EDQUOT: [Getter/Setter],\n EEXIST: [Getter/Setter],\n EFAULT: [Getter/Setter],\n EFBIG: [Getter/Setter],\n EHOSTUNREACH: [Getter/Setter],\n EIDRM: [Getter/Setter],\n EILSEQ: [Getter/Setter],\n EINPROGRESS: [Getter/Setter],\n EINTR: [Getter/Setter],\n EINVAL: [Getter/Setter],\n EIO: [Getter/Setter],\n EISCONN: [Getter/Setter],\n EISDIR: [Getter/Setter],\n ELOOP: [Getter/Setter],\n EMFILE: [Getter/Setter],\n EMLINK: [Getter/Setter],\n EMSGSIZE: [Getter/Setter],\n EMULTIHOP: [Getter/Setter],\n ENAMETOOLONG: [Getter/Setter],\n ENETDOWN: [Getter/Setter],\n ENETRESET: [Getter/Setter],\n ENETUNREACH: [Getter/Setter],\n ENFILE: [Getter/Setter],\n ENGINE_METHOD_ALL: [Getter/Setter],\n ENGINE_METHOD_CIPHERS: [Getter/Setter],\n ENGINE_METHOD_DH: [Getter/Setter],\n ENGINE_METHOD_DIGESTS: [Getter/Setter],\n ENGINE_METHOD_DSA: [Getter/Setter],\n ENGINE_METHOD_ECDH: [Getter/Setter],\n ENGINE_METHOD_ECDSA: [Getter/Setter],\n ENGINE_METHOD_NONE: [Getter/Setter],\n ENGINE_METHOD_PKEY_ASN1_METHS: [Getter/Setter],\n ENGINE_METHOD_PKEY_METHS: [Getter/Setter],\n ENGINE_METHOD_RAND: [Getter/Setter],\n ENGINE_METHOD_STORE: [Getter/Setter],\n ENOBUFS: [Getter/Setter],\n ENODATA: [Getter/Setter],\n ENODEV: [Getter/Setter],\n ENOENT: [Getter/Setter],\n ENOEXEC: [Getter/Setter],\n ENOLCK: [Getter/Setter],\n ENOLINK: [Getter/Setter],\n ENOMEM: [Getter/Setter],\n ENOMSG: [Getter/Setter],\n ENOPROTOOPT: [Getter/Setter],\n ENOSPC: [Getter/Setter],\n ENOSR: [Getter/Setter],\n ENOSTR: [Getter/Setter],\n ENOSYS: [Getter/Setter],\n ENOTCONN: [Getter/Setter],\n ENOTDIR: [Getter/Setter],\n ENOTEMPTY: [Getter/Setter],\n ENOTSOCK: [Getter/Setter],\n ENOTSUP: [Getter/Setter],\n ENOTTY: [Getter/Setter],\n ENXIO: [Getter/Setter],\n EOPNOTSUPP: [Getter/Setter],\n EOVERFLOW: [Getter/Setter],\n EPERM: [Getter/Setter],\n EPIPE: [Getter/Setter],\n EPROTO: [Getter/Setter],\n EPROTONOSUPPORT: [Getter/Setter],\n EPROTOTYPE: [Getter/Setter],\n ERANGE: [Getter/Setter],\n EROFS: [Getter/Setter],\n ESPIPE: [Getter/Setter],\n ESRCH: [Getter/Setter],\n ESTALE: [Getter/Setter],\n ETIME: [Getter/Setter],\n ETIMEDOUT: [Getter/Setter],\n ETXTBSY: [Getter/Setter],\n EWOULDBLOCK: [Getter/Setter],\n EXDEV: [Getter/Setter],\n F_OK: [Getter/Setter],\n NPN_ENABLED: [Getter/Setter],\n O_APPEND: [Getter/Setter],\n O_CREAT: [Getter/Setter],\n O_DIRECTORY: [Getter/Setter],\n O_EXCL: [Getter/Setter],\n O_NOCTTY: [Getter/Setter],\n O_NOFOLLOW: [Getter/Setter],\n O_NONBLOCK: [Getter/Setter],\n O_RDONLY: [Getter/Setter],\n O_RDWR: [Getter/Setter],\n O_SYMLINK: [Getter/Setter],\n O_SYNC: [Getter/Setter],\n O_TRUNC: [Getter/Setter],\n O_WRONLY: [Getter/Setter],\n POINT_CONVERSION_COMPRESSED: [Getter/Setter],\n POINT_CONVERSION_HYBRID: [Getter/Setter],\n POINT_CONVERSION_UNCOMPRESSED: [Getter/Setter],\n RSA_NO_PADDING: [Getter/Setter],\n RSA_PKCS1_OAEP_PADDING: [Getter/Setter],\n RSA_PKCS1_PADDING: [Getter/Setter],\n RSA_PKCS1_PSS_PADDING: [Getter/Setter],\n RSA_SSLV23_PADDING: [Getter/Setter],\n RSA_X931_PADDING: [Getter/Setter],\n R_OK: [Getter/Setter],\n SIGABRT: [Getter/Setter],\n SIGALRM: [Getter/Setter],\n SIGBUS: [Getter/Setter],\n SIGCHLD: [Getter/Setter],\n SIGCONT: [Getter/Setter],\n SIGFPE: [Getter/Setter],\n SIGHUP: [Getter/Setter],\n SIGILL: [Getter/Setter],\n SIGINT: [Getter/Setter],\n SIGIO: [Getter/Setter],\n SIGIOT: [Getter/Setter],\n SIGKILL: [Getter/Setter],\n SIGPIPE: [Getter/Setter],\n SIGPROF: [Getter/Setter],\n SIGQUIT: [Getter/Setter],\n SIGSEGV: [Getter/Setter],\n SIGSTOP: [Getter/Setter],\n SIGSYS: [Getter/Setter],\n SIGTERM: [Getter/Setter],\n SIGTRAP: [Getter/Setter],\n SIGTSTP: [Getter/Setter],\n SIGTTIN: [Getter/Setter],\n SIGTTOU: [Getter/Setter],\n SIGURG: [Getter/Setter],\n SIGUSR1: [Getter/Setter],\n SIGUSR2: [Getter/Setter],\n SIGVTALRM: [Getter/Setter],\n SIGWINCH: [Getter/Setter],\n SIGXCPU: [Getter/Setter],\n SIGXFSZ: [Getter/Setter],\n SSL_OP_ALL: [Getter/Setter],\n SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION: [Getter/Setter],\n SSL_OP_CIPHER_SERVER_PREFERENCE: [Getter/Setter],\n SSL_OP_CISCO_ANYCONNECT: [Getter/Setter],\n SSL_OP_COOKIE_EXCHANGE: [Getter/Setter],\n SSL_OP_CRYPTOPRO_TLSEXT_BUG: [Getter/Setter],\n SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS: [Getter/Setter],\n SSL_OP_EPHEMERAL_RSA: [Getter/Setter],\n SSL_OP_LEGACY_SERVER_CONNECT: [Getter/Setter],\n SSL_OP_MICROSOFT_BIG_SSLV3_BUFFER: [Getter/Setter],\n SSL_OP_MICROSOFT_SESS_ID_BUG: [Getter/Setter],\n SSL_OP_MSIE_SSLV2_RSA_PADDING: [Getter/Setter],\n SSL_OP_NETSCAPE_CA_DN_BUG: [Getter/Setter],\n SSL_OP_NETSCAPE_CHALLENGE_BUG: [Getter/Setter],\n SSL_OP_NETSCAPE_DEMO_CIPHER_CHANGE_BUG: [Getter/Setter],\n SSL_OP_NETSCAPE_REUSE_CIPHER_CHANGE_BUG: [Getter/Setter],\n SSL_OP_NO_COMPRESSION: [Getter/Setter],\n SSL_OP_NO_QUERY_MTU: [Getter/Setter],\n SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION: [Getter/Setter],\n SSL_OP_NO_SSLv2: [Getter/Setter],\n SSL_OP_NO_SSLv3: [Getter/Setter],\n SSL_OP_NO_TICKET: [Getter/Setter],\n SSL_OP_NO_TLSv1: [Getter/Setter],\n SSL_OP_NO_TLSv1_1: [Getter/Setter],\n SSL_OP_NO_TLSv1_2: [Getter/Setter],\n SSL_OP_PKCS1_CHECK_1: [Getter/Setter],\n SSL_OP_PKCS1_CHECK_2: [Getter/Setter],\n SSL_OP_SINGLE_DH_USE: [Getter/Setter],\n SSL_OP_SINGLE_ECDH_USE: [Getter/Setter],\n SSL_OP_SSLEAY_080_CLIENT_DH_BUG: [Getter/Setter],\n SSL_OP_SSLREF2_REUSE_CERT_TYPE_BUG: [Getter/Setter],\n SSL_OP_TLS_BLOCK_PADDING_BUG: [Getter/Setter],\n SSL_OP_TLS_D5_BUG: [Getter/Setter],\n SSL_OP_TLS_ROLLBACK_BUG: [Getter/Setter],\n S_IFBLK: [Getter/Setter],\n S_IFCHR: [Getter/Setter],\n S_IFDIR: [Getter/Setter],\n S_IFIFO: [Getter/Setter],\n S_IFLNK: [Getter/Setter],\n S_IFMT: [Getter/Setter],\n S_IFREG: [Getter/Setter],\n S_IFSOCK: [Getter/Setter],\n S_IRGRP: [Getter/Setter],\n S_IROTH: [Getter/Setter],\n S_IRUSR: [Getter/Setter],\n S_IRWXG: [Getter/Setter],\n S_IRWXO: [Getter/Setter],\n S_IRWXU: [Getter/Setter],\n S_IWGRP: [Getter/Setter],\n S_IWOTH: [Getter/Setter],\n S_IWUSR: [Getter/Setter],\n S_IXGRP: [Getter/Setter],\n S_IXOTH: [Getter/Setter],\n S_IXUSR: [Getter/Setter],\n UV_UDP_REUSEADDR: [Getter/Setter],\n W_OK: [Getter/Setter],\n X_OK: [Getter/Setter],\n },\n crypto: {\n Cipher: [Getter/Setter],\n Cipheriv: [Getter/Setter],\n DEFAULT_ENCODING: [Getter/Setter],\n Decipher: [Getter/Setter],\n Decipheriv: [Getter/Setter],\n DiffieHellman: [Getter/Setter],\n DiffieHellmanGroup: [Getter/Setter],\n Hash: [Getter/Setter],\n Hmac: [Getter/Setter],\n Sign: [Getter/Setter],\n Verify: [Getter/Setter],\n constants: [Getter/Setter],\n createCipher: [Getter/Setter],\n createCipheriv: [Getter/Setter],\n createCredentials: [Getter/Setter],\n createDecipher: [Getter/Setter],\n createDecipheriv: [Getter/Setter],\n createDiffieHellman: [Getter/Setter],\n createDiffieHellmanGroup: [Getter/Setter],\n createECDH: [Getter/Setter],\n createHash: [Getter/Setter],\n createHmac: [Getter/Setter],\n createSign: [Getter/Setter],\n createVerify: [Getter/Setter],\n default: [Getter/Setter],\n getCiphers: [Getter/Setter],\n getCurves: [Getter/Setter],\n getDiffieHellman: [Getter/Setter],\n getHashes: [Getter/Setter],\n getRandomValues: [Getter/Setter],\n listCiphers: [Getter/Setter],\n pbkdf2: [Getter/Setter],\n pbkdf2Sync: [Getter/Setter],\n privateDecrypt: [Getter/Setter],\n privateEncrypt: [Getter/Setter],\n prng: [Getter/Setter],\n pseudoRandomBytes: [Getter/Setter],\n publicDecrypt: [Getter/Setter],\n publicEncrypt: [Getter/Setter],\n randomBytes: [Getter/Setter],\n randomFill: [Getter/Setter],\n randomFillSync: [Getter/Setter],\n randomUUID: [Getter/Setter],\n rng: [Getter/Setter],\n webcrypto: [Getter/Setter],\n },\n dgram: {\n default: {},\n },\n dns: {\n default: {},\n },\n domain: {\n create: [Getter/Setter],\n createDomain: [Getter/Setter],\n },\n events: {\n EventEmitter: [Getter/Setter],\n addAbortListener: [Getter/Setter],\n captureRejectionSymbol: [Getter/Setter],\n default: [Getter/Setter],\n getEventListeners: [Getter/Setter],\n getMaxListeners: [Getter/Setter],\n init: [Getter/Setter],\n listenerCount: [Getter/Setter],\n once: [Getter/Setter],\n setMaxListeners: [Getter/Setter],\n },\n fs: {\n default: {},\n },\n http: {\n Agent: [Getter/Setter],\n ClientRequest: [Getter/Setter],\n IncomingMessage: [Getter/Setter],\n METHODS: [Getter/Setter],\n STATUS_CODES: [Getter/Setter],\n default: [Getter/Setter],\n get: [Getter/Setter],\n globalAgent: [Getter/Setter],\n request: [Getter/Setter],\n },\n https: {\n Agent: [Getter/Setter],\n ClientRequest: [Getter/Setter],\n IncomingMessage: [Getter/Setter],\n METHODS: [Getter/Setter],\n OutgoingMessage: [Getter/Setter],\n STATUS_CODES: [Getter/Setter],\n Server: [Getter/Setter],\n ServerResponse: [Getter/Setter],\n createServer: [Getter/Setter],\n default: [Getter/Setter],\n get: [Getter/Setter],\n globalAgent: [Getter/Setter],\n maxHeaderSize: [Getter/Setter],\n request: [Getter/Setter],\n setMaxIdleHTTPParsers: [Getter/Setter],\n validateHeaderName: [Getter/Setter],\n validateHeaderValue: [Getter/Setter],\n },\n module: {\n default: {},\n },\n net: {\n default: [Getter/Setter],\n isIP: [Getter/Setter],\n isIPv4: [Getter/Setter],\n isIPv6: [Getter/Setter],\n },\n os: {\n EOL: [Getter/Setter],\n arch: [Getter/Setter],\n cpus: [Getter/Setter],\n endianness: [Getter/Setter],\n freemem: [Getter/Setter],\n getNetworkInterfaces: [Getter/Setter],\n homedir: [Getter/Setter],\n hostname: [Getter/Setter],\n loadavg: [Getter/Setter],\n networkInterfaces: [Getter/Setter],\n platform: [Getter/Setter],\n release: [Getter/Setter],\n tmpDir: [Getter/Setter],\n tmpdir: [Getter/Setter],\n totalmem: [Getter/Setter],\n type: [Getter/Setter],\n uptime: [Getter/Setter],\n },\n path: {\n _makeLong: [Getter/Setter],\n basename: [Getter/Setter],\n default: [Getter/Setter],\n delimiter: [Getter/Setter],\n dirname: [Getter/Setter],\n extname: [Getter/Setter],\n format: [Getter/Setter],\n isAbsolute: [Getter/Setter],\n join: [Getter/Setter],\n normalize: [Getter/Setter],\n parse: [Getter/Setter],\n posix: [Getter/Setter],\n relative: [Getter/Setter],\n resolve: [Getter/Setter],\n sep: [Getter/Setter],\n },\n perf_hooks: {\n default: {},\n },\n process: {\n addListener: [Getter/Setter],\n argv: [Getter/Setter],\n binding: [Getter/Setter],\n browser: [Getter/Setter],\n chdir: [Getter/Setter],\n cwd: [Getter/Setter],\n emit: [Getter/Setter],\n env: [Getter/Setter],\n listeners: [Getter/Setter],\n nextTick: [Getter/Setter],\n off: [Getter/Setter],\n on: [Getter/Setter],\n once: [Getter/Setter],\n prependListener: [Getter/Setter],\n prependOnceListener: [Getter/Setter],\n removeAllListeners: [Getter/Setter],\n removeListener: [Getter/Setter],\n title: [Getter/Setter],\n umask: [Getter/Setter],\n version: [Getter/Setter],\n versions: [Getter/Setter],\n },\n punycode: {\n default: [Getter/Setter],\n },\n querystring: {\n decode: [Getter/Setter],\n default: [Getter/Setter],\n encode: [Getter/Setter],\n escape: [Getter/Setter],\n parse: [Getter/Setter],\n stringify: [Getter/Setter],\n unescape: [Getter/Setter],\n unescapeBuffer: [Getter/Setter],\n },\n readline: {\n default: {},\n },\n repl: {\n default: {},\n },\n stream: Function {\n default: [Stream: Readable],\n length: [Getter],\n name: [Getter],\n prototype: [Getter],\n ReadableState: [Getter],\n _fromList: [Getter],\n from: [Getter],\n fromWeb: [Getter],\n toWeb: [Getter],\n wrap: [Getter],\n _uint8ArrayToBuffer: [Getter],\n _isUint8Array: [Getter],\n isDisturbed: [Getter],\n isErrored: [Getter],\n isReadable: [Getter],\n Readable: [Getter],\n Writable: [Getter],\n Duplex: [Getter],\n Transform: [Getter],\n PassThrough: [Getter],\n addAbortSignal: [Getter],\n finished: [Getter],\n destroy: [Getter],\n pipeline: [Getter],\n compose: [Getter],\n Stream: [Getter],\n isDestroyed: [Function: isDestroyed],\n isWritable: [Function: isWritable],\n setDefaultHighWaterMark: [Function: setDefaultHighWaterMark],\n getDefaultHighWaterMark: [Function: getDefaultHighWaterMark],\n promises: [Getter],\n },\n string_decoder: {\n StringDecoder: [Getter/Setter],\n default: [Getter/Setter],\n },\n sys: {\n TextDecoder: [Getter/Setter],\n TextEncoder: [Getter/Setter],\n _extend: [Getter/Setter],\n callbackify: [Getter/Setter],\n callbackifyOnRejected: [Getter/Setter],\n debuglog: [Getter/Setter],\n default: [Getter/Setter],\n deprecate: [Getter/Setter],\n format: [Getter/Setter],\n inherits: [Getter/Setter],\n inspect: [Getter/Setter],\n isArray: [Getter/Setter],\n isBoolean: [Getter/Setter],\n isBuffer: [Getter/Setter],\n isDate: [Getter/Setter],\n isError: [Getter/Setter],\n isFunction: [Getter/Setter],\n isNull: [Getter/Setter],\n isNullOrUndefined: [Getter/Setter],\n isNumber: [Getter/Setter],\n isObject: [Getter/Setter],\n isPrimitive: [Getter/Setter],\n isRegExp: [Getter/Setter],\n isString: [Getter/Setter],\n isSymbol: [Getter/Setter],\n isUndefined: [Getter/Setter],\n log: [Getter/Setter],\n promisify: [Getter/Setter],\n types: [Getter/Setter],\n },\n timers: {\n _unrefActive: [Getter/Setter],\n clearImmediate: [Getter/Setter],\n clearInterval: [Getter/Setter],\n clearTimeout: [Getter/Setter],\n promises: [Getter/Setter],\n setImmediate: [Getter/Setter],\n setInterval: [Getter/Setter],\n setTimeout: [Getter/Setter],\n },\n tls: {\n default: {},\n },\n tty: {\n ReadStream: [Getter/Setter],\n WriteStream: [Getter/Setter],\n default: [Getter/Setter],\n isatty: [Getter/Setter],\n },\n url: {\n URL: [Getter/Setter],\n URLSearchParams: [Getter/Setter],\n Url: [Getter/Setter],\n default: [Getter/Setter],\n format: [Getter/Setter],\n parse: [Getter/Setter],\n resolve: [Getter/Setter],\n resolveObject: [Getter/Setter],\n },\n util: {\n TextDecoder: [Getter/Setter],\n TextEncoder: [Getter/Setter],\n _extend: [Getter/Setter],\n callbackify: [Getter/Setter],\n callbackifyOnRejected: [Getter/Setter],\n debuglog: [Getter/Setter],\n default: [Getter/Setter],\n deprecate: [Getter/Setter],\n format: [Getter/Setter],\n inherits: [Getter/Setter],\n inspect: [Getter/Setter],\n isArray: [Getter/Setter],\n isBoolean: [Getter/Setter],\n isBuffer: [Getter/Setter],\n isDate: [Getter/Setter],\n isError: [Getter/Setter],\n isFunction: [Getter/Setter],\n isNull: [Getter/Setter],\n isNullOrUndefined: [Getter/Setter],\n isNumber: [Getter/Setter],\n isObject: [Getter/Setter],\n isPrimitive: [Getter/Setter],\n isRegExp: [Getter/Setter],\n isString: [Getter/Setter],\n isSymbol: [Getter/Setter],\n isUndefined: [Getter/Setter],\n log: [Getter/Setter],\n promisify: [Getter/Setter],\n types: [Getter/Setter],\n },\n v8: {\n default: {},\n },\n vm: {\n default: {},\n },\n zlib: {\n default: [Getter/Setter],\n },\n}", validate(ctx) {}, }, @@ -289,6 +289,27 @@ describe("bundler", () => { }, }); + // A package the user marks external stays external: neither the browser + // polyfill ("path") nor the disabled stub ("fs") replaces it. + itBundled("browser/ExternalWinsOverBuiltinPolyfillAndStub", { + files: { + "/entry.js": /* js */ ` + import fs from "fs"; + import path from "path"; + console.log(typeof fs.readFileSync, typeof path.join); + `, + }, + target: "browser", + external: ["fs", "path"], + onAfterBundle(api) { + api.expectFile("out.js").toInclude(`from "fs"`); + api.expectFile("out.js").toInclude(`from "path"`); + }, + run: { + stdout: "function function", + }, + }); + // #4928: a package.json "browser": {"": false} must win over the // builtin polyfill for --target browser. Packages set this to keep their // node-only code paths from dragging crypto/stream/buffer into browser bundles. diff --git a/test/bundler/esbuild/default.test.ts b/test/bundler/esbuild/default.test.ts index 950319df7bab..e7b75da6d0d1 100644 --- a/test/bundler/esbuild/default.test.ts +++ b/test/bundler/esbuild/default.test.ts @@ -1271,7 +1271,7 @@ describe.concurrent("bundler", () => { }, target: "browser", run: { - stdout: "[Function]", + stdout: "{}", }, }); itBundled("default/RequireFSNode", { @@ -1306,7 +1306,7 @@ describe.concurrent("bundler", () => { `, }, run: { - stdout: "[Function: fs] undefined undefined", + stdout: "{\n default: {},\n} undefined {}", }, target: "browser", }); diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index 9354073c7229..9de11b944e5d 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -59,6 +59,286 @@ describe("bundler", () => { "/index.js": [`Invalid JSX escape - use XML entity codes quotes or pass a JavaScript string instead`], }, }); + + // Test the "browser" field in "package.json". These are esbuild's 19 + // end-to-end cases. The target defaults to "browser". A mapped file prints + // "works"; the file the map replaces throws, has invalid syntax, or is absent. + const works = `console.log('works')`; + itBundled("extra/BrowserFieldBareToRelativeKeyDotSlash", { + files: { + "entry.js": `require('foo')`, + "package.json": `{ "browser": { "./foo": "./file" } }`, + "file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldBareToRelativeKeyBare", { + files: { + "entry.js": `require('foo')`, + "package.json": `{ "browser": { "foo": "./file" } }`, + "file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldRelativeToRelativeKeyDotSlash", { + files: { + "entry.js": `require('./foo')`, + "package.json": `{ "browser": { "./foo": "./file" } }`, + "file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldRelativeToRelativeKeyBare", { + files: { + "entry.js": `require('./foo')`, + "package.json": `{ "browser": { "foo": "./file" } }`, + "file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldSubpathKeyDotSlashFilePresent", { + files: { + "entry.js": `require('pkg/foo/bar')`, + "node_modules/pkg/package.json": `{ "browser": { "./foo/bar": "./file" } }`, + "node_modules/pkg/foo/bar.js": `invalid syntax`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldSubpathKeyBareFilePresent", { + files: { + "entry.js": `require('pkg/foo/bar')`, + "node_modules/pkg/package.json": `{ "browser": { "foo/bar": "./file" } }`, + "node_modules/pkg/foo/bar.js": `invalid syntax`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldSubpathKeyDotSlashFileAbsent", { + files: { + "entry.js": `require('pkg/foo/bar')`, + "node_modules/pkg/package.json": `{ "browser": { "./foo/bar": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldSubpathKeyBareFileAbsent", { + files: { + "entry.js": `require('pkg/foo/bar')`, + "node_modules/pkg/package.json": `{ "browser": { "foo/bar": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldBareSubpathInsidePackageKeyDotSlash", { + files: { + "entry.js": `require('pkg')`, + "node_modules/pkg/index.js": `require('foo/bar')`, + "node_modules/pkg/package.json": `{ "browser": { "./foo/bar": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldBareSubpathInsidePackageKeyBare", { + files: { + "entry.js": `require('pkg')`, + "node_modules/pkg/index.js": `require('foo/bar')`, + "node_modules/pkg/package.json": `{ "browser": { "foo/bar": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldIndexKeyWithExtFilePresent", { + files: { + "entry.js": `require('pkg')`, + "node_modules/pkg/index.js": `throw 'fail'`, + "node_modules/pkg/package.json": `{ "browser": { "./index.js": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldIndexKeyWithExtFileAbsent", { + files: { + "entry.js": `require('pkg')`, + "node_modules/pkg/package.json": `{ "browser": { "./index.js": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldIndexKeyNoExtFilePresent", { + files: { + "entry.js": `require('pkg')`, + "node_modules/pkg/index.js": `throw 'fail'`, + "node_modules/pkg/package.json": `{ "browser": { "./index": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldIndexKeyNoExtFileAbsent", { + files: { + "entry.js": `require('pkg')`, + "node_modules/pkg/package.json": `{ "browser": { "./index": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldMainKeyWithExtFilePresent", { + files: { + "entry.js": `require('pkg')`, + "node_modules/pkg/main.js": `throw 'fail'`, + "node_modules/pkg/package.json": `{ "main": "./main",\n "browser": { "./main.js": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldMainKeyWithExtFileAbsent", { + files: { + "entry.js": `require('pkg')`, + "node_modules/pkg/package.json": `{ "main": "./main",\n "browser": { "./main.js": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldInnerPackageMapWinsOverOuter", { + files: { + "entry.js": `require('pkg')`, + "package.json": `{ "browser": { "pkg2": "pkg3" } }`, + "node_modules/pkg/index.js": `require('pkg2')`, + "node_modules/pkg/package.json": `{ "browser": { "pkg2": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldOuterPackageMapAppliesInsideNodeModules", { + files: { + "entry.js": `require('pkg')`, + "package.json": `{ "browser": { "pkg2": "pkg3" } }`, + "node_modules/pkg/index.js": `require('pkg2')`, + "node_modules/pkg2/index.js": `throw 'fail'`, + "node_modules/pkg3/index.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldInnerPackageDotSlashKeyOverridesBarePath", { + files: { + "entry.js": `require('pkg')`, + "package.json": `{ "browser": { "pkg2": "pkg3" } }`, + "node_modules/pkg/index.js": `require('pkg2')`, + "node_modules/pkg/package.json": `{ "browser": { "./pkg2": "./file" } }`, + "node_modules/pkg/file.js": works, + }, + run: { stdout: "works" }, + }); + + // The same matrix through `import` statements. + itBundled("extra/BrowserFieldImportBareKeyDotSlash", { + files: { + "entry.js": `import 'foo'`, + "package.json": `{ "browser": { "./foo": "./file" } }`, + "file.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldImportSubpathKeyDotSlashFileAbsent", { + files: { + "entry.js": `import {value} from 'pkg/foo/bar'; console.log(value)`, + "node_modules/pkg/package.json": `{ "browser": { "./foo/bar": "./file" } }`, + "node_modules/pkg/file.js": `export const value = 'works'`, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldImportSubpathKeyBareFilePresent", { + files: { + "entry.js": `import {value} from 'pkg/foo/bar'; console.log(value)`, + "node_modules/pkg/package.json": `{ "browser": { "foo/bar": "./file" } }`, + "node_modules/pkg/foo/bar.js": `invalid syntax`, + "node_modules/pkg/file.js": `export const value = 'works'`, + }, + run: { stdout: "works" }, + }); + + // Browserify lets "./pkg" match `require('pkg')` only from the package that + // owns the map. A "./pkg2" entry in the project does not reach into a + // dependency that requires "pkg2". + itBundled("extra/BrowserFieldDotSlashKeyDoesNotCrossNodeModules", { + files: { + "entry.js": `require('pkg')`, + "package.json": `{ "browser": { "./pkg2": "./file" } }`, + "file.js": `throw 'fail'`, + "node_modules/pkg/index.js": `require('pkg2')`, + "node_modules/pkg2/index.js": works, + }, + run: { stdout: "works" }, + }); + // The "./pkg" spelling is matched without implicit extensions: "./sub.js" does + // not remap `require('sub')`, so the real "sub" package is used. + itBundled("extra/BrowserFieldDotSlashKeyWithExtDoesNotMatchBarePath", { + files: { + "entry.js": `require('pkg')`, + "node_modules/pkg/index.js": `require('sub')`, + "node_modules/pkg/package.json": `{ "browser": { "./sub.js": "./file.js" } }`, + "node_modules/pkg/file.js": `throw 'fail'`, + "node_modules/sub/index.js": works, + }, + run: { stdout: "works" }, + }); + // From a subdirectory of the package, the bare path is matched against the + // key spelled relative to the importer's directory. + itBundled("extra/BrowserFieldBarePathFromSubdirectory", { + files: { + "entry.js": `require('pkg')`, + "node_modules/pkg/package.json": `{ "main": "./sub/foo.js", "browser": { "./sub/sub": "./sub/bar.js", "./sub/nodeonly": false } }`, + "node_modules/pkg/sub/foo.js": `require('sub'); console.log(JSON.stringify(require('nodeonly')))`, + "node_modules/pkg/sub/bar.js": works, + "node_modules/sub/index.js": `throw 'fail'`, + "node_modules/nodeonly/index.js": `throw 'fail'`, + }, + run: { stdout: "works\n{}" }, + }); + + // A module disabled with `false` is an empty CommonJS module: `require()` + // returns the same `{}` every time and `import` sees its namespace object. + itBundled("extra/BrowserFieldDisabledModuleIsEmptyObject", { + files: { + "entry.js": `console.log(JSON.stringify(require('pkg')))`, + "node_modules/pkg/package.json": `{ "main": "index.js", "browser": { "./node.js": false, "nodeonly": false, "./missing.js": false } }`, + "node_modules/pkg/index.js": ` + var n = require('./node.js'), m = require('nodeonly'), x = require('./missing.js') + module.exports = [typeof n, Object.keys(n).length, typeof m, Object.keys(m).length, typeof x, n === require('./node.js')] + `, + "node_modules/pkg/node.js": `throw 'fail'`, + "node_modules/nodeonly/index.js": `throw 'fail'`, + }, + run: { stdout: `["object",0,"object",0,"object",true]` }, + }); + itBundled("extra/BrowserFieldDisabledModuleImportForms", { + files: { + "entry.js": `import {info} from 'pkg'; console.log(JSON.stringify(info))`, + "node_modules/pkg/package.json": `{ "main": "index.js", "browser": { "./node.js": false } }`, + "node_modules/pkg/index.js": ` + import * as ns from './node.js' + import def, {named} from './node.js' + import './node.js' + export const info = [typeof ns, Object.keys(ns), typeof def, Object.keys(def).length, typeof named] + `, + "node_modules/pkg/node.js": `throw 'fail'`, + }, + run: { stdout: `["object",["default"],"object",0,"undefined"]` }, + }); + // A subpath the package disables is also disabled when imported from outside + // the package, and it stays a separate module from the enabled file. + itBundled("extra/BrowserFieldDisabledSubpathFromOutside", { + files: { + "entry.js": ` + import * as ns from 'pkg/node.js' + console.log(typeof ns, JSON.stringify(require('pkg/node.js'))) + `, + "node_modules/pkg/package.json": `{ "browser": { "./node.js": false } }`, + "node_modules/pkg/node.js": `throw 'fail'`, + }, + run: { stdout: "object {}" }, + }); + // Test arbitrary module namespace identifier names // See https://github.com/tc39/ecma262/pull/2154 itBundled("extra/ArbitraryModuleNamespaceIdentifiers1", { diff --git a/test/bundler/esbuild/packagejson.test.ts b/test/bundler/esbuild/packagejson.test.ts index 4f17d2d55b84..799a821d4472 100644 --- a/test/bundler/esbuild/packagejson.test.ts +++ b/test/bundler/esbuild/packagejson.test.ts @@ -323,7 +323,6 @@ describe("bundler", () => { }, }); itBundled("packagejson/BrowserMapModuleDisabled", { - todo: true, files: { "/Users/user/project/src/entry.js": /* js */ ` import fn from 'demo-pkg' @@ -564,7 +563,6 @@ describe("bundler", () => { }, }); itBundled("packagejson/BrowserNodeModulesNoExt", { - todo: true, files: { "/Users/user/project/src/entry.js": /* js */ ` import {value as a} from 'demo-pkg/no-ext' @@ -599,7 +597,6 @@ describe("bundler", () => { }, }); itBundled("packagejson/BrowserNodeModulesIndexNoExt", { - todo: true, files: { "/Users/user/project/src/entry.js": /* js */ ` import {value as a} from 'demo-pkg/no-ext' @@ -702,7 +699,6 @@ describe("bundler", () => { }, }); itBundled("packagejson/BrowserESBuildIssue2002A", { - todo: true, files: { "/Users/user/project/src/entry.js": `require('pkg/sub')`, "/Users/user/project/src/node_modules/pkg/package.json": /* json */ ` @@ -721,7 +717,6 @@ describe("bundler", () => { }, }); itBundled("packagejson/BrowserESBuildIssue2002B", { - todo: true, files: { "/Users/user/project/src/entry.js": `require('pkg/sub')`, "/Users/user/project/src/node_modules/pkg/package.json": /* json */ ` @@ -740,7 +735,6 @@ describe("bundler", () => { }, }); itBundled("packagejson/BrowserESBuildIssue2002C", { - todo: true, files: { "/Users/user/project/src/entry.js": `require('pkg/sub')`, "/Users/user/project/src/node_modules/pkg/package.json": /* json */ ` @@ -2167,10 +2161,8 @@ describe("bundler", () => { stdout: "exact with-ext as-index as-index-ext", }, }); - // Browser-map keys are normalized at parse time ("./name" -> "name"), so a - // "./name" entry also remaps the *package path* "name" imported from within - // the same package (Browserify compatibility quirk); the normalized key - // matches the package specifier on the checker's first probe. + // A "./name" entry also remaps the *package path* "name" imported from within + // the same package (Browserify compatibility quirk). itBundled("packagejson/BrowserMapDotSlashOverridesPackagePath", { files: { "/Users/user/project/src/entry.js": /* js */ ` From 8d62417bfe7ab15509c9a3c97588787821a527fc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:12:42 +0000 Subject: [PATCH 02/22] bundler: keep the browser-map subpath lookup safe across re-entry Resolving a "browser" remap target that is a bare package re-enters load_node_modules, which reuses the threadlocal scratch buffers the outer call's abs_path and esm.subpath live in. The helper now copies abs_path before the re-entrant lookup and runs esbuild's fallback to the file itself on that copy. The caller skips its own file lookup afterwards. The ESM subpath moves to a stack buffer. The same browser-map check now also runs in the auto-install lookup, and the onResolve plugin fallback path (run_resolver) creates the empty module for a disabled import like resolve_import_records does. ParseTask::init owns the empty-contents rule for disabled paths. The "(disabled):" pretty path is built with an exact-size allocation instead of a fixed path buffer. --- src/bundler/ParseTask.rs | 16 +++- src/bundler/bundle_v2.rs | 68 ++++++++++------- src/resolver/resolver.rs | 108 +++++++++++++++++++++------ test/bundler/bundler_browser.test.ts | 32 ++++++++ test/bundler/esbuild/extra.test.ts | 15 ++++ 5 files changed, 182 insertions(+), 57 deletions(-) diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 20f6ea89b3b1..e184725da0fc 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -255,12 +255,20 @@ impl ParseTask { // live `&mut BundleV2` coerced to `*mut`. let ctx_ref = unsafe { bun_ptr::ParentRef::from_raw_mut(ctx.cast::>()) }; let known_target = ctx_ref.get().transpiler().options.target; + let path = resolve_result.path_pair.primary; ParseTask { ctx: Some(ctx_ref), - path: resolve_result.path_pair.primary, - contents_or_fd: ContentsOrFd::Fd { - dir: resolve_result.dirname_fd, - file: resolve_result.file_fd, + path, + // A path the "browser" field disabled is an empty module. Nothing is read + // from disk: the path may not exist, and when it does, its contents are + // exactly what the field excludes. + contents_or_fd: if path.is_disabled { + ContentsOrFd::Contents(b"") + } else { + ContentsOrFd::Fd { + dir: resolve_result.dirname_fd, + file: resolve_result.file_fd, + } }, side_effects: resolve_result.primary_side_effects_data, // D042: resolver-side and bundler-side `jsx::Pragma` are the SAME diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 373602a9fcec..5fafc0c3b4df 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2575,23 +2575,29 @@ pub mod bv2_impl { let mut path: Fs::Path<'static> = match resolve_result.path() { Some(p) => *p, None => { - let record: &mut ImportRecord = &mut self.graph.ast.items_import_records_mut() - [import_record.importer_source_index as usize] - .as_mut_slice()[import_record.import_record_index as usize]; - // Disable failing packages from being printed. - // This may cause broken code to write. - // However, doing this means we tell them all the resolve errors - // Rather than just the first one. - record.path.is_disabled = true; - return; + // A disabled import becomes an empty module, as in + // `resolve_import_records`. The DevServer keeps the external form. + if self.dev_server.is_some() { + let record: &mut ImportRecord = + &mut self.graph.ast.items_import_records_mut() + [import_record.importer_source_index as usize] + .as_mut_slice() + [import_record.import_record_index as usize]; + record.path.is_disabled = true; + return; + } + resolve_result.path_pair.primary } }; + let is_disabled = path.is_disabled; if resolve_result.flags.is_external() { return; } - if path.pretty.as_ptr() == path.text.as_ptr() { + if is_disabled { + path = self.disabled_path_with_pretty(&path).expect("oom"); + } else if path.pretty.as_ptr() == path.text.as_ptr() { // TODO: outbase let rel = bun_paths::resolve_path::relative_platform::< bun_paths::resolve_path::platform::Loose, @@ -2605,24 +2611,35 @@ pub mod bv2_impl { unsafe { bun_ptr::detach_lifetime(self.arena().alloc_slice_copy(rel)) }; } path.assert_pretty_is_valid(); - path.assert_file_path_is_absolute(); + if !is_disabled { + // A disabled bare specifier ("nodeonly") has no file behind it. + path.assert_file_path_is_absolute(); + } // borrowck: get-then-put (instead of a single get-or-put) so the map // borrow doesn't span `enqueue_parse_task` (which needs `&mut self`). - if let Some(existing) = self.path_to_source_index_map(target).get(path.text) { + if let Some(existing) = self.path_to_source_index_map(target).get(path.source_key()) { out_source_index = Some(Index::init(existing)); } else { - path = self - .path_with_pretty_initialized(&path, target) - .expect("oom"); + if !is_disabled { + path = self + .path_with_pretty_initialized(&path, target) + .expect("oom"); + } // The borrowck-reshape above cloned // `path` out, so write the prettified path back so // `ParseTask::init(&resolve_result, ..)` (via `enqueue_parse_task`) // sees the relativized `pretty`. - if let Some(p) = resolve_result.path() { + if is_disabled { + resolve_result.path_pair.primary = path; + } else if let Some(p) = resolve_result.path() { *p = path; } let loader: Loader = 'brk: { + if is_disabled { + // Empty module, whatever the extension (or lack of one) says. + break 'brk Loader::Js; + } let record: &ImportRecord = &self.graph.ast.items_import_records() [import_record.importer_source_index as usize] .as_slice()[import_record.import_record_index as usize]; @@ -2649,7 +2666,7 @@ pub mod bv2_impl { ) .expect("oom"); self.path_to_source_index_map(target) - .put(path.text, idx) + .put(path.source_key(), idx) .expect("oom"); out_source_index = Some(Index::init(idx)); @@ -5970,7 +5987,6 @@ pub mod bv2_impl { path: &Fs::Path<'static>, ) -> Result, Error> { use crate::bun_fs::PathResolverExt as _; - use bun_io::Write as _; debug_assert!(path.is_disabled); // SAFETY: arena outlives the bundle pass; see `path_with_pretty_initialized`. @@ -5991,14 +6007,13 @@ pub mod bv2_impl { path.text }; - let mut pretty_buf = bun_paths::path_buffer_pool::get(); - let mut fbs = bun_io::FixedBufferStream::new_mut(&mut pretty_buf.0[..]); - let _ = fbs.write_all(b"(disabled):"); - let _ = fbs.write_all(rel); - let written = fbs.pos; + const PREFIX: &[u8] = b"(disabled):"; + let mut pretty = Vec::with_capacity(PREFIX.len() + rel.len()); + pretty.extend_from_slice(PREFIX); + pretty.extend_from_slice(rel); let mut with_pretty: Fs::Path<'_> = *path; - with_pretty.pretty = &pretty_buf.0[..written]; + with_pretty.pretty = &pretty; with_pretty.dupe_alloc_fix_pretty(bump).map_err(Into::into) } @@ -6857,11 +6872,6 @@ pub mod bv2_impl { resolve_task.jsx.development = transpiler.options.forced_jsx_development(); resolve_task.loader = Some(import_record_loader); - if is_disabled { - // Nothing is read from disk: the path may not exist, and when it - // does, its contents are exactly what the "browser" field excludes. - resolve_task.contents_or_fd = parse_task::ContentsOrFd::Contents(b""); - } *resolve_entry.value_ptr = resolve_task; if let Some(secondary) = &resolve_result.path_pair.secondary { if !secondary.is_disabled diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index af6a0b96db5c..ee4b85bf3dae 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -332,7 +332,6 @@ pub struct Bufs { pub(crate) tsconfig_match_full_buf2: PathBuffer, pub(crate) tsconfig_match_full_buf3: PathBuffer, - pub(crate) esm_subpath: [u8; 512], pub(crate) esm_absolute_package_path: PathBuffer, pub(crate) esm_absolute_package_path_joined: PathBuffer, @@ -2522,7 +2521,10 @@ impl<'a> Resolver<'a> { } } - let esm_ = crate::package_json::Package::parse(import_path, bufs!(esm_subpath)); + // Stack, not `bufs!`: a "browser" remap to another package re-enters this + // function while `esm.subpath` is still in use. + let mut esm_subpath_buf = [0u8; 512]; + let esm_ = crate::package_json::Package::parse(import_path, &mut esm_subpath_buf); let source_dir_info = dir_info; let mut any_node_modules_folder = false; @@ -2731,22 +2733,28 @@ impl<'a> Resolver<'a> { } // Check the "browser" map - if self.care_about_browser_field - && self - .load_package_subpath_from_browser_map( - pkg_dir_info, - abs_path, - kind, - global_cache, - out, - ) - .is_success() - { - self.extension_order = prev_extension_order; - if let Some(d) = self.debug_logs.as_mut() { - d.decrease_indent(); + if self.care_about_browser_field { + match self.load_package_subpath_from_browser_map( + pkg_dir_info, + abs_path, + kind, + global_cache, + out, + ) { + BrowserMapSubpath::NoEntry => {} + BrowserMapSubpath::Found => { + self.extension_order = prev_extension_order; + if let Some(d) = self.debug_logs.as_mut() { + d.decrease_indent(); + } + return MatchStatus::Success; + } + BrowserMapSubpath::NotFound => { + // `abs_path` is stale now; move on to the parent directory. + self.extension_order = prev_extension_order; + break 'node_modules; + } } - return MatchStatus::Success; } } } @@ -3231,9 +3239,24 @@ impl<'a> Resolver<'a> { )); } - if self - .load_as_file_or_directory(abs_path, kind, out) - .is_success() + // Check the "browser" map + let browser_map_subpath = if self.care_about_browser_field { + self.load_package_subpath_from_browser_map( + pkg_dir_info, + abs_path, + kind, + global_cache, + out, + ) + } else { + BrowserMapSubpath::NoEntry + }; + + if browser_map_subpath == BrowserMapSubpath::Found + || (browser_map_subpath == BrowserMapSubpath::NoEntry + && self + .load_as_file_or_directory(abs_path, kind, out) + .is_success()) { out.is_node_module = true; if let Some(d) = self.debug_logs.as_mut() { @@ -5014,6 +5037,11 @@ impl<'a> Resolver<'a> { /// `abs_path` is the subpath joined onto the package directory, without an /// implicit extension, so a key like "./foo/bar" matches even when /// "foo/bar.js" does not exist. + /// + /// Resolving a remap target can re-enter `load_node_modules`, which reuses + /// the threadlocal scratch buffers the caller's `abs_path` may live in. So on + /// `BrowserMapSubpath::NotFound` the fallback file lookup for `abs_path` has + /// already run here, and the caller must not use `abs_path` again. fn load_package_subpath_from_browser_map( &mut self, pkg_dir_info: DirInfoRef, @@ -5021,11 +5049,11 @@ impl<'a> Resolver<'a> { kind: ast::ImportKind, global_cache: GlobalCache, out: &mut MatchResult, - ) -> MatchStatus { + ) -> BrowserMapSubpath { let Some(remap) = self.check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>(pkg_dir_info, abs_path) else { - return MatchStatus::NotFound; + return BrowserMapSubpath::NoEntry; }; let browser_scope = pkg_dir_info .get_enclosing_browser_scope() @@ -5047,10 +5075,31 @@ impl<'a> Resolver<'a> { package_json: browser_scope.package_json().map(std::ptr::from_ref), ..Default::default() }; - return MatchStatus::Success; + return BrowserMapSubpath::Found; + } + + // The copy survives the re-entrant lookup below. + let mut abs_path_buf = bun_paths::path_buffer_pool::get(); + let Some(abs_path) = concat_into(&mut **abs_path_buf, &[abs_path]) else { + return BrowserMapSubpath::NotFound; + }; + + if self + .resolve_without_remapping(browser_scope, remap, kind, global_cache, out) + .is_success() + { + return BrowserMapSubpath::Found; } - self.resolve_without_remapping(browser_scope, remap, kind, global_cache, out) + // Like esbuild, a target that does not resolve falls back to the file itself. + if self + .load_as_file_or_directory(abs_path, kind, out) + .is_success() + { + return BrowserMapSubpath::Found; + } + + BrowserMapSubpath::NotFound } /// Looks `input_path` up in the "browser" map of the package that encloses @@ -6653,6 +6702,17 @@ enum ImplicitExtensions { Skip, } +/// Result of `Resolver::load_package_subpath_from_browser_map`. +#[derive(Clone, Copy, PartialEq, Eq)] +enum BrowserMapSubpath { + /// The map has no entry for the subpath. The caller runs node's file lookup. + NoEntry, + /// `out` holds the module: the remap target, or the disabled path. + Found, + /// The map has an entry, but neither its target nor the subpath itself resolves. + NotFound, +} + pub(crate) struct BrowserMapPath<'b> { pub(crate) remapped: &'static [u8], pub(crate) extension_order: &'b [Box<[u8]>], diff --git a/test/bundler/bundler_browser.test.ts b/test/bundler/bundler_browser.test.ts index 57b5bd5ca84c..2325589542b1 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -310,6 +310,38 @@ describe("bundler", () => { }, }); + // When an onResolve plugin matches but declines, the default resolver runs + // from the plugin path. A disabled import still becomes the shared empty + // module there, the same as without a plugin. + itBundled("browser/BrowserFieldDisabledModuleWithDecliningOnResolvePlugin", { + files: { + "/entry.js": /* js */ ` + import info from "pkg"; + console.log(JSON.stringify(info)); + `, + "/node_modules/pkg/package.json": `{ "main": "index.js", "browser": { "./node.js": false, "nodeonly": false } }`, + "/node_modules/pkg/index.js": /* js */ ` + import * as ns from "./node.js"; + import nodeonly from "nodeonly"; + export default [typeof ns, typeof ns.default, typeof nodeonly, require("./node.js") === require("./node.js")]; + `, + "/node_modules/pkg/node.js": `throw 'fail'`, + "/node_modules/nodeonly/index.js": `throw 'fail'`, + }, + target: "browser", + plugins: [ + { + name: "decline", + setup(builder) { + builder.onResolve({ filter: /.*/ }, () => undefined); + }, + }, + ], + run: { + stdout: `["object","object","object",true]`, + }, + }); + // #4928: a package.json "browser": {"": false} must win over the // builtin polyfill for --target browser. Packages set this to keep their // node-only code paths from dragging crypto/stream/buffer into browser bundles. diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index 9de11b944e5d..fda0b405793e 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -282,6 +282,21 @@ describe("bundler", () => { }, run: { stdout: "works" }, }); + // A remap to a package that does not exist falls back to the subpath's own + // file. When that is missing too, the lookup moves on to the parent + // node_modules, where the other copy of the package resolves the subpath + // through "exports". + itBundled("extra/BrowserFieldSubpathRemapToMissingPackage", { + files: { + "src/entry.js": `require('pkg/foo'); require('pkg2/bar')`, + "src/node_modules/pkg/package.json": `{ "browser": { "./foo": "missing-pkg" } }`, + "src/node_modules/pkg/foo.js": `console.log('fallback')`, + "src/node_modules/pkg2/package.json": `{ "browser": { "./bar": "missing-pkg" } }`, + "node_modules/pkg2/package.json": `{ "exports": { "./bar": "./bar.js" } }`, + "node_modules/pkg2/bar.js": `console.log('parent')`, + }, + run: { stdout: "fallback\nparent" }, + }); // From a subdirectory of the package, the bare path is matched against the // key spelled relative to the importer's directory. itBundled("extra/BrowserFieldBarePathFromSubdirectory", { From 8dc7bc686781938a0b9ead90b1b8e8408eedd9b0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:19:00 +0000 Subject: [PATCH 03/22] bundler: shorten the browser-field comments --- src/bundler/LinkerContext.rs | 5 +- src/bundler/ParseTask.rs | 4 +- src/bundler/PathToSourceIndexMap.rs | 5 +- src/bundler/bundle_v2.rs | 17 ++---- .../linker_context/scanImportsAndExports.rs | 4 +- src/js_printer/lib.rs | 3 +- src/resolver/package_json.rs | 6 +- src/resolver/resolver.rs | 58 ++++++------------- 8 files changed, 33 insertions(+), 69 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 92d521693b86..768426321ad7 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -3618,9 +3618,8 @@ impl<'a> LinkerContext<'a> { } } - // Warn about importing from a file that is known to not have any exports. - // A disabled module is empty too, but its default import is the `{}` - // exports object, so only a named import reads `undefined`. + // Warn about importing from a file that is known to not have any exports + // (for a disabled module, its default import is the `{}` exports object). let is_always_undefined = match status { ImportTrackerStatus::CjsWithoutExports => true, ImportTrackerStatus::Disabled => { diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index e184725da0fc..6eeade087930 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -259,9 +259,7 @@ impl ParseTask { ParseTask { ctx: Some(ctx_ref), path, - // A path the "browser" field disabled is an empty module. Nothing is read - // from disk: the path may not exist, and when it does, its contents are - // exactly what the field excludes. + // A disabled path is an empty module; nothing is read from disk. contents_or_fd: if path.is_disabled { ContentsOrFd::Contents(b"") } else { diff --git a/src/bundler/PathToSourceIndexMap.rs b/src/bundler/PathToSourceIndexMap.rs index 1f792dffbcce..720b864e3c50 100644 --- a/src/bundler/PathToSourceIndexMap.rs +++ b/src/bundler/PathToSourceIndexMap.rs @@ -8,9 +8,8 @@ use crate::IndexStringMap::IndexInt; pub trait PathLike { fn path_text(&self) -> &[u8]; - /// The key this module is filed under. A module the "browser" field - /// disabled is keyed by its `(disabled):...` pretty path: the same file can - /// also be in the graph enabled, and the two must stay separate modules. + /// The map key. A disabled module is keyed by its `(disabled):` pretty path, + /// so it stays separate from the same file enabled. fn source_key(&self) -> &[u8]; } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 5fafc0c3b4df..6203b4845b0d 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2575,8 +2575,7 @@ pub mod bv2_impl { let mut path: Fs::Path<'static> = match resolve_result.path() { Some(p) => *p, None => { - // A disabled import becomes an empty module, as in - // `resolve_import_records`. The DevServer keeps the external form. + // See `resolve_import_records`: the DevServer keeps disabled imports external. if self.dev_server.is_some() { let record: &mut ImportRecord = &mut self.graph.ast.items_import_records_mut() @@ -5978,10 +5977,8 @@ pub mod bv2_impl { Ok(out) } - /// The pretty path of a module the "browser" field disabled: /// `(disabled):`, the spelling esbuild uses. - /// `text` stays the resolved path (or the bare specifier when nothing - /// resolved), so the loader and the debugger still see where it came from. + /// `text` stays the resolved path, or the bare specifier when nothing resolved. fn disabled_path_with_pretty( &self, path: &Fs::Path<'static>, @@ -6671,11 +6668,8 @@ pub mod bv2_impl { // SAFETY: `resolve_result` outlives this borrow; see note above. Some(p) => unsafe { bun_ptr::detach_lifetime_mut::(p) }, None => { - // The "browser" field disabled this import (or it is a Node.js - // builtin with no browser polyfill). Like esbuild, it becomes an - // empty CommonJS module, so `require()` returns one `{}` and an - // `import` gets a namespace object. The DevServer keeps the - // external form, which its module runtime handles itself. + // Like esbuild, a disabled import is an empty CommonJS module. + // The DevServer keeps the external form. if self.dev_server.is_some() { import_record.path.is_disabled = true; import_record.source_index = Index::INVALID; @@ -6818,8 +6812,7 @@ pub mod bv2_impl { && target.is_server_side() && self.dev_server.is_none(); - // A disabled module gets the pretty path esbuild uses, - // "(disabled):", which is also its `source_key()`. + // The `(disabled):` pretty path is also the `source_key()`. if is_disabled { *path = self.disabled_path_with_pretty(path).expect("oom"); } diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index 4a164653333e..e21b9060ab11 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -178,9 +178,7 @@ pub(crate) fn scan_imports_and_exports( match record.kind { ImportKind::Stmt => { - // A module the "browser" field disabled is an empty CommonJS - // module whatever the import syntax: star and default imports get - // the `__toESM` namespace, named imports read `undefined` off it. + // A disabled module is an empty CommonJS module for every import syntax. if col_ref!(input_files)[other_file].path.is_disabled { col!(exports_kind)[other_file] = ExportsKind::Cjs; col!(flags)[other_file].wrap = WrapKind::Cjs; diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index d7ec385bd32e..322aa6b19177 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -6361,8 +6361,7 @@ pub(crate) mod __gated_printer { } } - /// A disabled module evaluates to an empty object, like the empty module - /// esbuild, webpack and Browserify substitute for `"browser": {"x": false}`. + /// A disabled module evaluates to an empty object, as in other bundlers. #[inline] fn print_disabled_import(&mut self) { self.print_whitespacer(ws!(b"({})")); diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index d64addd0baa3..ac3714daed06 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -598,10 +598,8 @@ impl PackageJSON { if let js_ast::ExprData::EObjectJSON(obj) = &browser_prop.expr.data { // The value is an object - // Remap all files in the browser field. Keys are stored as written: - // "./foo" and "foo" are different keys, and `check_browser_map` - // decides which spellings an import may match (Browserify lets - // "./foo" match `require("foo")`, but not "./foo.js"). + // Remap all files in the browser field. Keys are stored as written; + // `check_browser_map` decides which spellings an import may match. for prop in obj.get().properties() { let key: &[u8] = prop.key.slice(); diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index ee4b85bf3dae..c66530dc9d80 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1875,8 +1875,7 @@ impl<'a> Resolver<'a> { } if check_package { - // Check for external packages first: a package the user marked external is - // never replaced by a browser polyfill or disabled builtin. + // Check for external packages first, before the browser polyfills below. if self.opts.external.node_modules.count() > 0 // Imports like "process/" need to resolve to the filesystem, not a builtin && !import_path.ends_with(b"/") @@ -2521,8 +2520,7 @@ impl<'a> Resolver<'a> { } } - // Stack, not `bufs!`: a "browser" remap to another package re-enters this - // function while `esm.subpath` is still in use. + // On the stack: a "browser" remap can re-enter this function while `esm` is in use. let mut esm_subpath_buf = [0u8; 512]; let esm_ = crate::package_json::Package::parse(import_path, &mut esm_subpath_buf); @@ -5032,16 +5030,11 @@ impl<'a> Resolver<'a> { ) } - /// A package subpath (`require("pkg/foo/bar")`) is checked against the - /// package's "browser" map before node's file and directory lookup. - /// `abs_path` is the subpath joined onto the package directory, without an - /// implicit extension, so a key like "./foo/bar" matches even when - /// "foo/bar.js" does not exist. + /// Checks a package subpath (`require("pkg/foo/bar")`) against the package's + /// "browser" map before node's file lookup. `abs_path` has no implicit extension. /// - /// Resolving a remap target can re-enter `load_node_modules`, which reuses - /// the threadlocal scratch buffers the caller's `abs_path` may live in. So on - /// `BrowserMapSubpath::NotFound` the fallback file lookup for `abs_path` has - /// already run here, and the caller must not use `abs_path` again. + /// Resolving a remap target can re-enter `load_node_modules`, which reuses the + /// scratch buffer `abs_path` may live in, so the fallback lookup runs in here. fn load_package_subpath_from_browser_map( &mut self, pkg_dir_info: DirInfoRef, @@ -5078,7 +5071,6 @@ impl<'a> Resolver<'a> { return BrowserMapSubpath::Found; } - // The copy survives the re-entrant lookup below. let mut abs_path_buf = bun_paths::path_buffer_pool::get(); let Some(abs_path) = concat_into(&mut **abs_path_buf, &[abs_path]) else { return BrowserMapSubpath::NotFound; @@ -5091,7 +5083,7 @@ impl<'a> Resolver<'a> { return BrowserMapSubpath::Found; } - // Like esbuild, a target that does not resolve falls back to the file itself. + // Like esbuild, an unresolved target falls back to the file itself. if self .load_as_file_or_directory(abs_path, kind, out) .is_success() @@ -5102,14 +5094,9 @@ impl<'a> Resolver<'a> { BrowserMapSubpath::NotFound } - /// Looks `input_path` up in the "browser" map of the package that encloses - /// `resolve_dir_info`. For `PackagePath`, `resolve_dir_info` is the importer's - /// directory and `input_path` the specifier as written. For `AbsolutePath`, - /// `input_path` is the absolute path that is about to be loaded (without - /// implicit extensions). - /// - /// Returns the remap target relative to the browser scope, or an empty slice - /// when the entry is `false` (the module is disabled). + /// Looks `input_path` up in the "browser" map enclosing `resolve_dir_info` + /// (the importer's directory for `PackagePath`). Returns the remap target + /// relative to the browser scope, or an empty slice for a disabled entry. pub(crate) fn check_browser_map( &mut self, resolve_dir_info: DirInfoRef, @@ -5196,8 +5183,6 @@ impl<'a> Resolver<'a> { }; // Browserify lets "require('pkg')" match "./pkg" but not "./pkg.js". - // So don't add implicit extensions specifically in this place so we - // match Browserify's behavior. if checker.check_path(candidate, ImplicitExtensions::Skip) { return Some(checker.remapped); } @@ -6705,11 +6690,11 @@ enum ImplicitExtensions { /// Result of `Resolver::load_package_subpath_from_browser_map`. #[derive(Clone, Copy, PartialEq, Eq)] enum BrowserMapSubpath { - /// The map has no entry for the subpath. The caller runs node's file lookup. + /// No entry for the subpath; the caller runs node's file lookup. NoEntry, - /// `out` holds the module: the remap target, or the disabled path. + /// `out` holds the remap target or the disabled path. Found, - /// The map has an entry, but neither its target nor the subpath itself resolves. + /// An entry exists, but neither its target nor the subpath itself resolves. NotFound, } @@ -6720,9 +6705,7 @@ pub(crate) struct BrowserMapPath<'b> { } impl<'b> BrowserMapPath<'b> { - /// Looks up one key. On a match only `self.remapped` is updated; the - /// candidate may borrow threadlocal scratch buffers and must never be - /// stored back into the checker. + /// On a match only `self.remapped` is stored; `key` may be scratch storage. fn lookup(&mut self, key: &[u8]) -> bool { let Some(result) = self.map.get(key) else { return false; @@ -6756,9 +6739,8 @@ impl<'b> BrowserMapPath<'b> { false } - /// Probes the map the way Browserify and esbuild do: the path as written, - /// the path with an implicit extension, the path as a directory with an - /// "index" file, and that index with an implicit extension. + /// Probes the path as written, with an implicit extension, as a directory + /// with an "index" file, and that index with an implicit extension. pub(crate) fn check_path( &mut self, path_to_check: &[u8], @@ -6813,16 +6795,14 @@ fn concat_into<'a>(buf: &'a mut [u8], parts: &[&[u8]]) -> Option<&'a [u8]> { Some(&buf[..len]) } -/// `path` relative to the directory `base`, with forward slashes and no -/// trailing slash, so it can be compared against "browser" map keys. Empty when -/// `path` is `base` itself. `None` when `path` is not inside `base`. +/// `path` relative to the directory `base`, with forward slashes and no trailing +/// slash. Empty when `path` is `base` itself, `None` when it is not inside `base`. fn browser_map_relative_path<'a>(base: &[u8], path: &[u8], buf: &'a mut [u8]) -> Option<&'a [u8]> { if !path.starts_with(base) { return None; } let rest = &path[base.len()..]; - // The remainder has to start at a path component boundary: `base` may or - // may not carry its trailing separator. + // `base` may or may not carry its trailing separator. if !rest.is_empty() && !strings::char_is_any_slash(rest[0]) && !base.last().is_some_and(|&c| strings::char_is_any_slash(c)) From bc452810bde583c342292121b3d7662060b80b51 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:32:03 +0000 Subject: [PATCH 04/22] resolver: give the browser-map index probe its own scratch buffer The plugin fallback test also records which specifiers the declining onResolve callback saw. --- src/resolver/resolver.rs | 3 +- test/bundler/bundler_browser.test.ts | 60 ++++++++++++++++------------ 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index c66530dc9d80..7932c5acd992 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -353,6 +353,7 @@ pub struct Bufs { pub(crate) field_abs_path: PathBuffer, pub(crate) tsconfig_path_abs: PathBuffer, pub(crate) check_browser_map: PathBuffer, + pub(crate) browser_map_index: PathBuffer, pub(crate) remap_path: PathBuffer, pub(crate) load_as_file: PathBuffer, pub(crate) remap_path_trailing_slash: PathBuffer, @@ -6761,7 +6762,7 @@ impl<'b> BrowserMapPath<'b> { } // If that failed, try assuming this is a directory and looking for an "index" file - let index_buf = bufs!(tsconfig_base_url); + let index_buf = bufs!(browser_map_index); let trimmed = strings::trim_right(path_to_check, b"/"); let Some(index_path) = (if trimmed.is_empty() || trimmed == b"." { concat_into(index_buf, &[b"./index"]) diff --git a/test/bundler/bundler_browser.test.ts b/test/bundler/bundler_browser.test.ts index 2325589542b1..7cfda902c08d 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -313,33 +313,43 @@ describe("bundler", () => { // When an onResolve plugin matches but declines, the default resolver runs // from the plugin path. A disabled import still becomes the shared empty // module there, the same as without a plugin. - itBundled("browser/BrowserFieldDisabledModuleWithDecliningOnResolvePlugin", { - files: { - "/entry.js": /* js */ ` - import info from "pkg"; - console.log(JSON.stringify(info)); - `, - "/node_modules/pkg/package.json": `{ "main": "index.js", "browser": { "./node.js": false, "nodeonly": false } }`, - "/node_modules/pkg/index.js": /* js */ ` - import * as ns from "./node.js"; - import nodeonly from "nodeonly"; - export default [typeof ns, typeof ns.default, typeof nodeonly, require("./node.js") === require("./node.js")]; - `, - "/node_modules/pkg/node.js": `throw 'fail'`, - "/node_modules/nodeonly/index.js": `throw 'fail'`, - }, - target: "browser", - plugins: [ - { - name: "decline", - setup(builder) { - builder.onResolve({ filter: /.*/ }, () => undefined); + itBundled("browser/BrowserFieldDisabledModuleWithDecliningOnResolvePlugin", () => { + const declined: string[] = []; + return { + files: { + "/entry.js": /* js */ ` + import info from "pkg"; + console.log(JSON.stringify(info)); + `, + "/node_modules/pkg/package.json": `{ "main": "index.js", "browser": { "./node.js": false, "nodeonly": false } }`, + "/node_modules/pkg/index.js": /* js */ ` + import * as ns from "./node.js"; + import nodeonly from "nodeonly"; + export default [typeof ns, typeof ns.default, typeof nodeonly, require("./node.js") === require("./node.js")]; + `, + "/node_modules/pkg/node.js": `throw 'fail'`, + "/node_modules/nodeonly/index.js": `throw 'fail'`, + }, + target: "browser", + plugins: [ + { + name: "decline", + setup(builder) { + builder.onResolve({ filter: /.*/ }, args => { + declined.push(args.path); + return undefined; + }); + }, }, + ], + onAfterBundle() { + expect(declined).toContain("./node.js"); + expect(declined).toContain("nodeonly"); }, - ], - run: { - stdout: `["object","object","object",true]`, - }, + run: { + stdout: `["object","object","object",true]`, + }, + }; }); // #4928: a package.json "browser": {"": false} must win over the From 95aa1120fc806629f64687e946409b34112ab8d3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:07:33 +0000 Subject: [PATCH 05/22] bundler: test a package whose "module" entry is disabled by the browser field Covers the main-field fallback path: import gets the empty module and require gets "main". Stock bun fails the import with File not found "". --- test/bundler/esbuild/extra.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index fda0b405793e..1432bf7c4a80 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -353,6 +353,27 @@ describe("bundler", () => { }, run: { stdout: "object {}" }, }); + // A package whose "module" entry is disabled: `import` gets the empty module + // and `require` falls back to "main", as in esbuild. + const disabledModuleEntry = { + "node_modules/pkg/package.json": `{ "main": "./main.js", "module": "./module.js", "browser": { "./module.js": false } }`, + "node_modules/pkg/main.js": `module.exports = 'main'`, + "node_modules/pkg/module.js": `throw 'fail'`, + }; + itBundled("extra/BrowserFieldDisabledModuleEntryImport", { + files: { + "entry.js": `import v from 'pkg'; console.log(JSON.stringify([typeof v, v]))`, + ...disabledModuleEntry, + }, + run: { stdout: `["object",{}]` }, + }); + itBundled("extra/BrowserFieldDisabledModuleEntryRequire", { + files: { + "entry.js": `console.log(require('pkg'))`, + ...disabledModuleEntry, + }, + run: { stdout: "main" }, + }); // Test arbitrary module namespace identifier names // See https://github.com/tc39/ecma262/pull/2154 From 4da0dc078a1ca957008f5635923aff8cd3aa2971 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:22:27 +0000 Subject: [PATCH 06/22] bundler: key a disabled "module" entry by its own path, not by "main" When the "browser" field disables the "module" entry and "main" stays live, the resolver returns the disabled path as primary and "main" as the secondary. The bundler used the secondary as the module key while ParseTask parsed the primary, so an import and a require of the same package shared one slot that held the empty module. The import now keeps the disabled primary, with "main" as its secondary path, so scan_for_secondary_paths redirects it to "main" when a require bundles that file. This is esbuild's behavior for both cases. --- src/bundler/bundle_v2.rs | 52 +++++++++++++++++------------- test/bundler/esbuild/extra.test.ts | 9 ++++++ 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 6203b4845b0d..d78d9f2b2650 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2572,11 +2572,11 @@ pub mod bv2_impl { // borrowck: a `&mut` into `resolve_result` can't be held while // also reading other fields and re-borrowing `self`, // so we clone the active path out and operate on an owned value. - let mut path: Fs::Path<'static> = match resolve_result.path() { - Some(p) => *p, - None => { - // See `resolve_import_records`: the DevServer keeps disabled imports external. - if self.dev_server.is_some() { + let mut path: Fs::Path<'static> = if self.dev_server.is_some() { + // The DevServer keeps a disabled import external. + match resolve_result.path() { + Some(p) => *p, + None => { let record: &mut ImportRecord = &mut self.graph.ast.items_import_records_mut() [import_record.importer_source_index as usize] @@ -2585,8 +2585,10 @@ pub mod bv2_impl { record.path.is_disabled = true; return; } - resolve_result.path_pair.primary } + } else { + // See `resolve_import_records`: a disabled primary is the empty module. + resolve_result.path_pair.primary }; let is_disabled = path.is_disabled; @@ -2629,10 +2631,12 @@ pub mod bv2_impl { // `path` out, so write the prettified path back so // `ParseTask::init(&resolve_result, ..)` (via `enqueue_parse_task`) // sees the relativized `pretty`. - if is_disabled { + if self.dev_server.is_some() { + if let Some(p) = resolve_result.path() { + *p = path; + } + } else { resolve_result.path_pair.primary = path; - } else if let Some(p) = resolve_result.path() { - *p = path; } let loader: Loader = 'brk: { if is_disabled { @@ -6664,23 +6668,27 @@ pub mod bv2_impl { // would lock the whole struct while the loop body still needs to // read other `resolve_result` fields (`.flags`, `.path_pair`, // `.primary_side_effects_data`, `.jsx`). Detach via raw ptr. - let path: &mut Fs::Path = match resolve_result.path() { - // SAFETY: `resolve_result` outlives this borrow; see note above. - Some(p) => unsafe { bun_ptr::detach_lifetime_mut::(p) }, - None => { - // Like esbuild, a disabled import is an empty CommonJS module. - // The DevServer keeps the external form. - if self.dev_server.is_some() { + let path: &mut Fs::Path = if self.dev_server.is_some() { + // The DevServer keeps a disabled import external. + match resolve_result.path() { + // SAFETY: `resolve_result` outlives this borrow; see note above. + Some(p) => unsafe { bun_ptr::detach_lifetime_mut::(p) }, + None => { import_record.path.is_disabled = true; import_record.source_index = Index::INVALID; continue; } - // SAFETY: same as the `Some` arm. - unsafe { - bun_ptr::detach_lifetime_mut::( - &mut resolve_result.path_pair.primary, - ) - } + } + } else { + // Like esbuild, a disabled import is an empty CommonJS module. When the + // "browser" field disables the "module" entry, "main" stays the + // secondary: `scan_for_secondary_paths` redirects to it once a + // `require()` bundles that file. + // SAFETY: see note above. + unsafe { + bun_ptr::detach_lifetime_mut::( + &mut resolve_result.path_pair.primary, + ) } }; let is_disabled = path.is_disabled; diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index 1432bf7c4a80..8fa43f830071 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -374,6 +374,15 @@ describe("bundler", () => { }, run: { stdout: "main" }, }); + // Once a `require` bundles "main", the `import` is redirected to it as well + // (the dual package hazard rule), so the disabled module is not in the output. + itBundled("extra/BrowserFieldDisabledModuleEntryImportAndRequire", { + files: { + "entry.js": `import v from 'pkg'; console.log(JSON.stringify([typeof v, v]), require('pkg'))`, + ...disabledModuleEntry, + }, + run: { stdout: `["string","main"] main` }, + }); // Test arbitrary module namespace identifier names // See https://github.com/tc39/ecma262/pull/2154 From 7ddbc29d05045991dcc3fd7b590d8d38f451b4e9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:30:57 +0000 Subject: [PATCH 07/22] resolver: stop a "browser" map remap chain that loops back on itself A map target that is a package subpath re-enters load_node_modules, and that package's map can send it back ("./x": "pkg-a/x"). The chain had no bound, so `bun build --target=browser` overflowed the stack on such a package.json. Cut the chain at 16 hops. Past the limit the remap does not apply: the subpath's own file is used, or the import fails to resolve. --- src/resolver/resolver.rs | 26 +++++++++++++++++++++----- test/bundler/esbuild/extra.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 7932c5acd992..fc84ac31fbac 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -476,6 +476,11 @@ pub struct Resolver<'a> { /// Read the "browser" field in package.json files? /// For Bun's runtime, we don't. pub(crate) care_about_browser_field: bool, + /// How many "browser" map targets are being resolved through + /// `load_node_modules` on the current call stack. A map can send a subpath + /// back to itself (`"./x": "pkg/x"`), so the chain is cut at + /// `MAX_BROWSER_REMAP_DEPTH`. + browser_remap_depth: u8, pub debug_logs: Option, pub elapsed: u64, // tracing @@ -614,6 +619,7 @@ impl<'a> Resolver<'a> { care_about_bin_folder: from.care_about_bin_folder, care_about_scripts: from.care_about_scripts, care_about_browser_field: from.care_about_browser_field, + browser_remap_depth: 0, // `DebugLogs` owns Vecs — per-worker fresh. debug_logs: None, elapsed: 0, @@ -913,6 +919,7 @@ impl<'a> Resolver<'a> { log, extension_order: options::ExtOrder::DefaultDefault, care_about_browser_field, + browser_remap_depth: 0, care_about_bin_folder: false, care_about_scripts: false, debug_logs: None, @@ -5077,11 +5084,15 @@ impl<'a> Resolver<'a> { return BrowserMapSubpath::NotFound; }; - if self - .resolve_without_remapping(browser_scope, remap, kind, global_cache, out) - .is_success() - { - return BrowserMapSubpath::Found; + if self.browser_remap_depth < MAX_BROWSER_REMAP_DEPTH { + self.browser_remap_depth += 1; + let resolved = self + .resolve_without_remapping(browser_scope, remap, kind, global_cache, out) + .is_success(); + self.browser_remap_depth -= 1; + if resolved { + return BrowserMapSubpath::Found; + } } // Like esbuild, an unresolved target falls back to the file itself. @@ -6688,6 +6699,11 @@ enum ImplicitExtensions { Skip, } +/// Longest chain of "browser" map targets that are themselves package subpaths +/// (`"./x": "pkg-b/y"` in pkg-a, `"./y": "pkg-c/z"` in pkg-b, ...). A real chain +/// has one or two hops. A longer one is a cycle, and the remap stops applying. +const MAX_BROWSER_REMAP_DEPTH: u8 = 16; + /// Result of `Resolver::load_package_subpath_from_browser_map`. #[derive(Clone, Copy, PartialEq, Eq)] enum BrowserMapSubpath { diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index 8fa43f830071..69113cc5f8f7 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -297,6 +297,35 @@ describe("bundler", () => { }, run: { stdout: "fallback\nparent" }, }); + // A remap target that leads back to the same subpath is a cycle. The remap + // stops applying: the subpath's own file is used, or the import fails. + itBundled("extra/BrowserFieldSubpathRemapCycleToSelf", { + files: { + "entry.js": `require('pkg-a/x')`, + "node_modules/pkg-a/package.json": `{ "browser": { "./x": "pkg-a/x" } }`, + }, + bundleErrors: { + "/entry.js": [`Could not resolve: "pkg-a/x"`], + }, + }); + itBundled("extra/BrowserFieldSubpathRemapCycleToSelfWithFile", { + files: { + "entry.js": `require('pkg-a/x')`, + "node_modules/pkg-a/package.json": `{ "browser": { "./x": "pkg-a/x" } }`, + "node_modules/pkg-a/x.js": works, + }, + run: { stdout: "works" }, + }); + itBundled("extra/BrowserFieldSubpathRemapCycleBetweenPackages", { + files: { + "entry.js": `import 'pkg-a/x'`, + "node_modules/pkg-a/package.json": `{ "browser": { "./x": "pkg-b/y" } }`, + "node_modules/pkg-b/package.json": `{ "browser": { "./y": "pkg-a/x" } }`, + }, + bundleErrors: { + "/entry.js": [`Could not resolve: "pkg-a/x"`], + }, + }); // From a subdirectory of the package, the bare path is matched against the // key spelled relative to the importer's directory. itBundled("extra/BrowserFieldBarePathFromSubdirectory", { From 6524e93ac084a1751a32eac0adb05b7e96981d52 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:39:50 +0000 Subject: [PATCH 08/22] resolver: shorten the remap-depth comments --- src/resolver/resolver.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index fc84ac31fbac..4dffc4e3af34 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -476,10 +476,8 @@ pub struct Resolver<'a> { /// Read the "browser" field in package.json files? /// For Bun's runtime, we don't. pub(crate) care_about_browser_field: bool, - /// How many "browser" map targets are being resolved through - /// `load_node_modules` on the current call stack. A map can send a subpath - /// back to itself (`"./x": "pkg/x"`), so the chain is cut at - /// `MAX_BROWSER_REMAP_DEPTH`. + /// Nested "browser" map targets being resolved on the current call stack; + /// a map can send a subpath back to itself (`"./x": "pkg/x"`). browser_remap_depth: u8, pub debug_logs: Option, @@ -6699,9 +6697,8 @@ enum ImplicitExtensions { Skip, } -/// Longest chain of "browser" map targets that are themselves package subpaths -/// (`"./x": "pkg-b/y"` in pkg-a, `"./y": "pkg-c/z"` in pkg-b, ...). A real chain -/// has one or two hops. A longer one is a cycle, and the remap stops applying. +/// Longest chain of "browser" map targets that are themselves package subpaths. +/// A real chain has one or two hops; a longer one is a cycle, and the remap stops applying. const MAX_BROWSER_REMAP_DEPTH: u8 = 16; /// Result of `Resolver::load_package_subpath_from_browser_map`. From bdb312e2b6de487b79e13e9e054b7f379a9021e5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:57:16 +0000 Subject: [PATCH 09/22] bundler: port the browser-field tests from #36642 and #37532 #36642 and #37532 each fix one part of the "browser" field handling that this branch reworks. Their tests are added here so that closing those PRs loses no coverage: - packagejson/BrowserNodeModulesSubpathDisabled, SubpathToPackage, SubpathToPackageCycle and ExportsShadowsBrowserSubpath (#36642): a package subpath import whose map entry is false, a bare package, a cycle, or shadowed by "exports". - "browser map lookup of a bare specifier that does not fit a path buffer" in test/js/bun/resolve/resolve.test.ts (#37532): a specifier longer than a path buffer must fail to resolve, not abort the build. On main, four of the six packagejson cases and four of the five resolve.test.ts cases fail. All pass with this branch. --- test/bundler/esbuild/packagejson.test.ts | 86 ++++++++++++++++++ test/js/bun/resolve/resolve.test.ts | 109 +++++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/test/bundler/esbuild/packagejson.test.ts b/test/bundler/esbuild/packagejson.test.ts index 799a821d4472..c6b959057bec 100644 --- a/test/bundler/esbuild/packagejson.test.ts +++ b/test/bundler/esbuild/packagejson.test.ts @@ -630,6 +630,92 @@ describe("bundler", () => { `, }, }); + itBundled("packagejson/BrowserNodeModulesSubpathDisabled", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + const ns = require('demo-pkg/disabled') + console.log(JSON.stringify([ns.value])) + `, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "browser": { + "./disabled": false + } + } + `, + "/Users/user/project/node_modules/demo-pkg/disabled.js": `module.exports = {value: 'node'}`, + }, + run: { + stdout: "[null]", + }, + }); + itBundled("packagejson/BrowserNodeModulesSubpathToPackage", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import {value as a} from 'demo-pkg/to-pkg' + import {value as b} from 'demo-pkg/to-missing' + console.log(a) + console.log(b) + `, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "browser": { + "./to-pkg": "other-pkg", + "./to-missing": "missing-pkg" + } + } + `, + "/Users/user/project/node_modules/demo-pkg/to-pkg.js": `export let value = 'node'`, + "/Users/user/project/node_modules/demo-pkg/to-missing.js": `export let value = 'node'`, + "/Users/user/project/node_modules/other-pkg/package.json": `{"main": "./index.js"}`, + "/Users/user/project/node_modules/other-pkg/index.js": `export let value = 'other-pkg'`, + }, + run: { + stdout: ` + other-pkg + node + `, + }, + }); + itBundled("packagejson/BrowserNodeModulesSubpathToPackageCycle", { + files: { + "/Users/user/project/src/entry.js": `import 'pkg-a/x'`, + "/Users/user/project/node_modules/pkg-a/package.json": /* json */ ` + { + "name": "pkg-a", + "browser": { + "./x": "pkg-a/x" + } + } + `, + }, + bundleErrors: { + "/Users/user/project/src/entry.js": [`Could not resolve: "pkg-a/x". Maybe you need to "bun install"?`], + }, + }); + itBundled("packagejson/ExportsShadowsBrowserSubpath", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import {value} from 'demo-pkg/sub' + console.log(value) + `, + "/Users/user/project/node_modules/demo-pkg/package.json": /* json */ ` + { + "exports": { + "./sub": "./from-exports.js" + }, + "browser": { + "./sub": "./from-browser.js" + } + } + `, + "/Users/user/project/node_modules/demo-pkg/from-exports.js": `export let value = 'exports'`, + "/Users/user/project/node_modules/demo-pkg/from-browser.js": `export let value = 'browser'`, + }, + run: { + stdout: "exports", + }, + }); itBundled("packagejson/BrowserNoExt", { files: { "/Users/user/project/src/entry.js": /* js */ ` diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 554b7d8e1046..ad88cb695166 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -444,6 +444,115 @@ describe("When CJS and ESM are mixed", () => { }); }); +// When the importing package has a "browser" map, every bare specifier is probed +// against the map through fixed-size path buffers (as written, with "/index" +// appended, and with "./" prepended). A specifier that did not fit used to abort +// the build: +// panic: range end index 5000 out of range for slice of length 4096 +// Such a specifier cannot be remapped; it has to fall through to ordinary +// resolution exactly like it does when there is no "browser" map. +describe.concurrent("browser map lookup of a bare specifier that does not fit a path buffer", () => { + // MAX_PATH_BYTES in src/bun_core/util.rs + const maxPathBytes = { linux: 4096, darwin: 1024, win32: 32767 * 3 + 1 }[process.platform] ?? 1024; + const packageJsonWithBrowserMap = JSON.stringify({ name: "pkg", browser: { "./a.js": "./b.js" } }); + + async function bunBuild(dir: string, entry: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--target=browser", entry], + env: bunEnv, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + it.each([ + // The shortest specifier that overflowed: it fits on its own, `/index` does not. + ["specifier + '/index' does not fit", maxPathBytes - "/index".length], + // The specifier itself does not fit a path buffer. + ["specifier does not fit", maxPathBytes + 1000], + ])("bun build reports the unresolved import (%s)", async (_, length) => { + const specifier = Buffer.alloc(length, "x").toString(); + using dir = tempDir("resolver-browser-map-long-specifier", { + "package.json": packageJsonWithBrowserMap, + "entry.js": `import ${JSON.stringify(specifier)};`, + }); + + const { stdout, stderr, exitCode } = await bunBuild(String(dir), "entry.js"); + + expect(stderr).toContain(`error: Could not resolve: "${specifier}"`); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + }); + + it("Bun.build reports the unresolved import", async () => { + const specifier = Buffer.alloc(maxPathBytes + 1000, "x").toString(); + using dir = tempDir("resolver-browser-map-long-specifier-api", { + "package.json": packageJsonWithBrowserMap, + "entry.js": `import ${JSON.stringify(specifier)};`, + "build.js": ` + const result = await Bun.build({ entrypoints: ["./entry.js"], target: "browser", throw: false }); + console.log(JSON.stringify({ success: result.success, messages: result.logs.map(log => log.message) })); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build.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).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + success: false, + messages: [expect.stringContaining(`Could not resolve: "${specifier}"`)], + }); + expect(exitCode).toBe(0); + }); + + it("still resolves the specifier through node_modules", async () => { + // Ordinary resolution normalizes this down to "b". The browser map lookup sees the + // raw specifier, whose "./"-prefixed probe does not fit; it must give up on the + // remap instead of aborting. + const segment = "x/../"; + const segments = Math.ceil((maxPathBytes + 100) / segment.length); + const specifier = Buffer.alloc(segments * segment.length, segment).toString() + "b"; + using dir = tempDir("resolver-browser-map-long-specifier-node-modules", { + "package.json": packageJsonWithBrowserMap, + "node_modules/b/index.js": `export const x = "resolved through node_modules";`, + "entry.js": `import {x} from ${JSON.stringify(specifier)}; console.log(x);`, + }); + + const { stdout, stderr, exitCode } = await bunBuild(String(dir), "entry.js"); + + expect(stderr).toBe(""); + expect(stdout).toContain(`"resolved through node_modules"`); + expect(exitCode).toBe(0); + }); + + it("still remaps a long specifier that does fit", async () => { + // Long enough to matter on every platform, while staying inside macOS's 1024-byte + // path buffer. + const specifier = Buffer.alloc(1000, "x").toString(); + using dir = tempDir("resolver-browser-map-long-specifier-remapped", { + "package.json": JSON.stringify({ name: "pkg", browser: { [specifier]: "./shim.js" } }), + "shim.js": `export const x = "remapped by the browser map";`, + "entry.js": `import {x} from ${JSON.stringify(specifier)}; console.log(x);`, + }); + + const { stdout, stderr, exitCode } = await bunBuild(String(dir), "entry.js"); + + expect(stderr).toBe(""); + expect(stdout).toContain(`"remapped by the browser map"`); + expect(exitCode).toBe(0); + }); +}); + // The "browser" map resolver copied the normalized input path into a 512-byte // threadlocal buffer without a bounds check. Paths inside deep directory trees // can easily exceed 512 bytes while still being well under MAX_PATH_BYTES. From 82d5b2f32c261dee449585856c4ff593d9f96bd9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:19:18 +0000 Subject: [PATCH 10/22] bundler: DevServer uses "main" when the "browser" field disables "module" The DevServer took the live secondary path as the import's module key but ParseTask::init still read the disabled primary, so "main" was parsed as an empty module. The secondary is now promoted to primary before the parse task is created. The ESM subpath scratch moves to a pooled path buffer so a subpath longer than 512 bytes is not dropped. --- src/bundler/bundle_v2.rs | 60 ++++++++++++++++-------------------- src/resolver/resolver.rs | 6 ++-- test/bake/dev/bundle.test.ts | 24 +++++++++++++++ 3 files changed, 54 insertions(+), 36 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index d78d9f2b2650..bf2e1cce3728 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2572,11 +2572,14 @@ pub mod bv2_impl { // borrowck: a `&mut` into `resolve_result` can't be held while // also reading other fields and re-borrowing `self`, // so we clone the active path out and operate on an owned value. - let mut path: Fs::Path<'static> = if self.dev_server.is_some() { - // The DevServer keeps a disabled import external. - match resolve_result.path() { - Some(p) => *p, - None => { + if self.dev_server.is_some() && resolve_result.path_pair.primary.is_disabled { + // See `resolve_import_records`. + match resolve_result.path_pair.secondary { + Some(secondary) if !secondary.is_disabled => { + resolve_result.path_pair.primary = secondary; + resolve_result.path_pair.secondary = None; + } + _ => { let record: &mut ImportRecord = &mut self.graph.ast.items_import_records_mut() [import_record.importer_source_index as usize] @@ -2586,10 +2589,8 @@ pub mod bv2_impl { return; } } - } else { - // See `resolve_import_records`: a disabled primary is the empty module. - resolve_result.path_pair.primary - }; + } + let mut path: Fs::Path<'static> = resolve_result.path_pair.primary; let is_disabled = path.is_disabled; if resolve_result.flags.is_external() { @@ -2631,13 +2632,7 @@ pub mod bv2_impl { // `path` out, so write the prettified path back so // `ParseTask::init(&resolve_result, ..)` (via `enqueue_parse_task`) // sees the relativized `pretty`. - if self.dev_server.is_some() { - if let Some(p) = resolve_result.path() { - *p = path; - } - } else { - resolve_result.path_pair.primary = path; - } + resolve_result.path_pair.primary = path; let loader: Loader = 'brk: { if is_disabled { // Empty module, whatever the extension (or lack of one) says. @@ -6668,28 +6663,27 @@ pub mod bv2_impl { // would lock the whole struct while the loop body still needs to // read other `resolve_result` fields (`.flags`, `.path_pair`, // `.primary_side_effects_data`, `.jsx`). Detach via raw ptr. - let path: &mut Fs::Path = if self.dev_server.is_some() { - // The DevServer keeps a disabled import external. - match resolve_result.path() { - // SAFETY: `resolve_result` outlives this borrow; see note above. - Some(p) => unsafe { bun_ptr::detach_lifetime_mut::(p) }, - None => { + if self.dev_server.is_some() && resolve_result.path_pair.primary.is_disabled { + // The DevServer keeps a disabled import external. A disabled "module" + // entry with a live "main" uses "main". + match resolve_result.path_pair.secondary { + Some(secondary) if !secondary.is_disabled => { + resolve_result.path_pair.primary = secondary; + resolve_result.path_pair.secondary = None; + } + _ => { import_record.path.is_disabled = true; import_record.source_index = Index::INVALID; continue; } } - } else { - // Like esbuild, a disabled import is an empty CommonJS module. When the - // "browser" field disables the "module" entry, "main" stays the - // secondary: `scan_for_secondary_paths` redirects to it once a - // `require()` bundles that file. - // SAFETY: see note above. - unsafe { - bun_ptr::detach_lifetime_mut::( - &mut resolve_result.path_pair.primary, - ) - } + } + // Like esbuild, a disabled import is an empty CommonJS module. When the + // "browser" field disables the "module" entry, "main" stays the secondary: + // `scan_for_secondary_paths` redirects to it once a `require()` bundles it. + // SAFETY: `resolve_result` outlives this borrow; see note above. + let path: &mut Fs::Path = unsafe { + bun_ptr::detach_lifetime_mut::(&mut resolve_result.path_pair.primary) }; let is_disabled = path.is_disabled; diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 4dffc4e3af34..bddca6a669c8 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -2526,9 +2526,9 @@ impl<'a> Resolver<'a> { } } - // On the stack: a "browser" remap can re-enter this function while `esm` is in use. - let mut esm_subpath_buf = [0u8; 512]; - let esm_ = crate::package_json::Package::parse(import_path, &mut esm_subpath_buf); + // Pooled, not `bufs!`: a "browser" remap can re-enter this function while `esm` is in use. + let mut esm_subpath_buf = bun_paths::path_buffer_pool::get(); + let esm_ = crate::package_json::Package::parse(import_path, &mut **esm_subpath_buf); let source_dir_info = dir_info; let mut any_node_modules_folder = false; diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 6c6d3657029c..0a1709402e5a 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -865,3 +865,27 @@ devTest("barrel optimization: namespace re-export cycle through a star-exported await c.expectMessage("result: object Y KEEP DEEP OTHER"); }, }); +devTest('"browser" field: a disabled "module" entry falls back to "main"', { + files: { + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.ts"], + }), + "index.ts": ` + import value from 'pkg'; + console.log('value: ' + JSON.stringify(value)); + `, + "node_modules/pkg/package.json": JSON.stringify({ + name: "pkg", + main: "./main.js", + module: "./module.js", + browser: { "./module.js": false }, + }), + "node_modules/pkg/main.js": `module.exports = 'main';`, + "node_modules/pkg/module.js": `throw new Error('fail');`, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage('value: "main"'); + }, +}); From 53bf0758c8a247139601dcadd7c4f51cb55d1f39 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:39:55 +0000 Subject: [PATCH 11/22] bundler: test every import form of a node: builtin without a browser polyfill require() twice, default, namespace and named import, and import() all read one shared empty module under --target=browser, for esm, cjs and iife output with and without minification. import() of such a builtin no longer stays in the output as a request the browser cannot serve. --- test/bundler/bundler_browser.test.ts | 94 ++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/test/bundler/bundler_browser.test.ts b/test/bundler/bundler_browser.test.ts index ba57149396f5..4c1e103501a1 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -101,6 +101,100 @@ describe("bundler", () => { api.expectFile("out.js").not.toInclude("import "); }, }); + // A builtin without a browser polyfill is one shared empty module, whatever + // syntax reaches it: require(), import, import * as, and import(). + for (const format of ["esm", "cjs", "iife"] as const) { + for (const minify of [false, true]) { + itBundled(`browser/NodeBuiltinDisabledShape${format.toUpperCase()}${minify ? "Minified" : ""}`, { + files: { + "/entry.js": /* js */ ` + import c from "./c.cjs"; + import d from "node:fs"; + import * as ns from "node:fs"; + import { readFileSync } from "node:fs"; + import("node:fs").then(m => { + console.log(JSON.stringify({ + requireTwiceIdentical: c.a === c.b, + typeofRequire: typeof c.a, + requireKeys: Object.keys(c.a).length, + requireIsDefault: c.a === d, + typeofDefault: typeof d, + typeofNamespace: typeof ns, + namespaceDefaultIsDefault: ns.default === d, + typeofNamed: typeof readFileSync, + dynamicDefaultIsDefault: m.default === d, + typeofDynamicNamed: typeof m.readFileSync, + })); + }); + `, + "/c.cjs": /* js */ ` + exports.a = require("node:fs"); + exports.b = require("node:fs"); + `, + }, + target: "browser", + format, + minifySyntax: minify, + minifyIdentifiers: minify, + minifyWhitespace: minify, + onAfterBundle(api) { + api.expectFile("out.js").not.toInclude(`import("node:fs")`); + api.expectFile("out.js").not.toInclude(`import('node:fs')`); + }, + run: { + stdout: JSON.stringify({ + requireTwiceIdentical: true, + typeofRequire: "object", + requireKeys: 0, + requireIsDefault: true, + typeofDefault: "object", + typeofNamespace: "object", + namespaceDefaultIsDefault: true, + typeofNamed: "undefined", + dynamicDefaultIsDefault: true, + typeofDynamicNamed: "undefined", + }), + }, + }); + } + } + // import() of such a builtin resolves to the same empty module instead of + // staying in the output as a request the browser cannot serve. + itBundled("browser/NodeBuiltinDisabledDynamicImport", { + files: { + "/entry.js": /* js */ ` + const specifiers = ["node:fs", "fs", "fs/promises", "node:fs/promises", "node:child_process", "node:vm"]; + const results = await Promise.all([ + import("node:fs"), + import("fs"), + import("fs/promises"), + import("node:fs/promises"), + import("node:child_process"), + import("node:vm"), + ]); + for (let i = 0; i < results.length; i++) { + const m = results[i]; + console.log(specifiers[i], typeof m.default, Object.keys(m.default).length, Object.keys(m).join(",")); + } + console.log(results[0].default === results[1].default, results[2].default === results[3].default); + `, + }, + target: "browser", + onAfterBundle(api) { + api.expectFile("out.js").not.toInclude(`import("`); + }, + run: { + stdout: ` + node:fs object 0 default + fs object 0 default + fs/promises object 0 default + node:fs/promises object 0 default + node:child_process object 0 default + node:vm object 0 default + true true + `, + }, + }); itBundled("browser/NodeTTY", { files: { "/entry.js": /* js */ ` From f8cd8d06a26a411f0a5aa23d515f3ce71c705e6d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:00:54 +0000 Subject: [PATCH 12/22] resolver: apply a package's "browser" map to a subpath import found through NODE_PATH The NODE_PATH lookup went straight to the file lookup, so `pkg/x.js` with `"browser": {"./x.js": "./a.js"}` bundled x.js once the lookup after resolution was gone. It now runs the same check as the node_modules walk, as esbuild does for both. Tests: a scoped package's subpath import (never remapped before, because the lookup after resolution took `node_modules/@scope` as the package directory) and a package found through NODE_PATH. --- src/resolver/resolver.rs | 28 +++++++++++++++++++++++ test/bundler/esbuild/packagejson.test.ts | 29 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index acf46d9d45c0..c915c6920a56 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -2853,6 +2853,34 @@ impl<'a> Resolver<'a> { bstr::BStr::new(abs_path) )); } + + // Check the "browser" map + if self.care_about_browser_field + && let Some(esm) = esm_.as_ref() + && let Some(abs_package_path) = self + .fs_ref() + .abs_buf_checked(&[path, esm.name], bufs!(esm_absolute_package_path)) + && let Ok(Some(pkg_dir_info)) = self.dir_info_cached(abs_package_path) + { + match self.load_package_subpath_from_browser_map( + pkg_dir_info, + abs_path, + kind, + global_cache, + out, + ) { + BrowserMapSubpath::NoEntry => {} + BrowserMapSubpath::Found => { + if let Some(d) = self.debug_logs.as_mut() { + d.decrease_indent(); + } + return MatchStatus::Success; + } + // `abs_path` is stale now; move on to the next directory. + BrowserMapSubpath::NotFound => continue, + } + } + if self .load_as_file_or_directory(abs_path, kind, out) .is_success() diff --git a/test/bundler/esbuild/packagejson.test.ts b/test/bundler/esbuild/packagejson.test.ts index 48c497c1d3af..34c9aa2dccc6 100644 --- a/test/bundler/esbuild/packagejson.test.ts +++ b/test/bundler/esbuild/packagejson.test.ts @@ -2539,6 +2539,35 @@ describe("bundler", () => { stdout: "scoped-browser scoped-browser-no-ext unscoped-browser {}", }, }); + // A package found through NODE_PATH has its "browser" map applied to subpath + // imports too. + itBundled("packagejson/BrowserMapNodePathSubpath", { + files: { + "/Users/user/project/src/entry.js": /* js */ ` + import sub from 'demo-pkg/x.js' + import scoped from '@scope/pkg/x' + import main from 'demo-pkg' + console.log(sub, scoped, main, JSON.stringify(require('demo-pkg/node.js'))) + `, + "/Users/user/global/demo-pkg/package.json": `{ "main": "./main.js", "browser": { "./x.js": "./x-browser.js", "./main.js": "./main-browser.js", "./node.js": false } }`, + "/Users/user/global/demo-pkg/x.js": `module.exports = 'node'`, + "/Users/user/global/demo-pkg/x-browser.js": `module.exports = 'browser'`, + "/Users/user/global/demo-pkg/main.js": `module.exports = 'main-node'`, + "/Users/user/global/demo-pkg/main-browser.js": `module.exports = 'main-browser'`, + "/Users/user/global/demo-pkg/node.js": `throw 'fail'`, + "/Users/user/global/@scope/pkg/package.json": `{ "browser": { "./x": "./x-browser.js" } }`, + "/Users/user/global/@scope/pkg/x.js": `module.exports = 'scoped-node'`, + "/Users/user/global/@scope/pkg/x-browser.js": `module.exports = 'scoped-browser'`, + }, + backend: "cli", + target: "browser", + env: { + NODE_PATH: "{{root}}/Users/user/global", + }, + run: { + stdout: "browser scoped-browser main-browser {}", + }, + }); // When "main" points at a directory, the implicit "

/index" is checked // against the browser map before extension resolution. itBundled("packagejson/BrowserMapMainFieldDirIndexNoExt", { From 1a344ba34a9d79225c748dd1f7916b5d13eb23b0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:12:11 +0000 Subject: [PATCH 13/22] resolver: keep the ESM subpath scratch in Bufs; restore it after a re-entrant lookup The pooled buffer added a pool round trip to every node_modules lookup on every target. The only code that re-enters load_node_modules while a caller still holds `esm.subpath` is the "browser" remap target lookup, so that one place saves the 512-byte scratch and puts it back. The missing-package test now uses a target with a subpath of its own, which is what overwrites the scratch. It fails without the restore. --- src/resolver/resolver.rs | 9 ++++++--- test/bundler/esbuild/extra.test.ts | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index c915c6920a56..e76b29c410c7 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -330,6 +330,7 @@ pub struct Bufs { pub(crate) tsconfig_match_full_buf: PathBuffer, pub(crate) tsconfig_match_full_buf3: PathBuffer, + pub(crate) esm_subpath: [u8; 512], pub(crate) esm_absolute_package_path: PathBuffer, pub(crate) esm_absolute_package_path_joined: PathBuffer, @@ -2567,9 +2568,7 @@ impl<'a> Resolver<'a> { } } - // Pooled, not `bufs!`: a "browser" remap can re-enter this function while `esm` is in use. - let mut esm_subpath_buf = bun_paths::path_buffer_pool::get(); - let esm_ = crate::package_json::Package::parse(import_path, &mut **esm_subpath_buf); + let esm_ = crate::package_json::Package::parse(import_path, bufs!(esm_subpath)); let source_dir_info = dir_info; let mut any_node_modules_folder = false; @@ -5171,11 +5170,15 @@ impl<'a> Resolver<'a> { }; if self.browser_remap_depth < MAX_BROWSER_REMAP_DEPTH { + // A package-path target re-enters `load_node_modules`, which parses into the + // scratch the caller's `esm.subpath` points at. + let caller_esm_subpath = *bufs!(esm_subpath); self.browser_remap_depth += 1; let resolved = self .resolve_without_remapping(browser_scope, remap, kind, global_cache, out) .is_success(); self.browser_remap_depth -= 1; + *bufs!(esm_subpath) = caller_esm_subpath; if resolved { return BrowserMapSubpath::Found; } diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index 8494299dc650..e62f498a4801 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -285,13 +285,14 @@ describe("bundler", () => { // A remap to a package that does not exist falls back to the subpath's own // file. When that is missing too, the lookup moves on to the parent // node_modules, where the other copy of the package resolves the subpath - // through "exports". + // through "exports". The target has a subpath of its own, so the lookup of + // the target must leave the subpath of the import it interrupts intact. itBundled("extra/BrowserFieldSubpathRemapToMissingPackage", { files: { "src/entry.js": `require('pkg/foo'); require('pkg2/bar')`, - "src/node_modules/pkg/package.json": `{ "browser": { "./foo": "missing-pkg" } }`, + "src/node_modules/pkg/package.json": `{ "browser": { "./foo": "missing-pkg/some/other/subpath" } }`, "src/node_modules/pkg/foo.js": `console.log('fallback')`, - "src/node_modules/pkg2/package.json": `{ "browser": { "./bar": "missing-pkg" } }`, + "src/node_modules/pkg2/package.json": `{ "browser": { "./bar": "missing-pkg/some/other/subpath" } }`, "node_modules/pkg2/package.json": `{ "exports": { "./bar": "./bar.js" } }`, "node_modules/pkg2/bar.js": `console.log('parent')`, }, From 2a4595bb745a571d149af47405fe027942cb6290 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:45:46 +0000 Subject: [PATCH 14/22] resolver: shorten a comment --- src/resolver/resolver.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index e76b29c410c7..6e050d83ecd9 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -5170,8 +5170,7 @@ impl<'a> Resolver<'a> { }; if self.browser_remap_depth < MAX_BROWSER_REMAP_DEPTH { - // A package-path target re-enters `load_node_modules`, which parses into the - // scratch the caller's `esm.subpath` points at. + // The nested lookup parses into the scratch the caller's `esm.subpath` points at. let caller_esm_subpath = *bufs!(esm_subpath); self.browser_remap_depth += 1; let resolved = self From 76bd6d91a19b37f5117a2f4fded63408489d27fa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:02:24 +0000 Subject: [PATCH 15/22] bundler: test import() of a disabled module with code splitting --- test/bundler/bundler_browser.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/bundler/bundler_browser.test.ts b/test/bundler/bundler_browser.test.ts index 4c1e103501a1..21256bed37ce 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -195,6 +195,33 @@ describe("bundler", () => { `, }, }); + // With code splitting, a disabled module that only import() reaches gets its + // own chunk. The promise resolves to the same shape as without splitting. + itBundled("browser/DisabledModuleDynamicImportSplitting", { + files: { + "/entry.js": /* js */ ` + import { load } from "pkg"; + const builtin = await import("node:fs"); + const file = await load(); + for (const m of [builtin, file]) { + console.log(typeof m.default, Object.keys(m.default).length, Object.keys(m).join(",")); + } + `, + "/node_modules/pkg/package.json": `{ "main": "./index.js", "browser": { "./node.js": false } }`, + "/node_modules/pkg/index.js": `export const load = () => import("./node.js");`, + "/node_modules/pkg/node.js": `throw new Error("fail");`, + }, + target: "browser", + splitting: true, + outdir: "/out", + run: { + file: "/out/entry.js", + stdout: ` + object 0 default + object 0 default + `, + }, + }); itBundled("browser/NodeTTY", { files: { "/entry.js": /* js */ ` From 4a2f76e9afd0f505ac12a1a5abc3392642336a9a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:34:54 +0000 Subject: [PATCH 16/22] bundler: only a JavaScript import of a disabled path becomes the empty module Four cases kept what main does for a path the "browser" field disables: - A stylesheet's `@import` or `url()` of one stays as written. It was given the empty JavaScript module and the build failed with `Cannot import a ".js" file into a CSS file`. The same holds for an HTML file and for `require.resolve`. - No onLoad or onBeforeParse plugin runs for a disabled module. Its path is the real file, so a plugin that reads from disk put the Node-only file back into the bundle, or failed on a disabled bare name. - `bun build --check` checks the file as written, not the empty module. - An external "fs" also covers "node:fs". --- src/bundler/ParseTask.rs | 2 +- src/bundler/bundle_v2.rs | 71 ++++++++++++++------- src/resolver/resolver.rs | 56 ++++++++++------- test/bundler/bundler_browser.test.ts | 94 +++++++++++++++++++++++++++- test/bundler/esbuild/extra.test.ts | 3 + 5 files changed, 176 insertions(+), 50 deletions(-) diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 9119741eca3d..113f2306cdba 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -1647,7 +1647,7 @@ pub mod parse_worker { break 'brk false; } - if file_path.namespace == b"node" { + if file_path.namespace == b"node" || file_path.is_disabled { break 'brk false; } true diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 779be2fed278..c288acba6b6d 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2576,9 +2576,12 @@ pub mod bv2_impl { ); } } + // A disabled module is empty in the bundle only. Its file is checked as written. let mut entry_points = (named.iter().copied()) .filter(|&index| { - loaders[index].is_javascript_like() && sources[index].path.is_file() + loaders[index].is_javascript_like() + && sources[index].path.is_file() + && !sources[index].path.is_disabled }) .map(|index| sources[index].path.text); // The types are those of what is written, as in `bun check`, not of what a plugin makes @@ -2587,6 +2590,7 @@ pub mod bv2_impl { .filter(|((source, loader), flags)| { loader.is_javascript_like_or_json() && source.path.is_file() + && !source.path.is_disabled && !(flags.contains(crate::Graph::InputFileFlags::IS_LOADED_BY_PLUGIN) && bun_sys::exists(source.path.text)) }) @@ -2923,21 +2927,26 @@ pub mod bv2_impl { // borrowck: a `&mut` into `resolve_result` can't be held while // also reading other fields and re-borrowing `self`, // so we clone the active path out and operate on an owned value. - if self.dev_server.is_some() && resolve_result.path_pair.primary.is_disabled { - // See `resolve_import_records`. - match resolve_result.path_pair.secondary { - Some(secondary) if !secondary.is_disabled => { - resolve_result.path_pair.primary = secondary; - resolve_result.path_pair.secondary = None; - } - _ => { - let record: &mut ImportRecord = - &mut self.graph.ast.items_import_records_mut() - [import_record.importer_source_index as usize] - .as_mut_slice() - [import_record.import_record_index as usize]; - record.path.is_disabled = true; - return; + if resolve_result.path_pair.primary.is_disabled { + let importer = import_record.importer_source_index as usize; + let record_index = import_record.import_record_index as usize; + let importer_loader = self.graph.input_files.items_loader()[importer]; + let kind = + self.graph.ast.items_import_records()[importer].as_slice()[record_index].kind; + if !self.disabled_import_is_empty_module(importer_loader, kind) { + // See `resolve_import_records`. + match resolve_result.path_pair.secondary { + Some(secondary) if !secondary.is_disabled => { + resolve_result.path_pair.primary = secondary; + resolve_result.path_pair.secondary = None; + } + _ => { + self.graph.ast.items_import_records_mut()[importer].as_mut_slice() + [record_index] + .path + .is_disabled = true; + return; + } } } } @@ -6411,6 +6420,10 @@ pub mod bv2_impl { } pub(crate) fn enqueue_on_load_plugin_if_needed(&mut self, parse: &mut ParseTask) -> bool { + // A disabled module is the empty module. No plugin loads its path. + if parse.path.is_disabled { + return false; + } let had_matches = self.enqueue_on_load_plugin_if_needed_impl(parse); if had_matches { return true; @@ -6491,6 +6504,18 @@ pub mod bv2_impl { Ok(out) } + /// Like esbuild, a JavaScript import of a disabled path is an empty CommonJS + /// module. Every other import of one (CSS, HTML, `require.resolve`) stays + /// external, and so does every import in the DevServer. + fn disabled_import_is_empty_module(&self, importer: Loader, kind: ImportKind) -> bool { + self.dev_server.is_none() + && importer.is_javascript_like() + && matches!( + kind, + ImportKind::Stmt | ImportKind::Require | ImportKind::Dynamic + ) + } + /// `(disabled):`, the spelling esbuild uses. /// `text` stays the resolved path, or the bare specifier when nothing resolved. fn disabled_path_with_pretty( @@ -7217,9 +7242,11 @@ pub mod bv2_impl { // would lock the whole struct while the loop body still needs to // read other `resolve_result` fields (`.flags`, `.path_pair`, // `.primary_side_effects_data`, `.jsx`). Detach via raw ptr. - if self.dev_server.is_some() && resolve_result.path_pair.primary.is_disabled { - // The DevServer keeps a disabled import external. A disabled "module" - // entry with a live "main" uses "main". + if resolve_result.path_pair.primary.is_disabled + && !self.disabled_import_is_empty_module(loader, import_record.kind) + { + // The import stays external. A disabled "module" entry with a live + // "main" uses "main". match resolve_result.path_pair.secondary { Some(secondary) if !secondary.is_disabled => { resolve_result.path_pair.primary = secondary; @@ -7232,9 +7259,9 @@ pub mod bv2_impl { } } } - // Like esbuild, a disabled import is an empty CommonJS module. When the - // "browser" field disables the "module" entry, "main" stays the secondary: - // `scan_for_secondary_paths` redirects to it once a `require()` bundles it. + // When the "browser" field disables the "module" entry, "main" stays the + // secondary: `scan_for_secondary_paths` redirects to it once a `require()` + // bundles it. // SAFETY: `resolve_result` outlives this borrow; see note above. let path: &mut Fs::Path = unsafe { bun_ptr::detach_lifetime_mut::(&mut resolve_result.path_pair.primary) diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 6e050d83ecd9..836b9b39b85a 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1918,31 +1918,27 @@ impl<'a> Resolver<'a> { // Imports like "process/" need to resolve to the filesystem, not a builtin && !import_path.ends_with(b"/") { - let mut query = import_path; - loop { - if self.opts.external.node_modules.contains(query) { - if let Some(debug) = self.debug_logs.as_mut() { - debug.add_note_fmt(format_args!( - "The path \"{}\" was marked as external by the user", - bstr::BStr::new(query) - )); - } - return ResultUnion::Success(Result { - path_pair: PathPair { - primary: Path::init(query), - secondary: None, - }, - flags: ResultFlags::IS_EXTERNAL, - ..Default::default() - }); + // An external "fs" also covers "node:fs". + let external = self.user_external(import_path).or_else(|| { + import_path + .strip_prefix(b"node:") + .and_then(|bare| self.user_external(bare)) + }); + if let Some(query) = external { + if let Some(debug) = self.debug_logs.as_mut() { + debug.add_note_fmt(format_args!( + "The path \"{}\" was marked as external by the user", + bstr::BStr::new(query) + )); } - - // If the module "foo" has been marked as external, we also want to treat - // paths into that module such as "foo/bar" as external too. - let Some(slash) = strings::last_index_of_char(query, b'/') else { - break; - }; - query = &query[0..slash]; + return ResultUnion::Success(Result { + path_pair: PathPair { + primary: Path::init(query), + secondary: None, + }, + flags: ResultFlags::IS_EXTERNAL, + ..Default::default() + }); } } @@ -5123,6 +5119,18 @@ impl<'a> Resolver<'a> { ) } + /// The user's external entry that covers `import_path`: the path itself, or + /// the package it is a path into ("foo" covers "foo/bar"). + fn user_external<'p>(&self, import_path: &'p [u8]) -> Option<&'p [u8]> { + let mut query = import_path; + loop { + if self.opts.external.node_modules.contains(query) { + return Some(query); + } + query = &query[..strings::last_index_of_char(query, b'/')?]; + } + } + /// Checks a package subpath (`require("pkg/foo/bar")`) against the package's /// "browser" map before node's file lookup. `abs_path` has no implicit extension. /// diff --git a/test/bundler/bundler_browser.test.ts b/test/bundler/bundler_browser.test.ts index 21256bed37ce..512fd22b33a6 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -1,5 +1,8 @@ import assert from "assert"; -import { describe, expect } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { readdirSync } from "node:fs"; +import path from "node:path"; import { itBundled } from "./expectBundled"; describe("bundler", () => { @@ -214,6 +217,15 @@ describe("bundler", () => { target: "browser", splitting: true, outdir: "/out", + onAfterBundle(api) { + // Two chunks besides the entry: one per disabled module. + const chunks = readdirSync(api.outdir).filter(name => name !== "entry.js"); + expect(chunks.length).toBe(2); + for (const chunk of chunks) { + api.expectFile("/out/entry.js").toInclude(`import("./${chunk}")`); + api.expectFile(`/out/${chunk}`).toInclude("(disabled):"); + } + }, run: { file: "/out/entry.js", stdout: ` @@ -416,18 +428,20 @@ describe("bundler", () => { files: { "/entry.js": /* js */ ` import fs from "fs"; + import fsPrefixed from "node:fs"; import path from "path"; - console.log(typeof fs.readFileSync, typeof path.join); + console.log(typeof fs.readFileSync, typeof fsPrefixed.readFileSync, typeof path.join); `, }, target: "browser", external: ["fs", "path"], onAfterBundle(api) { api.expectFile("out.js").toInclude(`from "fs"`); + api.expectFile("out.js").toInclude(`from "node:fs"`); api.expectFile("out.js").toInclude(`from "path"`); }, run: { - stdout: "function function", + stdout: "function function function", }, }); @@ -473,6 +487,80 @@ describe("bundler", () => { }; }); + // An onLoad plugin never gets a disabled module. Its filter matches the real + // path, so loading it would put the Node-only file back into the bundle, and + // a disabled bare name has no file to load at all. + itBundled("browser/BrowserFieldDisabledModuleSkipsOnLoadPlugin", ({ root }) => { + const loaded: string[] = []; + return { + files: { + "/entry.js": `console.log(require("pkg").join(" "));`, + "/node_modules/pkg/package.json": `{ "main": "index.js", "browser": { "./node.js": false, "nodeonly": false } }`, + "/node_modules/pkg/index.js": `module.exports = [JSON.stringify(require("./node.js")), JSON.stringify(require("nodeonly"))];`, + "/node_modules/pkg/node.js": `module.exports = "node-only contents";`, + }, + target: "browser", + plugins: [ + { + name: "read-from-disk", + setup(builder) { + builder.onLoad({ filter: /.*/ }, async args => { + loaded.push(path.relative(root, args.path).replaceAll("\\", "/")); + return { contents: await Bun.file(args.path).text(), loader: "js" }; + }); + }, + }, + ], + onAfterBundle() { + expect(loaded.sort()).toEqual(["entry.js", "node_modules/pkg/index.js"]); + }, + run: { + stdout: "{} {}", + }, + }; + }); + + // Only a JavaScript import of a disabled path becomes the empty module. A + // stylesheet that names one keeps the "@import" and the "url()" as written. + itBundled("browser/BrowserFieldDisabledCssImportStaysAsWritten", { + files: { + "/entry.js": `import "pkg";`, + "/node_modules/pkg/package.json": `{ "main": "index.js", "browser": { "./node.css": false, "./img.png": false } }`, + "/node_modules/pkg/index.js": `import "./style.css";`, + "/node_modules/pkg/style.css": `@import "./node.css";\n.a { color: red; background: url("./img.png"); }`, + "/node_modules/pkg/node.css": `.node { color: blue; }`, + "/node_modules/pkg/img.png": `x`, + }, + target: "browser", + outdir: "/out", + onAfterBundle(api) { + const css = api.readFile("/out/entry.css"); + expect(css).toContain(`@import "./node.css";`); + expect(css).toContain(`url("./img.png")`); + expect(css).not.toContain("blue"); + }, + }); + + // `--check` checks the file as written. The empty module that stands in for a + // disabled file in the bundle has no exports, and is not what the types are of. + test("browser/BrowserFieldDisabledModuleIsTypeCheckedAsWritten", async () => { + using dir = tempDir("browser-field-check", { + "package.json": JSON.stringify({ name: "project", browser: { "./src/node-only.ts": false } }), + "src/node-only.ts": `export const x: number = 1;`, + "entry.ts": `import { x } from "./src/node-only";\nexport const y: number = x;\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--check", "--target=browser", "entry.ts", "--outdir", "out"], + 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(stdout + stderr).not.toContain("TS2305"); + expect(exitCode).toBe(0); + }); + // #4928: a package.json "browser": {"": false} must win over the // builtin polyfill for --target browser. Packages set this to keep their // node-only code paths from dragging crypto/stream/buffer into browser bundles. diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index e62f498a4801..a87529e83e02 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -411,6 +411,9 @@ describe("bundler", () => { "entry.js": `import v from 'pkg'; console.log(JSON.stringify([typeof v, v]), require('pkg'))`, ...disabledModuleEntry, }, + onAfterBundle(api) { + api.expectFile("out.js").not.toInclude("(disabled):"); + }, run: { stdout: `["string","main"] main` }, }); From cbb1634535a99a68c0dc7f6a9114ca73740389e8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:36:55 +0000 Subject: [PATCH 17/22] bundler: shorten three comments --- src/bundler/bundle_v2.rs | 7 ++----- src/resolver/resolver.rs | 3 +-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index c288acba6b6d..a4565592c1c8 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6504,9 +6504,7 @@ pub mod bv2_impl { Ok(out) } - /// Like esbuild, a JavaScript import of a disabled path is an empty CommonJS - /// module. Every other import of one (CSS, HTML, `require.resolve`) stays - /// external, and so does every import in the DevServer. + /// Only a JavaScript import of a disabled path is the empty module; the rest stay external. fn disabled_import_is_empty_module(&self, importer: Loader, kind: ImportKind) -> bool { self.dev_server.is_none() && importer.is_javascript_like() @@ -7245,8 +7243,7 @@ pub mod bv2_impl { if resolve_result.path_pair.primary.is_disabled && !self.disabled_import_is_empty_module(loader, import_record.kind) { - // The import stays external. A disabled "module" entry with a live - // "main" uses "main". + // Stays external. A live "main" stands in for a disabled "module" entry. match resolve_result.path_pair.secondary { Some(secondary) if !secondary.is_disabled => { resolve_result.path_pair.primary = secondary; diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 836b9b39b85a..38fefd7ac8d3 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -5119,8 +5119,7 @@ impl<'a> Resolver<'a> { ) } - /// The user's external entry that covers `import_path`: the path itself, or - /// the package it is a path into ("foo" covers "foo/bar"). + /// The user's external entry that covers `import_path` ("foo" covers "foo/bar"). fn user_external<'p>(&self, import_path: &'p [u8]) -> Option<&'p [u8]> { let mut query = import_path; loop { From a17bfcb1cb81c92c666fd8d3ac1c445b341c6dde Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:04:15 +0000 Subject: [PATCH 18/22] Shorten comments. Keep the nesting of main where only the indentation changed --- src/bundler/LinkerContext.rs | 5 +- src/bundler/ParseTask.rs | 31 ++- src/bundler/PathToSourceIndexMap.rs | 11 +- src/bundler/bundle_v2.rs | 110 +++++---- .../linker_context/scanImportsAndExports.rs | 4 +- src/resolver/package_json.rs | 4 +- src/resolver/resolver.rs | 212 ++++++++---------- 7 files changed, 177 insertions(+), 200 deletions(-) diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 2f8599521ad3..e33bd16234cf 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -4012,17 +4012,16 @@ impl<'a> LinkerContext<'a> { } } - // Warn about importing from a file that is known to not have any exports. // The default import of a disabled module is its `{}` exports object. let is_disabled_named_import = status == ImportTrackerStatus::Disabled && !named_import.alias_is_star && named_import.alias.is_some_and(|a| a.slice() != b"default"); + // Warn about importing from a file that is known to not have any exports if (status == ImportTrackerStatus::CjsWithoutExports || is_disabled_named_import) && !self.is_call_record(prev_source_index, named_import.import_record_index) { if is_disabled_named_import { - // Printed as `undefined`, so the empty module is not needed for it. // SAFETY: the symbol slot is disjoint from `named_import` (`graph.ast`). unsafe { self.graph.symbol_mut(tracker.import_ref) } .import_item_status = ImportItemStatus::Missing; @@ -4041,8 +4040,8 @@ impl<'a> LinkerContext<'a> { ); // Split-borrow with `named_import` (`&self.graph`) — // `log_disjoint` returns the disjoint `Transpiler.log` backref. - // Like esbuild, an import inside node_modules is not the user's to fix. if is_disabled_named_import && source.path.is_node_module() { + // Like esbuild: the user cannot fix an import inside node_modules. self.log_disjoint().add_debug_fmt( Some(source), named_import.alias_loc, diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 0d543d19fd87..d6e6e80feedb 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -258,23 +258,22 @@ impl<'a> ParseTask<'a> { // provenance to read-only, making the later `&mut *ctx` UB). ctx: *mut BundleV2<'a>, ) -> ParseTask<'a> { - // Every empty module of a disabled path gets the same attributes, whichever - // import creates it. + // Every empty module gets the same attributes, whichever import creates it. let is_empty_module = path.is_disabled_module(); - let (package_name, package_version) = - match resolve_result.package_json.filter(|_| !is_empty_module) { - // SAFETY: `package_json` is `Option<*const PackageJSON>`; the resolver - // arena outlives the bundle pass, so deref'ing the raw pointer here to - // borrow `name`/`version` is sound. - Some(pj) => unsafe { - let pj = &*pj; - ( - ast::StoreStr::new(&pj.name[..]), - ast::StoreStr::new(&pj.version[..]), - ) - }, - None => (ast::StoreStr::EMPTY, ast::StoreStr::EMPTY), - }; + let package_json = resolve_result.package_json.filter(|_| !is_empty_module); + let (package_name, package_version) = match package_json { + // SAFETY: `package_json` is `Option<*const PackageJSON>`; the resolver + // arena outlives the bundle pass, so deref'ing the raw pointer here to + // borrow `name`/`version` is sound. + Some(pj) => unsafe { + let pj = &*pj; + ( + ast::StoreStr::new(&pj.name[..]), + ast::StoreStr::new(&pj.version[..]), + ) + }, + None => (ast::StoreStr::EMPTY, ast::StoreStr::EMPTY), + }; // SAFETY: lifetime erased — `ctx` outlives the ParseTask (BACKREF); // write provenance from the `*mut BundleV2` parameter; caller passes a // live `&mut BundleV2` coerced to `*mut`. diff --git a/src/bundler/PathToSourceIndexMap.rs b/src/bundler/PathToSourceIndexMap.rs index 0a27c7e6c6bb..221922dbbd0c 100644 --- a/src/bundler/PathToSourceIndexMap.rs +++ b/src/bundler/PathToSourceIndexMap.rs @@ -2,8 +2,7 @@ use bun_collections::StringHashMap; use crate::IndexStringMap::IndexInt; -/// The pretty-path prefix of the empty module that stands in for a path the -/// "browser" field disabled. esbuild prints the same prefix. +/// Pretty-path prefix of the empty module of a disabled path, as in esbuild. pub(crate) const DISABLED_PRETTY_PREFIX: &[u8] = b"(disabled):"; /// An unused import of that empty module is dropped, as esbuild drops it. @@ -12,12 +11,11 @@ pub(crate) const DISABLED_MODULE_SIDE_EFFECTS: bun_ast::SideEffects = /// Abstracts over the two structurally-identical `Path` ports (`bun_paths::fs::Path` /// and `bun_resolver::fs::Path`) so the bundler can key the map with either while -/// the crates converge. +/// the crates converge. Both expose `.text: &[u8]`, which is all we need. pub trait PathLike { fn path_text(&self) -> &[u8]; - /// The path names the empty module of a disabled file, not the file. - /// `is_disabled` alone does not say so: records that stay external carry it too. + /// The path names the empty module of a disabled file, not an external disabled record. fn is_disabled_module(&self) -> bool; } @@ -41,8 +39,7 @@ impl PathLike for bun_paths::fs::Path<'_> { #[derive(Default)] pub struct PathToSourceIndexMap { pub(crate) map: Map, - /// The empty modules of disabled files, keyed by the same text. A file can be in - /// the graph both ways. + /// The empty modules of disabled files, keyed by the same text as the files. disabled: Map, } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 8c9711b8d0f6..b35a981df429 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2906,21 +2906,25 @@ pub mod bv2_impl { let out_source_index: Option; let importer = import_record.importer_source_index as usize; - let record_index = import_record.import_record_index as usize; let importer_loader = self.graph.input_files.items_loader()[importer]; - let kind = - self.graph.ast.items_import_records()[importer].as_slice()[record_index].kind; - if !self.select_import_module(&mut resolve_result, importer_loader, kind) { - // Disable failing packages from being printed. - // This may cause broken code to write. - // However, doing this means we tell them all the resolve errors - // Rather than just the first one. - self.graph.ast.items_import_records_mut()[importer].as_mut_slice()[record_index] - .path - .is_disabled = true; - return; - } - let mut path = resolve_result.path_pair.primary; + let kind = self.graph.ast.items_import_records()[importer].as_slice() + [import_record.import_record_index as usize] + .kind; + let module = self.select_import_module(&mut resolve_result, importer_loader, kind); + let mut path = match module { + Some(p) => *p, + None => { + let record: &mut ImportRecord = &mut self.graph.ast.items_import_records_mut() + [import_record.importer_source_index as usize] + .as_mut_slice()[import_record.import_record_index as usize]; + // Disable failing packages from being printed. + // This may cause broken code to write. + // However, doing this means we tell them all the resolve errors + // Rather than just the first one. + record.path.is_disabled = true; + return; + } + }; let is_empty_module = path.is_disabled_module(); if resolve_result.flags.is_external() { @@ -6397,50 +6401,43 @@ pub mod bv2_impl { false } - /// Makes `result.path_pair.primary` the module that an import of `result` loads. - /// Returns `false` when the import stays an external, disabled record. - /// - /// Like esbuild, a JavaScript import of a path the "browser" field disabled loads - /// the empty module `(disabled):`. Bun's own stubs for Node.js builtins are - /// in the `node` namespace and stay external, as does everything in the DevServer. - fn select_import_module<'r>( + /// The module that an import of `result` loads, or `None` for a disabled, external record. + fn select_import_module<'p, 'r>( &self, - result: &mut _resolver::Result<'r>, + result: &'p mut _resolver::Result<'r>, importer: Loader, kind: ImportKind, - ) -> bool + ) -> Option<&'p mut Fs::Path<'r>> where 'a: 'r, { let primary = result.path_pair.primary; - if !primary.is_disabled { - return true; - } - if self.dev_server.is_none() - && importer.is_javascript_like() - && matches!( - kind, - ImportKind::Stmt | ImportKind::Require | ImportKind::Dynamic - ) - && primary.is_file() - { - result.path_pair.primary = self.disabled_module_path(&primary); - return true; - } - // A live "main" stands in for a disabled "module" entry. The fds are not its own. - match result.path_pair.secondary.take() { - Some(secondary) if !secondary.is_disabled => { + if primary.is_disabled { + // Like esbuild. Bun's stubs for Node.js builtins (`node` namespace) stay external. + if self.dev_server.is_none() + && importer.is_javascript_like() + && matches!( + kind, + ImportKind::Stmt | ImportKind::Require | ImportKind::Dynamic + ) + && primary.is_file() + { + result.path_pair.primary = self.disabled_module_path(&primary); + } else { + // A live "main" stands in for a disabled "module" entry. The fds are not its own. + let secondary = result.path_pair.secondary.take()?; + if secondary.is_disabled { + return None; + } result.path_pair.primary = secondary; result.file_fd = bun_sys::Fd::INVALID; result.dirname_fd = bun_sys::Fd::INVALID; - true } - _ => false, } + Some(&mut result.path_pair.primary) } - /// `text` stays what the resolver returned. `pretty` is what esbuild prints: - /// `(disabled):`. + /// `pretty` is what esbuild prints: `(disabled):`. fn disabled_module_path(&self, path: &Fs::Path<'_>) -> Fs::Path<'a> { use crate::bun_fs::PathResolverExt as _; @@ -7157,16 +7154,18 @@ pub mod bv2_impl { // would lock the whole struct while the loop body still needs to // read other `resolve_result` fields (`.flags`, `.path_pair`, // `.primary_side_effects_data`, `.jsx`). Detach via raw ptr. - if !self.select_import_module(&mut resolve_result, loader, import_record.kind) { - import_record.path.is_disabled = true; - import_record.source_index = Index::INVALID; - continue; - } - // SAFETY: `resolve_result` outlives this borrow; see note above. - let path: &mut Fs::Path<'a> = unsafe { - bun_ptr::detach_lifetime_mut::>( - &mut resolve_result.path_pair.primary, - ) + let path: &mut Fs::Path<'a> = match self.select_import_module( + &mut resolve_result, + loader, + import_record.kind, + ) { + // SAFETY: `resolve_result` outlives this borrow; see note above. + Some(p) => unsafe { bun_ptr::detach_lifetime_mut::>(p) }, + None => { + import_record.path.is_disabled = true; + import_record.source_index = Index::INVALID; + continue; + } }; let is_empty_module = path.is_disabled_module(); @@ -7307,8 +7306,7 @@ pub mod bv2_impl { import_record.kind = ImportKind::HtmlManifest; } - // Within one importer's queue the pretty path tells the empty module of a - // file from the file. + // In one importer's queue the pretty path tells the empty module from its file. let queue_key = if is_empty_module { path.pretty } else { diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index 4397aa9bfc7d..af722a677d38 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -305,9 +305,7 @@ pub(crate) fn scan_imports_and_exports( match record.kind { ImportKind::Stmt => { - // The empty module of a disabled file follows the rule described - // below. Its placeholder AST has a lazy export, so that rule's own - // condition does not cover it. + // The rule below, for the empty module: its lazy export fails that condition. if col_ref!(input_files)[other_file].path.is_disabled { if record.flags.intersects( ImportRecordFlags::CONTAINS_IMPORT_STAR diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index 2d6408ae22c9..839092a13479 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -628,9 +628,9 @@ impl PackageJSON { if let js_ast::ExprData::EObjectJSON(obj) = &browser_prop.expr.data { // The value is an object - // Remap all files in the browser field. Keys are stored as written; - // `check_browser_map` decides which spellings an import may match. + // Remap all files in the browser field for prop in obj.get().properties() { + // Stored as written: `check_browser_map` decides which spellings match. let key: &[u8] = prop.key.slice(); match &prop.value { diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 9f0ed1235157..9c59aca4ba91 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -472,8 +472,7 @@ pub struct Resolver<'a> { /// Read the "browser" field in package.json files? /// For Bun's runtime, we don't. pub(crate) care_about_browser_field: bool, - /// Nested "browser" map targets being resolved on the current call stack; - /// a map can send a subpath back to itself (`"./x": "pkg/x"`). + /// Depth of nested "browser" map target lookups. A map can send a subpath back to itself. browser_remap_depth: u8, pub debug_logs: Option, @@ -1729,8 +1728,7 @@ impl<'a> Resolver<'a> { } } - // The loop above skips a disabled path, which nothing reads. It still names a - // module, so through a symlinked directory one file has to stay one path. + // A disabled path names a module too: a symlinked directory must not make it two. let PathPair { primary, secondary } = &mut result.path_pair; for path in core::iter::once(primary).chain(secondary.as_mut()) { if !path.is_disabled || !path.is_file() || !bun_paths::is_absolute(path.text()) { @@ -2391,9 +2389,11 @@ impl<'a> Resolver<'a> { ExternalKind::NotExternal }); - // A file that no "browser" map lookup picked gets one, in the map nearest to - // it: an `exports` or `imports` target, a file under a nested package.json, - // a tsconfig `paths` target. + if result.path_pair.primary.is_disabled && result.path_pair.secondary.is_none() { + return ResultUnion::Success(result); + } + + // A file that no "browser" map lookup picked gets one, in the map nearest to it. if self.care_about_browser_field && !res.browser_remapped && !res.is_external @@ -2406,47 +2406,51 @@ impl<'a> Resolver<'a> { _ => return ResultUnion::Success(result), }, }; - let browser_scope = base_dir_info.get_enclosing_browser_scope(); // The map that sent the specifier to a relative path does not apply to it again. - if let Some(browser_scope) = browser_scope - && relative_remap_scope != Some(browser_scope) - && let Some(remap) = self + if let Some(browser_scope) = base_dir_info + .get_enclosing_browser_scope() + .filter(|scope| relative_remap_scope != Some(*scope)) + { + if let Some(remap) = self .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( base_dir_info, result.path_pair.primary.text(), ) - { - if remap.is_empty() { - result.path_pair.primary.is_disabled = true; - } else { - let mut remapped = MatchResult::default(); - if self - .resolve_without_remapping( - browser_scope, - remap, - kind, - global_cache, - &mut remapped, - ) - .is_success() - { - result.path_pair = remapped.path_pair; - result.dirname_fd = remapped.dirname_fd; - result.file_fd = remapped.file_fd; - result.package_json = remapped.package_json; - result.module_type = remapped.module_type; - - // Potentially rewrite the import path if it's external that - // was remapped to a different path - result.flags.set_external_kind(if remapped.is_external { - ExternalKind::ExternalRewritePath - } else { - ExternalKind::NotExternal - }); - - result.flags.set_is_from_node_modules( - result.flags.is_from_node_modules() || remapped.is_node_module, - ); + { + if remap.is_empty() { + result.path_pair.primary.is_disabled = true; + } else { + let mut remapped = MatchResult::default(); + if self + .resolve_without_remapping( + browser_scope, + remap, + kind, + global_cache, + &mut remapped, + ) + .is_success() + { + result.path_pair = remapped.path_pair; + result.dirname_fd = remapped.dirname_fd; + result.file_fd = remapped.file_fd; + result.package_json = remapped.package_json; + result.module_type = remapped.module_type; + + // Potentially rewrite the import path if it's external that + // was remapped to a different path + result.flags.set_external_kind(if remapped.is_external { + ExternalKind::ExternalRewritePath + } else { + ExternalKind::NotExternal + }); + + result.flags.set_is_from_node_modules( + result.flags.is_from_node_modules() + || remapped.is_node_module, + ); + return ResultUnion::Success(result); + } } } } @@ -2925,8 +2929,7 @@ impl<'a> Resolver<'a> { bstr::BStr::new(abs_path) )); } - // This lookup has no "exports" step, so a package that has an "exports" - // map keeps the plain file lookup. + // No "exports" step here, so a package with an "exports" map keeps the file lookup. let package_dir_info = match esm_.as_ref() { Some(esm) if self.care_about_browser_field => self .fs_ref() @@ -5185,12 +5188,7 @@ impl<'a> Resolver<'a> { ) } - /// The last two steps of a package candidate, as in esbuild's `tryToResolvePackage`: - /// the package's "browser" map, then node's file and directory lookup. `abs_path` - /// has no implicit extension. - /// - /// Resolving a remap target can re-enter `load_node_modules`, which reuses the - /// scratch `abs_path` may live in, so `abs_path` is stale when this returns. + /// Like esbuild's `tryToResolvePackage`: the package's "browser" map, then the file lookup. fn load_package_file_with_browser_map( &mut self, pkg_dir_info: Option, @@ -5229,13 +5227,14 @@ impl<'a> Resolver<'a> { return MatchStatus::Success; } + // The nested lookup reuses the scratch that `abs_path` may live in. let mut abs_path_buf = bun_paths::path_buffer_pool::get(); let Ok(abs_path) = strings::concat_buf_t(&mut **abs_path_buf, &[abs_path]) else { return MatchStatus::NotFound; }; if self.browser_remap_depth < MAX_BROWSER_REMAP_DEPTH { - // The nested lookup parses into the scratch the caller's `esm.subpath` points at. + // It also parses into the scratch the caller's `esm.subpath` points at. let caller_esm_subpath = *bufs!(esm_subpath); self.browser_remap_depth += 1; let resolved = self @@ -5253,9 +5252,7 @@ impl<'a> Resolver<'a> { self.load_as_file_or_directory(abs_path, kind, out) } - /// Looks `input_path` up in the "browser" map enclosing `resolve_dir_info` - /// (the importer's directory for `PackagePath`). Returns the remap target - /// relative to the browser scope, or an empty slice for a disabled entry. + /// The remap target of `input_path_`, relative to the browser scope. Empty means disabled. pub(crate) fn check_browser_map( &mut self, resolve_dir_info: DirInfoRef, @@ -5289,62 +5286,57 @@ impl<'a> Resolver<'a> { map: &package_json.browser_map, }; - // First try the import path as a package path if checker.check_path(input_path, ImplicitExtensions::Include) { return Some(checker.remapped); } - if !is_package_path(input_path) { - return None; - } - - // If a package path didn't work, try the import path as a relative path - let buf = bufs!(abs_to_rel); - match KIND { - BrowserMapPathKind::AbsolutePath => { - let candidate = strings::concat_buf_t(buf, &[b"./", input_path]).ok()?; - if checker.check_path(candidate, ImplicitExtensions::Include) { - return Some(checker.remapped); - } - } - BrowserMapPathKind::PackagePath => { - // Browserify allows a browser map entry of "./pkg" to override a package - // path of "require('pkg')". This is weird, and arguably a bug. But we - // replicate this bug for compatibility. However, Browserify only allows - // this within the same package. It does not allow such an entry in a - // parent package to override this in a child package. So this behavior - // is disallowed if there is a "node_modules" folder in between the child - // package and the parent package. - let mut info = Some(resolve_dir_info); - while let Some(dir) = info { - if dir == browser_scope { - break; - } - if dir.is_node_modules() { - return None; + // First try the import path as a package path + if is_package_path(input_path) { + let abs_to_rel = bufs!(abs_to_rel); + match KIND { + BrowserMapPathKind::AbsolutePath => { + let candidate = strings::concat_buf_t(abs_to_rel, &[b"./", input_path]).ok()?; + if checker.check_path(candidate, ImplicitExtensions::Include) { + return Some(checker.remapped); } - info = dir.get_parent(); } + BrowserMapPathKind::PackagePath => { + // Browserify allows a browser map entry of "./pkg" to override a package + // path of "require('pkg')". This is weird, and arguably a bug. But we + // replicate this bug for compatibility. However, Browserify only allows + // this within the same package. It does not allow such an entry in a + // parent package to override this in a child package. So this behavior + // is disallowed if there is a "node_modules" folder in between the child + // package and the parent package. + let mut info = Some(resolve_dir_info); + while let Some(dir) = info { + if dir == browser_scope { + break; + } + if dir.is_node_modules() { + return None; + } + info = dir.get_parent(); + } - // Use the relative path from the file containing the import path to the - // enclosing package.json file. This includes any subdirectories within the - // package if there are any. - let mut rel_buf = bun_paths::path_buffer_pool::get(); - let rel_dir = browser_map_relative_path( - browser_scope.abs_path, - resolve_dir_info.abs_path, - &mut **rel_buf, - )?; - let parts: [&[u8]; 4] = if rel_dir.is_empty() { - [b"./", input_path, b"", b""] - } else { - [b"./", rel_dir, b"/", input_path] - }; - let candidate = strings::concat_buf_t(buf, &parts).ok()?; + // The key is relative to the package.json: it has the importer's subdirectory. + let mut rel_buf = bun_paths::path_buffer_pool::get(); + let rel_dir = browser_map_relative_path( + browser_scope.abs_path, + resolve_dir_info.abs_path, + &mut **rel_buf, + )?; + let parts: [&[u8]; 4] = if rel_dir.is_empty() { + [b"./", input_path, b"", b""] + } else { + [b"./", rel_dir, b"/", input_path] + }; + let candidate = strings::concat_buf_t(abs_to_rel, &parts).ok()?; - // Browserify lets "require('pkg')" match "./pkg" but not "./pkg.js". - if checker.check_path(candidate, ImplicitExtensions::Skip) { - return Some(checker.remapped); + // Browserify lets "require('pkg')" match "./pkg" but not "./pkg.js". + if checker.check_path(candidate, ImplicitExtensions::Skip) { + return Some(checker.remapped); + } } } } @@ -6910,8 +6902,7 @@ enum ImplicitExtensions { Skip, } -/// Longest chain of "browser" map targets that are themselves package subpaths. -/// A real chain has one or two hops; a longer one is a cycle, and the remap stops applying. +/// A longer chain of "browser" map targets is a cycle, and the remap stops applying. const MAX_BROWSER_REMAP_DEPTH: u8 = 16; pub(crate) struct BrowserMapPath<'b> { @@ -6926,10 +6917,7 @@ impl<'b> BrowserMapPath<'b> { let Some(result) = self.map.get(key) else { return false; }; - // SAFETY: ARENA — `BrowserMap` values are `Box<[u8]>` owned by a `'static` - // PackageJSON (allocated in `parse_package_json`, never freed — DirInfo - // cache is process-global); the `'b` borrow on `map` artificially shortens - // what is process-lifetime storage. `Interned` is the canonical proof type. + // SAFETY: `BrowserMap` values are owned by a `PackageJSON` that is never freed. self.remapped = unsafe { bun_ptr::Interned::assume(result) }.as_bytes(); true } @@ -6948,8 +6936,7 @@ impl<'b> BrowserMapPath<'b> { false } - /// Probes the path as written, with an implicit extension, as a directory - /// with an "index" file, and that index with an implicit extension. + /// Probes the path, then with an implicit extension, then as a directory with an "index". fn check_path( &mut self, path_to_check: &[u8], @@ -6990,8 +6977,7 @@ impl<'b> BrowserMapPath<'b> { } } -/// `path` relative to the directory `base`, with forward slashes and no trailing -/// slash. Empty when `path` is `base` itself, `None` when it is not inside `base`. +/// `path` relative to `base` with forward slashes, or `None` when it is not inside `base`. fn browser_map_relative_path<'a>(base: &[u8], path: &[u8], buf: &'a mut [u8]) -> Option<&'a [u8]> { if !path.starts_with(base) { return None; From e5a4d1454e4e5b0bbf1ef44f656c6e86705c0977 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:42:16 +0000 Subject: [PATCH 19/22] Re-cut the "browser" map change after review Resolver: - Restore the `package_json` condition of the lookup after resolution. Remove the chain marks and the tests that belong to #46985. - A package candidate takes a key in this order: a key for the path as the import spells it, the key of the file that loads, the lookups of a directory, then a key with an implicit extension for a file that does not exist. A key for `x.mjs` no longer applies to a `require` that loads `x.js`. - One constructor for a disabled path (`disabled_path`): no trailing separator, and one interned copy of each distinct path. The `main` and index lookups use it too. - A live `main` stands in for a disabled `module` entry where the pair is built, so `finalize_result` sees the path that loads. - A remap cycle stops at the entry that is already being resolved. - The `esm_subpath` scratch is on the frame of `load_node_modules`. Bundler: - The empty module of a disabled file has a typed mark, `Path.is_disabled_module`. `PathMap` keeps marked paths in a second table, for the module graph and for the queue of each importer. One input gives one bundle. - `export *` from a disabled file gives no name. An import of such a name is `undefined` with a warning, and not a build error. Tests: - A table of 564 layouts (`test/bundler/browser-field-matrix.ts`) with a snapshot, and `scripts/browser-field-matrix.ts` to compare it with esbuild. - Cases for each decision above, and the two cases of #41183. --- scripts/browser-field-matrix.ts | 23 + src/bundler/LinkerContext.rs | 105 +++- src/bundler/ParseTask.rs | 29 +- src/bundler/PathToSourceIndexMap.rs | 97 ++- src/bundler/bundle_v2.rs | 139 ++-- .../linker_context/scanImportsAndExports.rs | 12 - src/paths/lib.rs | 19 +- src/resolver/lib.rs | 31 +- src/resolver/resolver.rs | 350 +++++++---- src/resolver/result.rs | 2 +- .../bundler_browser.test.ts.snap | 595 ++++++++++++++++++ test/bundler/browser-field-matrix.ts | 223 +++++++ test/bundler/bundler_browser.test.ts | 280 ++++++++- test/bundler/esbuild/extra.test.ts | 48 +- test/bundler/esbuild/packagejson.test.ts | 505 ++++----------- test/js/bun/resolve/resolve.test.ts | 35 ++ 16 files changed, 1736 insertions(+), 757 deletions(-) create mode 100644 scripts/browser-field-matrix.ts create mode 100644 test/bundler/__snapshots__/bundler_browser.test.ts.snap create mode 100644 test/bundler/browser-field-matrix.ts diff --git a/scripts/browser-field-matrix.ts b/scripts/browser-field-matrix.ts new file mode 100644 index 000000000000..99dd506bb634 --- /dev/null +++ b/scripts/browser-field-matrix.ts @@ -0,0 +1,23 @@ +// scripts/browser-field-matrix.ts +// Builds every layout of test/bundler/browser-field-matrix.ts with the bun that runs this script and with esbuild, +// and prints the layouts where the two disagree about which files an import of a package with a "browser" map loads. +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bun, esbuild, layouts, table } from "../test/bundler/browser-field-matrix"; + +const module = await import("esbuild"); +const all = layouts().map(layout => ({ ...layout, name: `${layout.group}: ${layout.name}` })); +const root = mkdtempSync(join(realpathSync(tmpdir()), "browser-field-matrix-")); +const [ours, theirs] = [ + await table(join(root, "bun"), all, bun), + await table(join(root, "esbuild"), all, esbuild(module, join(root, "esbuild"))), +]; +rmSync(root, { recursive: true, force: true }); + +let same = 0; +for (const { name } of all) { + if (ours[name] === theirs[name]) same++; + else console.log(`${name}\n bun ${Bun.version}: ${ours[name]}\n esbuild ${module.version}: ${theirs[name]}`); +} +console.log(JSON.stringify({ layouts: all.length, same, differ: all.length - same })); diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index e33bd16234cf..99ab78293785 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -3728,6 +3728,24 @@ impl<'a> LinkerContext<'a> { }; } + // A named import of the empty module of a disabled file is always `undefined`. + if self.parse_graph().input_files.items_source()[other_source_index as usize] + .path + .is_disabled_module + && !alias_is_star + && alias.is_some_and(|a| a.slice() != b"default") + { + return ImportTrackerIterator { + value: ImportTracker { + source_index: crate::Index::init(other_source_index), + import_ref: Ref::NONE, + ..Default::default() + }, + status: ImportTrackerStatus::CjsWithoutExports, + ..Default::default() + }; + } + let flags = ast_flags[other_id as usize]; // Is this a named import of a file without any exports? @@ -4012,20 +4030,13 @@ impl<'a> LinkerContext<'a> { } } - // The default import of a disabled module is its `{}` exports object. - let is_disabled_named_import = status == ImportTrackerStatus::Disabled - && !named_import.alias_is_star - && named_import.alias.is_some_and(|a| a.slice() != b"default"); // Warn about importing from a file that is known to not have any exports - if (status == ImportTrackerStatus::CjsWithoutExports - || is_disabled_named_import) + if status == ImportTrackerStatus::CjsWithoutExports && !self.is_call_record(prev_source_index, named_import.import_record_index) { - if is_disabled_named_import { - // SAFETY: the symbol slot is disjoint from `named_import` (`graph.ast`). - unsafe { self.graph.symbol_mut(tracker.import_ref) } - .import_item_status = ImportItemStatus::Missing; - } + // SAFETY: the symbol slot is disjoint from `named_import` (`graph.ast`). + unsafe { self.graph.symbol_mut(tracker.import_ref) }.import_item_status = + ImportItemStatus::Missing; let source = self.get_source(tracker.source_index.get()); let imported = self.get_source(next_tracker.source_index.get()); // SAFETY: `alias` is an arena `*const [u8]` valid for the link pass. @@ -4038,22 +4049,11 @@ impl<'a> LinkerContext<'a> { bstr::BStr::new(alias), bstr::BStr::new(&imported.path.pretty), ); - // Split-borrow with `named_import` (`&self.graph`) — - // `log_disjoint` returns the disjoint `Transpiler.log` backref. - if is_disabled_named_import && source.path.is_node_module() { - // Like esbuild: the user cannot fix an import inside node_modules. - self.log_disjoint().add_debug_fmt( - Some(source), - named_import.alias_loc, - message, - ); - } else { - self.log_disjoint().add_range_warning_fmt( - Some(source), - source.range_of_identifier(named_import.alias_loc), - message, - ); - } + self.warn_import_is_undefined( + source, + source.range_of_identifier(named_import.alias_loc), + message, + ); } } @@ -4177,6 +4177,17 @@ impl<'a> LinkerContext<'a> { format_args!("Bun's bundler defaults to browser builds instead of node or bun builds. If you want to use node or bun builds, you can set the target to \"node\" or \"bun\" in the transpiler options."), r, ); + } else if self + .export_stars_reach_disabled_module(next_tracker.source_index.get()) + { + // `export *` from a disabled file gives no name, so the import is `undefined`. + symbol.import_item_status = ImportItemStatus::Missing; + let message = format_args!( + "Import \"{}\" will always be undefined because there is no matching export in \"{}\"", + bstr::BStr::new(alias), + bstr::BStr::new(&next_source.path.pretty), + ); + self.warn_import_is_undefined(source, r, message); } else { self.log_disjoint().add_range_error_fmt( Some(source), @@ -4324,6 +4335,46 @@ impl<'a> LinkerContext<'a> { } } + /// Like esbuild, a debug message when the importer is inside node_modules: its user cannot fix it. + fn warn_import_is_undefined( + &self, + source: &Source, + range: bun_ast::Range, + message: core::fmt::Arguments<'_>, + ) { + if source.path.is_node_module() { + self.log_disjoint() + .add_debug_fmt(Some(source), range.loc, message); + } else { + self.log_disjoint() + .add_range_warning_fmt(Some(source), range, message); + } + } + + /// `source_index` re-exports with `export *`, directly or not, from a disabled module. + fn export_stars_reach_disabled_module(&self, source_index: crate::IndexInt) -> bool { + let sources = self.parse_graph().input_files.items_source(); + let import_records = self.graph.ast.items_import_records(); + let export_stars = self.graph.ast.items_export_star_import_records(); + let mut visited = vec![source_index]; + let mut next = 0; + while let Some(&file) = visited.get(next) { + next += 1; + for &record_index in export_stars[file as usize].iter() { + let target = + import_records[file as usize].as_slice()[record_index as usize].source_index; + if !target.is_valid() || visited.contains(&target.get()) { + continue; + } + if sources[target.get() as usize].path.is_disabled_module { + return true; + } + visited.push(target.get()); + } + } + false + } + /// Is `ref_` the `exports` object of ES module `source_index` (i.e. an /// import that resolved here is that module's namespace)? /// The record of a named import in `source_index` is an `import()` or a diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index f325b859a9b4..9001cb164f6d 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -24,7 +24,6 @@ use bun_js_parser as js_parser; // ASTs (arena outlives the link step). Use the crate-level alias so the // `Success`/helper signatures don't carry an explicit `'static` everywhere. use crate::JSAst; -use crate::PathToSourceIndexMap::PathLike as _; /// `js_parser.Parser.Options` — the real parser-entry options struct. pub use bun_js_parser::parser::ParserOptions; @@ -258,10 +257,7 @@ impl<'a> ParseTask<'a> { // provenance to read-only, making the later `&mut *ctx` UB). ctx: *mut BundleV2<'a>, ) -> ParseTask<'a> { - // Every empty module gets the same attributes, whichever import creates it. - let is_empty_module = path.is_disabled_module(); - let package_json = resolve_result.package_json.filter(|_| !is_empty_module); - let (package_name, package_version) = match package_json { + let (package_name, package_version) = match resolve_result.package_json { // SAFETY: `package_json` is `Option<*const PackageJSON>`; the resolver // arena outlives the bundle pass, so deref'ing the raw pointer here to // borrow `name`/`version` is sound. @@ -282,32 +278,22 @@ impl<'a> ParseTask<'a> { ParseTask { ctx: Some(ctx_ref), path, - contents_or_fd: if is_empty_module { + contents_or_fd: if path.is_disabled_module { ContentsOrFd::Contents(b"") - } else if path.text == resolve_result.path_pair.primary.text { + } else { ContentsOrFd::Fd { dir: resolve_result.dirname_fd, file: resolve_result.file_fd, } - } else { - // The fds are the primary path's. The secondary path is opened by name. - ContentsOrFd::Fd { - dir: Fd::INVALID, - file: Fd::INVALID, - } - }, - side_effects: if is_empty_module { - crate::PathToSourceIndexMap::DISABLED_MODULE_SIDE_EFFECTS - } else { - resolve_result.primary_side_effects_data }, + side_effects: resolve_result.primary_side_effects_data, // D042: resolver-side and bundler-side `jsx::Pragma` are the SAME // nominal type (`bun_options_types::jsx::Pragma`). Preserves // jsxImportSource/runtime/etc. from tsconfig.json. jsx: resolve_result.jsx.clone(), source_index, - // `module.exports = {}`, also for a disabled ".mjs" or ".mts". - module_type: if is_empty_module { + // The empty module is `module.exports = {}`, also for an ".mjs" file. + module_type: if path.is_disabled_module { options::ModuleType::Cjs } else { resolve_result.module_type @@ -673,6 +659,7 @@ pub mod parse_worker { pretty: b"", is_disabled: false, is_symlink: false, + is_disabled_module: false, }, contents: std::borrow::Cow::Borrowed(runtime_code), // `Source.index` is `bun_ast::Index` (newtype `u32`), @@ -1663,7 +1650,7 @@ pub mod parse_worker { break 'brk false; } - if file_path.namespace == b"node" || file_path.is_disabled { + if file_path.namespace == b"node" || file_path.is_disabled_module { break 'brk false; } true diff --git a/src/bundler/PathToSourceIndexMap.rs b/src/bundler/PathToSourceIndexMap.rs index 221922dbbd0c..9b5515a9e24b 100644 --- a/src/bundler/PathToSourceIndexMap.rs +++ b/src/bundler/PathToSourceIndexMap.rs @@ -1,101 +1,73 @@ use bun_collections::StringHashMap; +use bun_paths::fs::Path; use crate::IndexStringMap::IndexInt; -/// Pretty-path prefix of the empty module of a disabled path, as in esbuild. +/// Pretty-path prefix of the empty module of a disabled file, as in esbuild. pub(crate) const DISABLED_PRETTY_PREFIX: &[u8] = b"(disabled):"; -/// An unused import of that empty module is dropped, as esbuild drops it. -pub(crate) const DISABLED_MODULE_SIDE_EFFECTS: bun_ast::SideEffects = - bun_ast::SideEffects::NoSideEffectsPureData; - -/// Abstracts over the two structurally-identical `Path` ports (`bun_paths::fs::Path` -/// and `bun_resolver::fs::Path`) so the bundler can key the map with either while -/// the crates converge. Both expose `.text: &[u8]`, which is all we need. -pub trait PathLike { - fn path_text(&self) -> &[u8]; - - /// The path names the empty module of a disabled file, not an external disabled record. - fn is_disabled_module(&self) -> bool; -} - -// `bun_resolver::fs::Path` is now a re-export of `bun_paths::fs::Path` (D090), -// so a single impl covers both. -impl PathLike for bun_paths::fs::Path<'_> { - #[inline] - fn path_text(&self) -> &[u8] { - self.text - } - - #[inline] - fn is_disabled_module(&self) -> bool { - self.is_disabled && self.pretty.starts_with(DISABLED_PRETTY_PREFIX) - } -} - /// The lifetime of the keys are not owned by this map. /// /// We assume it's arena allocated. #[derive(Default)] -pub struct PathToSourceIndexMap { - pub(crate) map: Map, - /// The empty modules of disabled files, keyed by the same text as the files. - disabled: Map, +pub struct PathMap { + pub(crate) map: StringHashMap, + /// A disabled file and its empty module are two modules with one `text`. + disabled_modules: StringHashMap, } +pub type PathToSourceIndexMap = PathMap; + pub type Map = StringHashMap; /// std `HashMap::entry` doesn't expose /// `found_existing` + value-ptr together, so we hand-roll a thin shim. -pub(crate) type GetOrPutResult<'a> = bun_collections::string_hash_map::GetOrPutResult<'a, IndexInt>; +pub(crate) type GetOrPutResult<'a, V = IndexInt> = + bun_collections::string_hash_map::GetOrPutResult<'a, V>; -impl PathToSourceIndexMap { - fn map_for(&self, path: &impl PathLike) -> &Map { - if path.is_disabled_module() { - &self.disabled +impl PathMap { + fn map_for(&self, path: &Path<'_>) -> &StringHashMap { + if path.is_disabled_module { + &self.disabled_modules } else { &self.map } } - fn map_for_mut(&mut self, path: &impl PathLike) -> &mut Map { - if path.is_disabled_module() { - &mut self.disabled + fn map_for_mut(&mut self, path: &Path<'_>) -> &mut StringHashMap { + if path.is_disabled_module { + &mut self.disabled_modules } else { &mut self.map } } - pub(crate) fn get_path(&self, path: &impl PathLike) -> Option { - self.map_for(path).get(path.path_text()).copied() + pub(crate) fn get_path(&self, path: &Path<'_>) -> Option { + self.map_for(path).get(path.text).copied() } pub(crate) fn put_path( &mut self, - path: &impl PathLike, - value: IndexInt, + path: &Path<'_>, + value: V, ) -> Result<(), bun_alloc::AllocError> { - self.map_for_mut(path).put(path.path_text(), value) + self.map_for_mut(path).put(path.text, value) } pub(crate) fn get_or_put_path( &mut self, - path: &impl PathLike, - ) -> Result, bun_alloc::AllocError> { - self.map_for_mut(path).get_or_put(path.path_text()) + path: &Path<'_>, + ) -> Result, bun_alloc::AllocError> { + self.map_for_mut(path).get_or_put(path.text) } - pub(crate) fn get(&self, text: impl AsRef<[u8]>) -> Option { + pub(crate) fn get(&self, text: impl AsRef<[u8]>) -> Option { self.map.get(text.as_ref()).copied() } // Takes `&[u8]` (not `impl AsRef<[u8]>`) // to avoid E0283 inference ambiguity at `.into()` call sites in bundle_v2. - pub(crate) fn put( - &mut self, - text: &[u8], - value: IndexInt, - ) -> Result<(), bun_alloc::AllocError> { + pub(crate) fn put(&mut self, text: &[u8], value: V) -> Result<(), bun_alloc::AllocError> { // PERF: bun_collections::StringHashMap is keyed by `Box<[u8]>`, so we dupe here. // Revisit once StringHashMap gains a borrowed-key variant. self.map.put(text, value) @@ -104,7 +76,7 @@ impl PathToSourceIndexMap { pub(crate) fn get_or_put( &mut self, text: impl AsRef<[u8]>, - ) -> Result, bun_alloc::AllocError> { + ) -> Result, bun_alloc::AllocError> { // PERF: see note in `put` re: key duplication. self.map.get_or_put(text.as_ref()) } @@ -112,4 +84,17 @@ impl PathToSourceIndexMap { pub fn remove(&mut self, text: impl AsRef<[u8]>) -> bool { self.map.remove(text.as_ref()).is_some() } + + pub(crate) fn values(&self) -> impl Iterator { + self.map.values().chain(self.disabled_modules.values()) + } + + pub(crate) fn reserve(&mut self, additional: usize) { + self.map.reserve(additional); + } + + pub(crate) fn clear(&mut self) { + self.map.clear(); + self.disabled_modules.clear(); + } } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 0af5986347a5..0168e43bcc76 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6,7 +6,7 @@ use core::ptr::NonNull; -use bun_collections::{ArrayHashMap, StringHashMap}; +use bun_collections::ArrayHashMap; use bun_core::ThreadLock; // `bake_types` / `dispatch` are canonically defined in `bv2_impl` below @@ -151,7 +151,7 @@ bun_core::declare_scope!(scan_counter, visible); /// Values are raw `*mut ParseTask` (arena-owned by `graph.heap`); the map only /// dedups by path during a single `on_parse_task_complete` pass. -pub(crate) type ResolveQueue<'a> = StringHashMap<*mut ParseTask<'a>>; +pub(crate) type ResolveQueue<'a> = crate::PathToSourceIndexMap::PathMap<*mut ParseTask<'a>>; pub struct BakeOptions<'a> { pub framework: bake::Framework, @@ -596,6 +596,7 @@ pub mod bv2_impl { namespace: b"bun", is_disabled: false, is_symlink: true, + is_disabled_module: false, }, index: bun_ast::Index(crate::Index::BAKE_SERVER_DATA.get()), ..Default::default() @@ -609,6 +610,7 @@ pub mod bv2_impl { namespace: b"bun", is_disabled: false, is_symlink: true, + is_disabled_module: false, }, index: bun_ast::Index(crate::Index::BAKE_CLIENT_DATA.get()), ..Default::default() @@ -1454,7 +1456,7 @@ pub mod bv2_impl { use crate::DeferredBatchTask::DeferredBatchTask; use crate::Graph::Graph; use crate::LinkerContext; - use crate::PathToSourceIndexMap::{PathLike as _, PathToSourceIndexMap}; + use crate::PathToSourceIndexMap::PathToSourceIndexMap; use crate::ServerComponentParseTask::ServerComponentParseTask; use crate::barrel_imports; @@ -2576,7 +2578,7 @@ pub mod bv2_impl { .filter(|&index| { loaders[index].is_javascript_like() && sources[index].path.is_file() - && !sources[index].path.is_disabled + && !sources[index].path.is_disabled_module }) .map(|index| sources[index].path.text); // The types are those of what is written, as in `bun check`, not of what a plugin makes @@ -2585,7 +2587,7 @@ pub mod bv2_impl { .filter(|((source, loader), flags)| { loader.is_javascript_like_or_json() && source.path.is_file() - && !source.path.is_disabled + && !source.path.is_disabled_module && !(flags.contains(crate::Graph::InputFileFlags::IS_LOADED_BY_PLUGIN) && bun_sys::exists(source.path.text)) }) @@ -2939,13 +2941,12 @@ pub mod bv2_impl { return; } }; - let is_empty_module = path.is_disabled_module(); if resolve_result.flags.is_external() { return; } - if !is_empty_module && path.pretty.as_ptr() == path.text.as_ptr() { + if path.pretty.as_ptr() == path.text.as_ptr() { // TODO: outbase let rel = bun_paths::resolve_path::relative_platform::< bun_paths::resolve_path::platform::Loose, @@ -2957,24 +2958,18 @@ pub mod bv2_impl { path.pretty = self.arena().alloc_slice_copy(rel); } path.assert_pretty_is_valid(); - if !is_empty_module { - // A disabled bare name has no file behind it. - path.assert_file_path_is_absolute(); - } + path.assert_file_path_is_absolute(); // borrowck: get-then-put (instead of a single get-or-put) so the map // borrow doesn't span `enqueue_parse_task` (which needs `&mut self`). if let Some(existing) = self.path_to_source_index_map(target).get_path(&path) { out_source_index = Some(Index::init(existing)); } else { - let path = if is_empty_module { - path_in_arena(&path, self.arena()) - } else { - self.path_with_pretty_initialized(&path, target) - .expect("oom") - }; + let path = self + .path_with_pretty_initialized(&path, target) + .expect("oom"); let loader: Loader = 'brk: { - if is_empty_module { + if path.is_disabled_module { break 'brk Loader::Js; } let record: &ImportRecord = &self.graph.ast.items_import_records() @@ -4133,11 +4128,7 @@ pub mod bv2_impl { self.graph.input_files.append(crate::Graph::InputFile { source: core::mem::take(source), loader, - side_effects: if path.is_disabled_module() { - crate::PathToSourceIndexMap::DISABLED_MODULE_SIDE_EFFECTS - } else { - loader.side_effects() - }, + side_effects: loader.side_effects(), ..Default::default() })?; // `ParseTask::init` takes `bun_ast::Index`; both Index newtypes @@ -6404,8 +6395,8 @@ pub mod bv2_impl { } pub(crate) fn enqueue_on_load_plugin_if_needed(&mut self, parse: &mut ParseTask) -> bool { - // The empty module of a disabled path. No plugin loads that path. - if parse.path.is_disabled_module() { + // No plugin loads the file that the empty module stands in for. + if parse.path.is_disabled_module { return false; } let had_matches = self.enqueue_on_load_plugin_if_needed_impl(parse); @@ -6476,54 +6467,24 @@ pub mod bv2_impl { result: &'p mut _resolver::Result<'r>, importer: Loader, kind: ImportKind, - ) -> Option<&'p mut Fs::Path<'r>> - where - 'a: 'r, - { - let primary = result.path_pair.primary; - if primary.is_disabled { - // Like esbuild. Bun's stubs for Node.js builtins (`node` namespace) stay external. - if self.dev_server.is_none() - && importer.is_javascript_like() - && matches!( - kind, - ImportKind::Stmt | ImportKind::Require | ImportKind::Dynamic - ) - && primary.is_file() - { - result.path_pair.primary = self.disabled_module_path(&primary); - } else { - // A live "main" stands in for a disabled "module" entry. The fds are not its own. - let secondary = result.path_pair.secondary.take()?; - if secondary.is_disabled { - return None; - } - result.path_pair.primary = secondary; - result.file_fd = bun_sys::Fd::INVALID; - result.dirname_fd = bun_sys::Fd::INVALID; - } + ) -> Option<&'p mut Fs::Path<'r>> { + if result.path_const().is_some() { + return result.path(); + } + let path = &mut result.path_pair.primary; + // Bun's stubs for Node.js builtins are in the `node` namespace and stay external. + let is_javascript_import = importer.is_javascript_like() + && matches!( + kind, + ImportKind::Stmt | ImportKind::Require | ImportKind::Dynamic + ); + if self.dev_server.is_some() || !is_javascript_import || !path.is_file() { + return None; } - Some(&mut result.path_pair.primary) - } - - /// `pretty` is what esbuild prints: `(disabled):`. - fn disabled_module_path(&self, path: &Fs::Path<'_>) -> Fs::Path<'a> { - use crate::bun_fs::PathResolverExt as _; - - let rel: &[u8] = if bun_paths::is_absolute(path.text) { - bun_paths::resolve_path::relative_platform::< - bun_paths::resolve_path::platform::Loose, - false, - >(self.transpiler.fs().top_level_dir(), path.text) - } else { - path.text - }; - let pretty = [crate::PathToSourceIndexMap::DISABLED_PRETTY_PREFIX, rel].concat(); - let mut with_pretty: Fs::Path<'_> = *path; - with_pretty.pretty = &pretty; - with_pretty - .dupe_alloc_fix_pretty(self.graph.heap) - .expect("oom") + // Like esbuild, the import loads an empty module in place of the file. + path.is_disabled = false; + path.is_disabled_module = true; + Some(path) } fn path_with_pretty_initialized( @@ -7236,7 +7197,6 @@ pub mod bv2_impl { continue; } }; - let is_empty_module = path.is_disabled_module(); if resolve_result.flags.is_external() { if resolve_result.flags.external_kind() @@ -7332,7 +7292,7 @@ pub mod bv2_impl { } let import_record_loader = 'brk: { - if is_empty_module { + if path.is_disabled_module { break 'brk Loader::Js; } let resolved_loader = import_record.loader.unwrap_or_else(|| { @@ -7375,24 +7335,16 @@ pub mod bv2_impl { import_record.kind = ImportKind::HtmlManifest; } - // In one importer's queue the pretty path tells the empty module from its file. - let queue_key = if is_empty_module { - path.pretty - } else { - path.text - }; - let resolve_entry = resolve_queue.get_or_put(queue_key).expect("oom"); + let resolve_entry = resolve_queue.get_or_put_path(path).expect("oom"); if resolve_entry.found_existing { // SAFETY: arena-allocated `ParseTask` stored in the queue; arena outlives the pass. import_record.path = unsafe { &**resolve_entry.value_ptr }.path; continue; } - if !is_empty_module { - *path = self - .path_with_pretty_initialized(path, target) - .expect("oom"); - } + *path = self + .path_with_pretty_initialized(path, target) + .expect("oom"); import_record.path = *path; // key already interned by get_or_put — no key_ptr on StringHashMapGetOrPut @@ -8431,7 +8383,19 @@ pub mod bv2_impl { }; let mut prefixed = Vec::new(); let mut path_clone: crate::bun_fs::Path<'_> = *path; - path_clone.pretty = if path.is_file() || is_node { + path_clone.pretty = if path.is_disabled_module { + // A disabled bare name has no file. Its name is its path. + let rel = if bun_paths::is_absolute(path.text) { + bun_paths::resolve_path::relative_platform::< + bun_paths::resolve_path::platform::Loose, + false, + >(top_level_dir, path.text) + } else { + path.text + }; + prefixed = [crate::PathToSourceIndexMap::DISABLED_PRETTY_PREFIX, rel].concat(); + &prefixed + } else if path.is_file() || is_node { let rel = bun_paths::resolve_path::relative_platform::< bun_paths::resolve_path::platform::Loose, false, @@ -8469,6 +8433,7 @@ pub mod bv2_impl { namespace: bump.alloc_slice_copy(path.namespace), is_disabled: path.is_disabled, is_symlink: path.is_symlink, + is_disabled_module: path.is_disabled_module, } } diff --git a/src/bundler/linker_context/scanImportsAndExports.rs b/src/bundler/linker_context/scanImportsAndExports.rs index af722a677d38..73dee5b22d28 100644 --- a/src/bundler/linker_context/scanImportsAndExports.rs +++ b/src/bundler/linker_context/scanImportsAndExports.rs @@ -305,18 +305,6 @@ pub(crate) fn scan_imports_and_exports( match record.kind { ImportKind::Stmt => { - // The rule below, for the empty module: its lazy export fails that condition. - if col_ref!(input_files)[other_file].path.is_disabled { - if record.flags.intersects( - ImportRecordFlags::CONTAINS_IMPORT_STAR - | ImportRecordFlags::CONTAINS_DEFAULT_ALIAS, - ) { - col!(exports_kind)[other_file] = ExportsKind::Cjs; - col!(flags)[other_file].wrap = WrapKind::Cjs; - } - continue; - } - // Importing using ES6 syntax from a file without any ES6 syntax // causes that module to be considered CommonJS-style, even if it // doesn't have any CommonJS exports. diff --git a/src/paths/lib.rs b/src/paths/lib.rs index a5fb3d79d2c4..e65f3222cb9f 100644 --- a/src/paths/lib.rs +++ b/src/paths/lib.rs @@ -764,6 +764,8 @@ pub mod fs { pub namespace: &'a [u8], pub is_disabled: bool, pub is_symlink: bool, + /// The empty module that the bundler loads in place of the disabled file `text`. + pub is_disabled_module: bool, } const _: () = assert!(core::mem::size_of::>() <= 56); @@ -777,6 +779,7 @@ pub mod fs { namespace: b"", is_disabled: false, is_symlink: false, + is_disabled_module: false, } } } @@ -788,6 +791,7 @@ pub mod fs { namespace: b"file", is_disabled: false, is_symlink: false, + is_disabled_module: false, }; /// Parsed (dir/base/ext/filename) view of `text`. Computed on demand — @@ -808,6 +812,7 @@ pub mod fs { namespace: b"file", is_disabled: false, is_symlink: false, + is_disabled_module: false, } } @@ -821,6 +826,7 @@ pub mod fs { namespace: b"file", is_disabled: false, is_symlink: false, + is_disabled_module: false, } } @@ -834,6 +840,7 @@ pub mod fs { namespace, is_disabled: false, is_symlink: false, + is_disabled_module: false, } } @@ -851,6 +858,7 @@ pub mod fs { text, namespace, is_disabled: false, + is_disabled_module: false, } } @@ -870,7 +878,8 @@ pub mod fs { /// CI-assert only. #[inline] pub fn assert_file_path_is_absolute(&self) { - if bun_core::Environment::CI_ASSERT && self.is_file() { + // A disabled bare name has no file. + if bun_core::Environment::CI_ASSERT && self.is_file() && !self.is_disabled_module { debug_assert!(crate::is_absolute(self.text)); } } @@ -927,6 +936,14 @@ pub mod fs { } } + /// Copies the flags of `other`: a copy of a path keeps them. + #[inline] + pub fn copy_flags_from(&mut self, other: &Path<'_>) { + self.is_disabled = other.is_disabled; + self.is_symlink = other.is_symlink; + self.is_disabled_module = other.is_disabled_module; + } + /// Records the resolved symlink target: `text` becomes `to`, the old /// path is kept as `pretty`, and `is_symlink` is set. pub fn set_realpath(&mut self, to: &'a [u8]) { diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 8100e5eed708..27674e076537 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -376,21 +376,6 @@ pub mod fs { .ok_or(bun_errno::SystemErrno::ENAMETOOLONG) } - /// Like `abs`, but interns the joined path into `DirnameStore` and - /// returns the `'static` copy. - pub(crate) fn abs_alloc( - &self, - parts: &[&[u8]], - ) -> core::result::Result<&'static [u8], bun_alloc::AllocError> { - use bun_paths::resolve_path::{join_abs_string, platform}; - let joined = join_abs_string::(self.top_level_dir(), parts); - // Route through DirnameStore so - // the resolver's `&'static [u8]` storage contract holds. - DirnameStore::instance() - .append_slice(joined) - .map_err(|_| bun_alloc::AllocError) - } - /// Relative path from `from` to `to`. Returns a slice into the /// resolver-shared threadlocal relative buffer; caller must dup /// before the next call. @@ -539,6 +524,7 @@ pub mod fs { }, is_symlink: self.is_symlink, is_disabled: self.is_disabled, + is_disabled_module: self.is_disabled_module, }; if core::ptr::eq(self.text.as_ptr(), self.pretty.as_ptr()) @@ -551,8 +537,7 @@ pub mod fs { let text = FilenameStore::instance().append_slice(self.text)?; let mut new_path = Path::<'static>::init(text); new_path.namespace = dupe_namespace(self.namespace)?; - new_path.is_symlink = self.is_symlink; - new_path.is_disabled = self.is_disabled; + new_path.copy_flags_from(self); Ok(new_path) } else if self.pretty.is_empty() { if let Some(text) = as_interned_path(self.text) { @@ -562,8 +547,7 @@ pub mod fs { let mut new_path = Path::<'static>::init(text); new_path.pretty = b""; new_path.namespace = dupe_namespace(self.namespace)?; - new_path.is_symlink = self.is_symlink; - new_path.is_disabled = self.is_disabled; + new_path.copy_flags_from(self); Ok(new_path) } else if let Some([offset, len]) = bun_alloc::range_of_slice_in_buffer(self.pretty, self.text) @@ -579,8 +563,7 @@ pub mod fs { let mut new_path = Path::<'static>::init(text); new_path.pretty = &text[offset as usize..][..len as usize]; new_path.namespace = dupe_namespace(self.namespace)?; - new_path.is_symlink = self.is_symlink; - new_path.is_disabled = self.is_disabled; + new_path.copy_flags_from(self); Ok(new_path) } else { if let (Some(text), Some(pretty)) = @@ -620,13 +603,11 @@ pub mod fs { b"" | b"file" => b"file", ns => as_interned_path(ns).unwrap_or_else(|| arena_z(ns)), }; - p.is_symlink = self.is_symlink; - p.is_disabled = self.is_disabled; + p.copy_flags_from(self); return Ok(p); }; new_path.namespace = dupe_namespace(self.namespace)?; - new_path.is_symlink = self.is_symlink; - new_path.is_disabled = self.is_disabled; + new_path.copy_flags_from(self); Ok(new_path) } } diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 8f87c3d70c5d..e0e90d482fb9 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -331,7 +331,6 @@ pub struct Bufs { pub(crate) tsconfig_match_full_buf: PathBuffer, pub(crate) tsconfig_match_full_buf3: PathBuffer, - pub(crate) esm_subpath: [u8; 512], pub(crate) esm_absolute_package_path: PathBuffer, pub(crate) esm_absolute_package_path_joined: PathBuffer, @@ -404,7 +403,7 @@ fn bufs_storage_get() -> *mut Bufs { #[cold] fn bufs_storage_init() -> *mut Bufs { // SAFETY: every field of `Bufs` is a byte/integer array (`PathBuffer` = - // `[u8; N]`, `[u8; 512]`, `[FD; 256]` where `Fd` is a `#[repr(transparent)]` + // `[u8; N]`, `[FD; 256]` where `Fd` is a `#[repr(transparent)]` // integer newtype with no niche) or `[MaybeUninit<_>; 256]`, so the // all-zero bit-pattern is a valid `Bufs`. `new_zeroed` (not `new_uninit`) // because integers must be initialized: a never-written `[u8; N]` is UB @@ -473,8 +472,8 @@ pub struct Resolver<'a> { /// Read the "browser" field in package.json files? /// For Bun's runtime, we don't. pub(crate) care_about_browser_field: bool, - /// Depth of nested "browser" map target lookups. A map can send a subpath back to itself. - browser_remap_depth: u8, + /// The "browser" map entries whose targets are being resolved, by address. + browser_remap_stack: BoundedArray, pub debug_logs: Option, pub elapsed: u64, // tracing @@ -618,7 +617,7 @@ impl<'a> Resolver<'a> { care_about_bin_folder: from.care_about_bin_folder, care_about_scripts: from.care_about_scripts, care_about_browser_field: from.care_about_browser_field, - browser_remap_depth: 0, + browser_remap_stack: BoundedArray::default(), // `DebugLogs` owns Vecs — per-worker fresh. debug_logs: None, elapsed: 0, @@ -985,7 +984,7 @@ impl<'a> Resolver<'a> { log, extension_order: options::ExtOrder::DefaultDefault, care_about_browser_field, - browser_remap_depth: 0, + browser_remap_stack: BoundedArray::default(), care_about_bin_folder: false, care_about_scripts: false, debug_logs: None, @@ -1893,28 +1892,40 @@ impl<'a> Resolver<'a> { } } - // A disabled path names a module too: a symlinked directory must not make it two. + // Like a live file, a disabled path that names a directory entry gets the real path of the entry. let PathPair { primary, secondary } = &mut result.path_pair; for path in core::iter::once(primary).chain(secondary.as_mut()) { - if !path.is_disabled || !path.is_file() || !bun_paths::is_absolute(path.text()) { + if !path.is_disabled { continue; } let name = path.name(); let Ok(Some(dir)) = self.read_dir_info(name.dir) else { continue; }; - if dir.abs_real_path.is_empty() { + let Some(query) = dir.get_entry(self.generation, name.filename) else { continue; + }; + // SAFETY: `rfs_ptr` is the process-global RealFS. `symlink()` locks the entry. + let mut symlink = unsafe { query.entry().symlink(self.rfs_ptr(), self.store_fd) }; + if symlink.is_empty() && !dir.abs_real_path.is_empty() { + let mut buf = bun_paths::path_buffer_pool::get(); + let Some(real) = ::bun_paths::resolve_path::join_abs_string_buf_z_checked::< + ::bun_paths::platform::Loose, + >( + self.fs_ref().top_level_dir(), + &mut buf[..], + &[dir.abs_real_path, query.entry().base()], + ) else { + continue; + }; + symlink = Fs::FilenameStore::instance().append_slice(real.as_bytes())?; + let _entry_guard = query.entry().mutex.lock_guard(); + query + .entry() + .set_cache_symlink(Interned::from_static(symlink)); } - let mut buf = bun_paths::path_buffer_pool::get(); - if let Some(real) = ::bun_paths::resolve_path::join_abs_string_buf_z_checked::< - ::bun_paths::platform::Loose, - >( - self.fs_ref().top_level_dir(), - &mut buf[..], - &[dir.abs_real_path, name.filename], - ) { - path.set_realpath(Fs::FilenameStore::instance().append_slice(real.as_bytes())?); + if !symlink.is_empty() { + path.set_realpath(symlink); } } @@ -2315,16 +2326,9 @@ impl<'a> Resolver<'a> { { // Is the path disabled? if remap.is_empty() { - let mut _path = Path::init( - self.fs_ref() - .dirname_store - .append_slice(abs_path) - .expect("unreachable"), - ); - _path.is_disabled = true; return ResultUnion::Success(Result { path_pair: PathPair { - primary: _path, + primary: self.disabled_path(abs_path), secondary: None, }, ..Default::default() @@ -2456,7 +2460,6 @@ impl<'a> Resolver<'a> { } }; - let mut relative_remap_scope: Option = None; if self.care_about_browser_field { // Support remapping one package path to another via the "browser" field if let Some(browser_scope) = source_dir_info.get_enclosing_browser_scope() { @@ -2511,9 +2514,6 @@ impl<'a> Resolver<'a> { } } - if !is_package_path(remapped) { - relative_remap_scope = Some(browser_scope); - } import_path = remapped; source_dir_info = browser_scope; } @@ -2558,11 +2558,10 @@ impl<'a> Resolver<'a> { return ResultUnion::Success(result); } - // A file that no "browser" map lookup picked gets one, in the map nearest to it. - if self.care_about_browser_field + // esbuild has no lookup here. It stays for the target of "exports", unless a package candidate already had its lookup. + if res.package_json.is_some() + && self.care_about_browser_field && !res.browser_remapped - && !res.is_external - && !result.path_pair.primary.is_disabled { let base_dir_info = match res.dir_info { Some(d) => d, @@ -2571,11 +2570,7 @@ impl<'a> Resolver<'a> { _ => return ResultUnion::Success(result), }, }; - // The map that sent the specifier to a relative path does not apply to it again. - if let Some(browser_scope) = base_dir_info - .get_enclosing_browser_scope() - .filter(|scope| relative_remap_scope != Some(*scope)) - { + if let Some(browser_scope) = base_dir_info.get_enclosing_browser_scope() { if let Some(remap) = self .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( base_dir_info, @@ -2583,7 +2578,15 @@ impl<'a> Resolver<'a> { ) { if remap.is_empty() { - result.path_pair.primary.is_disabled = true; + // A live "main" stands in for a disabled "module" entry. + match result.path_pair.secondary.take_if(|main| !main.is_disabled) { + Some(main) => { + result.path_pair.primary = main; + result.file_fd = FD::INVALID; + result.dirname_fd = FD::INVALID; + } + None => result.path_pair.primary.is_disabled = true, + } } else { let mut remapped = MatchResult::default(); if self @@ -2826,7 +2829,9 @@ impl<'a> Resolver<'a> { } } - let esm_ = crate::package_json::Package::parse(import_path, bufs!(esm_subpath)); + // On the frame: a "browser" map target can re-enter this function. + let mut esm_subpath_buf = [0u8; 512]; + let esm_ = crate::package_json::Package::parse(import_path, &mut esm_subpath_buf); let source_dir_info = dir_info; let mut any_node_modules_folder = false; @@ -5382,7 +5387,7 @@ impl<'a> Resolver<'a> { ) } - /// Like esbuild's `tryToResolvePackage`: the package's "browser" map, then the file lookup. + /// A package candidate of a browser build. esbuild does one lookup before the file. Here a key with an implicit extension applies only to the file that loads. fn load_package_file_with_browser_map( &mut self, pkg_dir_info: Option, @@ -5391,59 +5396,154 @@ impl<'a> Resolver<'a> { global_cache: GlobalCache, out: &mut MatchResult, ) -> MatchStatus { - let remap = match pkg_dir_info { - Some(pkg_dir_info) if self.care_about_browser_field => self - .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>(pkg_dir_info, abs_path) - .zip(pkg_dir_info.get_enclosing_browser_scope()), - _ => None, - }; - let Some((remap, browser_scope)) = remap else { + let Some(pkg_dir_info) = pkg_dir_info.filter(|_| self.care_about_browser_field) else { return self.load_as_file_or_directory(abs_path, kind, out); }; + // The lookup of a target reuses the scratch that `abs_path` can live in. + let mut abs_path_buf = bun_paths::path_buffer_pool::get(); + let Ok(abs_path) = strings::concat_buf_t(&mut **abs_path_buf, &[abs_path]) else { + return MatchStatus::NotFound; + }; + + // A key that names the path as it is spelled wins over the file. + let spelled = self.check_browser_map_with::<{ BrowserMapPathKind::AbsolutePath }>( + pkg_dir_info, + abs_path, + ImplicitExtensions::Skip, + ); + if let Some(remap) = spelled { + if self.load_browser_map_entry(pkg_dir_info, remap, abs_path, kind, global_cache, out) { + return MatchStatus::Success; + } + // Like esbuild, an unresolved target falls back to the file itself. + let status = self.load_as_file_or_directory(abs_path, kind, out); + out.browser_remapped = status.is_success(); + return status; + } + // Else the file loads, and the key that names the loaded file applies to it. + if self.load_as_file_match(abs_path, out) { + self.remap_loaded_file(out, abs_path, kind, global_cache); + return MatchStatus::Success; + } + // A directory has its own lookups, for its "main" file and for its index file. + if self.load_as_directory(abs_path, kind, out).is_success() { + // Like esbuild, an index file that this map disables has the name that the import spells. + if out.path_pair.primary.is_disabled + && self + .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( + pkg_dir_info, + abs_path, + ) + .is_some_and(<[u8]>::is_empty) + { + out.path_pair.primary = self.disabled_path(abs_path); + } + return MatchStatus::Success; + } + + // A key with an implicit extension can only name a file that does not exist. + match self.check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>(pkg_dir_info, abs_path) + { + Some(remap) + if self.load_browser_map_entry( + pkg_dir_info, + remap, + abs_path, + kind, + global_cache, + out, + ) => + { + MatchStatus::Success + } + _ => MatchStatus::NotFound, + } + } + + /// Loads what a "browser" map entry names: the disabled `abs_path` for `false`, else the target. + fn load_browser_map_entry( + &mut self, + dir_info: DirInfoRef, + remap: &'static [u8], + abs_path: &[u8], + kind: ast::ImportKind, + global_cache: GlobalCache, + out: &mut MatchResult, + ) -> bool { + let Some(browser_scope) = dir_info.get_enclosing_browser_scope() else { + return false; + }; if remap.is_empty() { - let mut path = Path::init( - self.fs_ref() - .dirname_store - .append_slice(abs_path) - .expect("oom"), - ); - path.is_disabled = true; *out = MatchResult { path_pair: PathPair { - primary: path, + primary: self.disabled_path(abs_path), secondary: None, }, package_json: browser_scope.package_json().map(std::ptr::from_ref), browser_remapped: true, ..Default::default() }; - return MatchStatus::Success; + return true; } - // The nested lookup reuses the scratch that `abs_path` may live in. - let mut abs_path_buf = bun_paths::path_buffer_pool::get(); - let Ok(abs_path) = strings::concat_buf_t(&mut **abs_path_buf, &[abs_path]) else { - return MatchStatus::NotFound; - }; + // A target that is a package path can lead back to this entry. + let entry = remap.as_ptr() as usize; + if self.browser_remap_stack.const_slice().contains(&entry) + || self.browser_remap_stack.append(entry).is_err() + { + return false; + } + let resolved = self + .resolve_without_remapping(browser_scope, remap, kind, global_cache, out) + .is_success(); + self.browser_remap_stack.pop(); + if resolved { + out.browser_remapped = true; + } + resolved + } - if self.browser_remap_depth < MAX_BROWSER_REMAP_DEPTH { - // It also parses into the scratch the caller's `esm.subpath` points at. - let caller_esm_subpath = *bufs!(esm_subpath); - self.browser_remap_depth += 1; - let resolved = self - .resolve_without_remapping(browser_scope, remap, kind, global_cache, out) - .is_success(); - self.browser_remap_depth -= 1; - *bufs!(esm_subpath) = caller_esm_subpath; - if resolved { - out.browser_remapped = true; - return MatchStatus::Success; - } + /// Applies the "browser" map nearest to the file that loaded, by the name of that file. + fn remap_loaded_file( + &mut self, + out: &mut MatchResult, + spelled: &[u8], + kind: ast::ImportKind, + global_cache: GlobalCache, + ) { + out.browser_remapped = true; + let loaded = out.path_pair.primary; + let Ok(Some(dir_info)) = self.read_dir_info(loaded.name().dir) else { + return; + }; + let Some(remap) = + self.check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>(dir_info, loaded.text()) + else { + return; + }; + // Like a relative import, a disabled file has the name that the import spells. + let mut target = MatchResult::default(); + if self.load_browser_map_entry(dir_info, remap, spelled, kind, global_cache, &mut target) { + *out = target; } + } - // Like esbuild, an unresolved target falls back to the file itself. - self.load_as_file_or_directory(abs_path, kind, out) + /// The path of a disabled file: no trailing separator, and one copy of each distinct path. + fn disabled_path(&mut self, abs_path: &[u8]) -> Path { + let text = ::bun_paths::string_paths::without_trailing_slash_windows_path(abs_path); + let text = DISABLED_PATHS.with_borrow_mut(|paths| { + if let Some((interned, ())) = paths.get_key_value(text) { + return *interned; + } + let interned: &'static [u8] = + bun_core::handle_oom(self.fs_ref().dirname_store.append_slice(text)); + paths.insert(interned, ()); + interned + }); + let mut path = Path::init(text); + path.is_disabled = true; + path } /// The remap target of `input_path_`, relative to the browser scope. Empty means disabled. @@ -5451,6 +5551,19 @@ impl<'a> Resolver<'a> { &mut self, resolve_dir_info: DirInfoRef, input_path_: &[u8], + ) -> Option<&'static [u8]> { + self.check_browser_map_with::( + resolve_dir_info, + input_path_, + ImplicitExtensions::Include, + ) + } + + fn check_browser_map_with( + &mut self, + resolve_dir_info: DirInfoRef, + input_path_: &[u8], + implicit_extensions: ImplicitExtensions, ) -> Option<&'static [u8]> { let browser_scope = resolve_dir_info.get_enclosing_browser_scope()?; let package_json = browser_scope.package_json()?; @@ -5480,7 +5593,7 @@ impl<'a> Resolver<'a> { map: &package_json.browser_map, }; - if checker.check_path(input_path, ImplicitExtensions::Include) { + if checker.check_path(input_path, implicit_extensions) { return Some(checker.remapped); } @@ -5490,7 +5603,7 @@ impl<'a> Resolver<'a> { match KIND { BrowserMapPathKind::AbsolutePath => { let candidate = strings::concat_buf_t(abs_to_rel, &[b"./", input_path]).ok()?; - if checker.check_path(candidate, ImplicitExtensions::Include) { + if checker.check_path(candidate, implicit_extensions) { return Some(checker.remapped); } } @@ -5578,7 +5691,6 @@ impl<'a> Resolver<'a> { dec_ret!(MatchStatus::NotFound); }; - let mut browser_remapped = false; if self.care_about_browser_field { // Potentially remap using the "browser" field if let Some(browser_scope) = dir_info.get_enclosing_browser_scope() { @@ -5591,13 +5703,9 @@ impl<'a> Resolver<'a> { { // Is the path disabled? if remap.is_empty() { - let paths = [path, field_rel_path]; - let new_path = self.fs_ref().abs_alloc(&paths).expect("unreachable"); - let mut _path = Path::init(new_path); - _path.is_disabled = true; *out = MatchResult { path_pair: PathPair { - primary: _path, + primary: self.disabled_path(field_abs_path), secondary: None, }, package_json: Some(std::ptr::from_ref(browser_json)), @@ -5614,7 +5722,6 @@ impl<'a> Resolver<'a> { Some(remapped) => remapped, None => dec_ret!(MatchStatus::NotFound), }; - browser_remapped = true; } } } @@ -5622,14 +5729,25 @@ impl<'a> Resolver<'a> { // Is this a file? if let Some(result) = self.load_as_file(field_abs_path, extension_order) { + if let Some(package_json) = dir_info.package_json() { + *out = MatchResult { + path_pair: PathPair { + primary: Fs::Path::init(result.path), + secondary: None, + }, + package_json: Some(std::ptr::from_ref(package_json)), + dirname_fd: result.dirname_fd, + ..Default::default() + }; + dec_ret!(MatchStatus::Success); + } + *out = MatchResult { path_pair: PathPair { primary: Fs::Path::init(result.path), secondary: None, }, - package_json: dir_info.package_json().map(std::ptr::from_ref), dirname_fd: result.dirname_fd, - browser_remapped, ..Default::default() }; dec_ret!(MatchStatus::Success); @@ -5646,9 +5764,6 @@ impl<'a> Resolver<'a> { extension_order, out, ); - if browser_remapped && r.is_success() { - out.browser_remapped = true; - } if let Some(d) = self.debug_logs.as_mut() { d.decrease_indent(); } @@ -5852,13 +5967,9 @@ impl<'a> Resolver<'a> { { // Is the path disabled? if remap.is_empty() { - let new_path = - self.fs_ref().abs_alloc(&index_paths).expect("unreachable"); - let mut _path = Path::init(new_path); - _path.is_disabled = true; *out = MatchResult { path_pair: PathPair { - primary: _path, + primary: self.disabled_path(index_abs_path), secondary: None, }, package_json: Some(std::ptr::from_ref(browser_json)), @@ -5884,7 +5995,6 @@ impl<'a> Resolver<'a> { primary: Path::init(file_result.path), secondary: None, }, - browser_remapped: true, ..Default::default() }; return MatchStatus::Success; @@ -5896,7 +6006,6 @@ impl<'a> Resolver<'a> { .load_as_index(new_dir, extension_order, out) .is_success() { - out.browser_remapped = true; return MatchStatus::Success; } } @@ -5916,6 +6025,14 @@ impl<'a> Resolver<'a> { kind: ast::ImportKind, out: &mut MatchResult, ) -> MatchStatus { + if self.load_as_file_match(path, out) { + return MatchStatus::Success; + } + self.load_as_directory(path, kind, out) + } + + /// The file half of `load_as_file_or_directory`. + fn load_as_file_match(&mut self, path: &[u8], out: &mut MatchResult) -> bool { let extension_order = self.extension_order; // Is this a file? @@ -5942,7 +6059,7 @@ impl<'a> Resolver<'a> { file_fd: file.file_fd, ..Default::default() }; - return MatchStatus::Success; + return true; } } } @@ -5959,9 +6076,21 @@ impl<'a> Resolver<'a> { file_fd: file.file_fd, ..Default::default() }; - return MatchStatus::Success; + return true; } + false + } + + /// The directory half of `load_as_file_or_directory`. + fn load_as_directory( + &mut self, + path: &[u8], + kind: ast::ImportKind, + out: &mut MatchResult, + ) -> MatchStatus { + let extension_order = self.extension_order; + // Is this a directory? if let Some(debug) = self.debug_logs.as_mut() { debug.add_note_fmt(format_args!( @@ -6105,7 +6234,13 @@ impl<'a> Resolver<'a> { // // Additionally, if this is for the runtime, use the "main" field. // If it doesn't exist, the "module" field will be used. - if self.prefer_module_field && kind != ast::ImportKind::Require { + // A live "main" stands in for a "module" entry that the "browser" map disables. + let module_is_disabled = out.path_pair.primary.is_disabled + && !auto_main_result.path_pair.primary.is_disabled; + if self.prefer_module_field + && kind != ast::ImportKind::Require + && !module_is_disabled + { if let Some(debug) = self.debug_logs.as_mut() { debug.add_note_fmt(format_args!( "Resolved to \"{}\" using the \"{}\" field in \"{}\"", @@ -6129,7 +6264,6 @@ impl<'a> Resolver<'a> { dirname_fd: out.dirname_fd, package_json, file_fd: auto_main_result.file_fd, - browser_remapped: out.browser_remapped, ..Default::default() }; dec_ret!(MatchStatus::Success); @@ -7167,8 +7301,14 @@ enum ImplicitExtensions { Skip, } -/// A longer chain of "browser" map targets is a cycle, and the remap stops applying. -const MAX_BROWSER_REMAP_DEPTH: u8 = 16; +/// A "browser" map target that is a package path can name another entry. A real chain is short. +const MAX_BROWSER_REMAP_DEPTH: usize = 16; + +thread_local! { + /// The paths that `Resolver::disabled_path` interned. The keys point into `DirnameStore`. + static DISABLED_PATHS: core::cell::RefCell> = + core::cell::RefCell::new(bun_collections::HashMap::new()); +} pub(crate) struct BrowserMapPath<'b> { pub(crate) remapped: &'static [u8], diff --git a/src/resolver/result.rs b/src/resolver/result.rs index 04fbdf645f47..65dd501b7b3f 100644 --- a/src/resolver/result.rs +++ b/src/resolver/result.rs @@ -371,7 +371,7 @@ pub struct MatchResult { pub(crate) dir_info: Option, pub(crate) module_type: options::ModuleType, pub(crate) is_external: bool, - /// A "browser" map lookup picked `path_pair.primary`, so no other lookup applies to it. + /// A "browser" map lookup already ran for `path_pair.primary`. No other lookup applies to it. pub(crate) browser_remapped: bool, } diff --git a/test/bundler/__snapshots__/bundler_browser.test.ts.snap b/test/bundler/__snapshots__/bundler_browser.test.ts.snap new file mode 100644 index 000000000000..9e3528aa1ee2 --- /dev/null +++ b/test/bundler/__snapshots__/bundler_browser.test.ts.snap @@ -0,0 +1,595 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`bundler browser/BrowserFieldMatrix: import of an unscoped package 1`] = ` +{ + "key ./dir is a file, import pkg": "node_modules/pkg/main.js", + "key ./dir is a file, import pkg/dir": "node_modules/pkg/a.js", + "key ./dir is a file, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key ./dir is a file, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./dir is a file, import pkg/x": "node_modules/pkg/x.js", + "key ./dir is a file, import pkg/x.js": "node_modules/pkg/x.js", + "key ./dir is false, import pkg": "node_modules/pkg/main.js", + "key ./dir is false, import pkg/dir": "(disabled):node_modules/pkg/dir", + "key ./dir is false, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key ./dir is false, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./dir is false, import pkg/x": "node_modules/pkg/x.js", + "key ./dir is false, import pkg/x.js": "node_modules/pkg/x.js", + "key ./dir/index is a file, import pkg": "node_modules/pkg/main.js", + "key ./dir/index is a file, import pkg/dir": "node_modules/pkg/a.js", + "key ./dir/index is a file, import pkg/dir/index": "node_modules/pkg/a.js", + "key ./dir/index is a file, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./dir/index is a file, import pkg/x": "node_modules/pkg/x.js", + "key ./dir/index is a file, import pkg/x.js": "node_modules/pkg/x.js", + "key ./dir/index is false, import pkg": "node_modules/pkg/main.js", + "key ./dir/index is false, import pkg/dir": "(disabled):node_modules/pkg/dir", + "key ./dir/index is false, import pkg/dir/index": "(disabled):node_modules/pkg/dir/index", + "key ./dir/index is false, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./dir/index is false, import pkg/x": "node_modules/pkg/x.js", + "key ./dir/index is false, import pkg/x.js": "node_modules/pkg/x.js", + "key ./dir/index.js is a file, import pkg": "node_modules/pkg/main.js", + "key ./dir/index.js is a file, import pkg/dir": "node_modules/pkg/a.js", + "key ./dir/index.js is a file, import pkg/dir/index": "node_modules/pkg/a.js", + "key ./dir/index.js is a file, import pkg/dir/index.js": "node_modules/pkg/a.js", + "key ./dir/index.js is a file, import pkg/x": "node_modules/pkg/x.js", + "key ./dir/index.js is a file, import pkg/x.js": "node_modules/pkg/x.js", + "key ./dir/index.js is false, import pkg": "node_modules/pkg/main.js", + "key ./dir/index.js is false, import pkg/dir": "(disabled):node_modules/pkg/dir", + "key ./dir/index.js is false, import pkg/dir/index": "(disabled):node_modules/pkg/dir/index", + "key ./dir/index.js is false, import pkg/dir/index.js": "(disabled):node_modules/pkg/dir/index.js", + "key ./dir/index.js is false, import pkg/x": "node_modules/pkg/x.js", + "key ./dir/index.js is false, import pkg/x.js": "node_modules/pkg/x.js", + "key ./main is a file, import pkg": "node_modules/pkg/main.js", + "key ./main is a file, import pkg/dir": "node_modules/pkg/dir/index.js", + "key ./main is a file, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key ./main is a file, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./main is a file, import pkg/x": "node_modules/pkg/x.js", + "key ./main is a file, import pkg/x.js": "node_modules/pkg/x.js", + "key ./main is false, import pkg": "node_modules/pkg/main.js", + "key ./main is false, import pkg/dir": "node_modules/pkg/dir/index.js", + "key ./main is false, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key ./main is false, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./main is false, import pkg/x": "node_modules/pkg/x.js", + "key ./main is false, import pkg/x.js": "node_modules/pkg/x.js", + "key ./main.js is a file, import pkg": "node_modules/pkg/a.js", + "key ./main.js is a file, import pkg/dir": "node_modules/pkg/dir/index.js", + "key ./main.js is a file, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key ./main.js is a file, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./main.js is a file, import pkg/x": "node_modules/pkg/x.js", + "key ./main.js is a file, import pkg/x.js": "node_modules/pkg/x.js", + "key ./main.js is false, import pkg": "(disabled):node_modules/pkg/main.js", + "key ./main.js is false, import pkg/dir": "node_modules/pkg/dir/index.js", + "key ./main.js is false, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key ./main.js is false, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./main.js is false, import pkg/x": "node_modules/pkg/x.js", + "key ./main.js is false, import pkg/x.js": "node_modules/pkg/x.js", + "key ./x is a file, import pkg": "node_modules/pkg/main.js", + "key ./x is a file, import pkg/dir": "node_modules/pkg/dir/index.js", + "key ./x is a file, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key ./x is a file, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./x is a file, import pkg/x": "node_modules/pkg/a.js", + "key ./x is a file, import pkg/x.js": "node_modules/pkg/x.js", + "key ./x is false, import pkg": "node_modules/pkg/main.js", + "key ./x is false, import pkg/dir": "node_modules/pkg/dir/index.js", + "key ./x is false, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key ./x is false, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./x is false, import pkg/x": "(disabled):node_modules/pkg/x", + "key ./x is false, import pkg/x.js": "node_modules/pkg/x.js", + "key ./x.js is a file, import pkg": "node_modules/pkg/main.js", + "key ./x.js is a file, import pkg/dir": "node_modules/pkg/dir/index.js", + "key ./x.js is a file, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key ./x.js is a file, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./x.js is a file, import pkg/x": "node_modules/pkg/a.js", + "key ./x.js is a file, import pkg/x.js": "node_modules/pkg/a.js", + "key ./x.js is false, import pkg": "node_modules/pkg/main.js", + "key ./x.js is false, import pkg/dir": "node_modules/pkg/dir/index.js", + "key ./x.js is false, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key ./x.js is false, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key ./x.js is false, import pkg/x": "(disabled):node_modules/pkg/x", + "key ./x.js is false, import pkg/x.js": "(disabled):node_modules/pkg/x.js", + "key dir is a file, import pkg": "node_modules/pkg/main.js", + "key dir is a file, import pkg/dir": "node_modules/pkg/a.js", + "key dir is a file, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key dir is a file, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key dir is a file, import pkg/x": "node_modules/pkg/x.js", + "key dir is a file, import pkg/x.js": "node_modules/pkg/x.js", + "key dir is false, import pkg": "node_modules/pkg/main.js", + "key dir is false, import pkg/dir": "(disabled):node_modules/pkg/dir", + "key dir is false, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key dir is false, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key dir is false, import pkg/x": "node_modules/pkg/x.js", + "key dir is false, import pkg/x.js": "node_modules/pkg/x.js", + "key x is a file, import pkg": "node_modules/pkg/main.js", + "key x is a file, import pkg/dir": "node_modules/pkg/dir/index.js", + "key x is a file, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key x is a file, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key x is a file, import pkg/x": "node_modules/pkg/a.js", + "key x is a file, import pkg/x.js": "node_modules/pkg/x.js", + "key x is false, import pkg": "node_modules/pkg/main.js", + "key x is false, import pkg/dir": "node_modules/pkg/dir/index.js", + "key x is false, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key x is false, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key x is false, import pkg/x": "(disabled):node_modules/pkg/x", + "key x is false, import pkg/x.js": "node_modules/pkg/x.js", + "key x.js is a file, import pkg": "node_modules/pkg/main.js", + "key x.js is a file, import pkg/dir": "node_modules/pkg/dir/index.js", + "key x.js is a file, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key x.js is a file, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key x.js is a file, import pkg/x": "node_modules/pkg/a.js", + "key x.js is a file, import pkg/x.js": "node_modules/pkg/a.js", + "key x.js is false, import pkg": "node_modules/pkg/main.js", + "key x.js is false, import pkg/dir": "node_modules/pkg/dir/index.js", + "key x.js is false, import pkg/dir/index": "node_modules/pkg/dir/index.js", + "key x.js is false, import pkg/dir/index.js": "node_modules/pkg/dir/index.js", + "key x.js is false, import pkg/x": "(disabled):node_modules/pkg/x", + "key x.js is false, import pkg/x.js": "(disabled):node_modules/pkg/x.js", +} +`; + +exports[`bundler browser/BrowserFieldMatrix: import of a scoped package 1`] = ` +{ + "key ./dir is a file, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./dir is a file, import @s/pkg/dir": "node_modules/@s/pkg/a.js", + "key ./dir is a file, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key ./dir is a file, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./dir is a file, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key ./dir is a file, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./dir is false, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./dir is false, import @s/pkg/dir": "(disabled):node_modules/@s/pkg/dir", + "key ./dir is false, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key ./dir is false, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./dir is false, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key ./dir is false, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./dir/index is a file, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./dir/index is a file, import @s/pkg/dir": "node_modules/@s/pkg/a.js", + "key ./dir/index is a file, import @s/pkg/dir/index": "node_modules/@s/pkg/a.js", + "key ./dir/index is a file, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./dir/index is a file, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key ./dir/index is a file, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./dir/index is false, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./dir/index is false, import @s/pkg/dir": "(disabled):node_modules/@s/pkg/dir", + "key ./dir/index is false, import @s/pkg/dir/index": "(disabled):node_modules/@s/pkg/dir/index", + "key ./dir/index is false, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./dir/index is false, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key ./dir/index is false, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./dir/index.js is a file, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./dir/index.js is a file, import @s/pkg/dir": "node_modules/@s/pkg/a.js", + "key ./dir/index.js is a file, import @s/pkg/dir/index": "node_modules/@s/pkg/a.js", + "key ./dir/index.js is a file, import @s/pkg/dir/index.js": "node_modules/@s/pkg/a.js", + "key ./dir/index.js is a file, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key ./dir/index.js is a file, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./dir/index.js is false, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./dir/index.js is false, import @s/pkg/dir": "(disabled):node_modules/@s/pkg/dir", + "key ./dir/index.js is false, import @s/pkg/dir/index": "(disabled):node_modules/@s/pkg/dir/index", + "key ./dir/index.js is false, import @s/pkg/dir/index.js": "(disabled):node_modules/@s/pkg/dir/index.js", + "key ./dir/index.js is false, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key ./dir/index.js is false, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./main is a file, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./main is a file, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key ./main is a file, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key ./main is a file, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./main is a file, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key ./main is a file, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./main is false, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./main is false, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key ./main is false, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key ./main is false, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./main is false, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key ./main is false, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./main.js is a file, import @s/pkg": "node_modules/@s/pkg/a.js", + "key ./main.js is a file, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key ./main.js is a file, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key ./main.js is a file, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./main.js is a file, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key ./main.js is a file, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./main.js is false, import @s/pkg": "(disabled):node_modules/@s/pkg/main.js", + "key ./main.js is false, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key ./main.js is false, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key ./main.js is false, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./main.js is false, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key ./main.js is false, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./x is a file, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./x is a file, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key ./x is a file, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key ./x is a file, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./x is a file, import @s/pkg/x": "node_modules/@s/pkg/a.js", + "key ./x is a file, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./x is false, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./x is false, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key ./x is false, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key ./x is false, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./x is false, import @s/pkg/x": "(disabled):node_modules/@s/pkg/x", + "key ./x is false, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key ./x.js is a file, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./x.js is a file, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key ./x.js is a file, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key ./x.js is a file, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./x.js is a file, import @s/pkg/x": "node_modules/@s/pkg/a.js", + "key ./x.js is a file, import @s/pkg/x.js": "node_modules/@s/pkg/a.js", + "key ./x.js is false, import @s/pkg": "node_modules/@s/pkg/main.js", + "key ./x.js is false, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key ./x.js is false, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key ./x.js is false, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key ./x.js is false, import @s/pkg/x": "(disabled):node_modules/@s/pkg/x", + "key ./x.js is false, import @s/pkg/x.js": "(disabled):node_modules/@s/pkg/x.js", + "key dir is a file, import @s/pkg": "node_modules/@s/pkg/main.js", + "key dir is a file, import @s/pkg/dir": "node_modules/@s/pkg/a.js", + "key dir is a file, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key dir is a file, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key dir is a file, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key dir is a file, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key dir is false, import @s/pkg": "node_modules/@s/pkg/main.js", + "key dir is false, import @s/pkg/dir": "(disabled):node_modules/@s/pkg/dir", + "key dir is false, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key dir is false, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key dir is false, import @s/pkg/x": "node_modules/@s/pkg/x.js", + "key dir is false, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key x is a file, import @s/pkg": "node_modules/@s/pkg/main.js", + "key x is a file, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key x is a file, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key x is a file, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key x is a file, import @s/pkg/x": "node_modules/@s/pkg/a.js", + "key x is a file, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key x is false, import @s/pkg": "node_modules/@s/pkg/main.js", + "key x is false, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key x is false, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key x is false, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key x is false, import @s/pkg/x": "(disabled):node_modules/@s/pkg/x", + "key x is false, import @s/pkg/x.js": "node_modules/@s/pkg/x.js", + "key x.js is a file, import @s/pkg": "node_modules/@s/pkg/main.js", + "key x.js is a file, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key x.js is a file, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key x.js is a file, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key x.js is a file, import @s/pkg/x": "node_modules/@s/pkg/a.js", + "key x.js is a file, import @s/pkg/x.js": "node_modules/@s/pkg/a.js", + "key x.js is false, import @s/pkg": "node_modules/@s/pkg/main.js", + "key x.js is false, import @s/pkg/dir": "node_modules/@s/pkg/dir/index.js", + "key x.js is false, import @s/pkg/dir/index": "node_modules/@s/pkg/dir/index.js", + "key x.js is false, import @s/pkg/dir/index.js": "node_modules/@s/pkg/dir/index.js", + "key x.js is false, import @s/pkg/x": "(disabled):node_modules/@s/pkg/x", + "key x.js is false, import @s/pkg/x.js": "(disabled):node_modules/@s/pkg/x.js", +} +`; + +exports[`bundler browser/BrowserFieldMatrix: import of a package with exports 1`] = ` +{ + "key ./lib/inner.js is a file, import pkg": "node_modules/pkg/main.js", + "key ./lib/inner.js is a file, import pkg/inner": "node_modules/pkg/a.js", + "key ./lib/inner.js is a file, import pkg/x": "node_modules/pkg/x.js", + "key ./lib/inner.js is a file, import pkg/x.js": "node_modules/pkg/x.js", + "key ./main.js is a file, import pkg": "node_modules/pkg/a.js", + "key ./main.js is a file, import pkg/inner": "node_modules/pkg/lib/inner.js", + "key ./main.js is a file, import pkg/x": "node_modules/pkg/x.js", + "key ./main.js is a file, import pkg/x.js": "node_modules/pkg/x.js", + "key ./x is a file, import pkg": "node_modules/pkg/main.js", + "key ./x is a file, import pkg/inner": "node_modules/pkg/lib/inner.js", + "key ./x is a file, import pkg/x": "node_modules/pkg/x.js", + "key ./x is a file, import pkg/x.js": "node_modules/pkg/x.js", + "key ./x.js is a file, import pkg": "node_modules/pkg/main.js", + "key ./x.js is a file, import pkg/inner": "node_modules/pkg/lib/inner.js", + "key ./x.js is a file, import pkg/x": "node_modules/pkg/a.js", + "key ./x.js is a file, import pkg/x.js": "node_modules/pkg/a.js", +} +`; + +exports[`bundler browser/BrowserFieldMatrix: require in main.js of the package 1`] = ` +{ + "key ./dir is a file, require ./dir": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key ./dir is a file, require ./x": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir is a file, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir is a file, require dir": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key ./dir is a file, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key ./dir is a file, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key ./dir is false, require ./dir": "(disabled):node_modules/pkg/dir + node_modules/pkg/main.js", + "key ./dir is false, require ./x": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir is false, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir is false, require dir": "(disabled):node_modules/dir/index.js + node_modules/pkg/main.js", + "key ./dir is false, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key ./dir is false, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key ./dir/index is a file, require ./dir": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key ./dir/index is a file, require ./x": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir/index is a file, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir/index is a file, require dir": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key ./dir/index is a file, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key ./dir/index is a file, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key ./dir/index is false, require ./dir": "(disabled):node_modules/pkg/dir + node_modules/pkg/main.js", + "key ./dir/index is false, require ./x": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir/index is false, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir/index is false, require dir": "(disabled):node_modules/dir/index.js + node_modules/pkg/main.js", + "key ./dir/index is false, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key ./dir/index is false, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key ./dir/index.js is a file, require ./dir": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key ./dir/index.js is a file, require ./x": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir/index.js is a file, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir/index.js is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key ./dir/index.js is a file, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key ./dir/index.js is a file, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key ./dir/index.js is false, require ./dir": "(disabled):node_modules/pkg/dir + node_modules/pkg/main.js", + "key ./dir/index.js is false, require ./x": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir/index.js is false, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./dir/index.js is false, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key ./dir/index.js is false, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key ./dir/index.js is false, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key ./main is a file, require ./dir": "node_modules/pkg/dir/index.js + node_modules/pkg/main.js", + "key ./main is a file, require ./x": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./main is a file, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./main is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key ./main is a file, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key ./main is a file, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key ./main is false, require ./dir": "node_modules/pkg/dir/index.js + node_modules/pkg/main.js", + "key ./main is false, require ./x": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./main is false, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./main is false, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key ./main is false, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key ./main is false, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key ./main.js is a file, require ./dir": "node_modules/pkg/a.js", + "key ./main.js is a file, require ./x": "node_modules/pkg/a.js", + "key ./main.js is a file, require ./x.js": "node_modules/pkg/a.js", + "key ./main.js is a file, require dir": "node_modules/pkg/a.js", + "key ./main.js is a file, require x": "node_modules/pkg/a.js", + "key ./main.js is a file, require x.js": "node_modules/pkg/a.js", + "key ./main.js is false, require ./dir": "(disabled):node_modules/pkg/main.js", + "key ./main.js is false, require ./x": "(disabled):node_modules/pkg/main.js", + "key ./main.js is false, require ./x.js": "(disabled):node_modules/pkg/main.js", + "key ./main.js is false, require dir": "(disabled):node_modules/pkg/main.js", + "key ./main.js is false, require x": "(disabled):node_modules/pkg/main.js", + "key ./main.js is false, require x.js": "(disabled):node_modules/pkg/main.js", + "key ./x is a file, require ./dir": "node_modules/pkg/dir/index.js + node_modules/pkg/main.js", + "key ./x is a file, require ./x": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key ./x is a file, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./x is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key ./x is a file, require x": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key ./x is a file, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key ./x is false, require ./dir": "node_modules/pkg/dir/index.js + node_modules/pkg/main.js", + "key ./x is false, require ./x": "(disabled):node_modules/pkg/x + node_modules/pkg/main.js", + "key ./x is false, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key ./x is false, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key ./x is false, require x": "(disabled):node_modules/x/index.js + node_modules/pkg/main.js", + "key ./x is false, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key ./x.js is a file, require ./dir": "node_modules/pkg/dir/index.js + node_modules/pkg/main.js", + "key ./x.js is a file, require ./x": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key ./x.js is a file, require ./x.js": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key ./x.js is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key ./x.js is a file, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key ./x.js is a file, require x.js": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key ./x.js is false, require ./dir": "node_modules/pkg/dir/index.js + node_modules/pkg/main.js", + "key ./x.js is false, require ./x": "(disabled):node_modules/pkg/x + node_modules/pkg/main.js", + "key ./x.js is false, require ./x.js": "(disabled):node_modules/pkg/x.js + node_modules/pkg/main.js", + "key ./x.js is false, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key ./x.js is false, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key ./x.js is false, require x.js": "(disabled):node_modules/x.js/index.js + node_modules/pkg/main.js", + "key dir is a file, require ./dir": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key dir is a file, require ./x": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key dir is a file, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key dir is a file, require dir": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key dir is a file, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key dir is a file, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key dir is false, require ./dir": "(disabled):node_modules/pkg/dir + node_modules/pkg/main.js", + "key dir is false, require ./x": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key dir is false, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key dir is false, require dir": "(disabled):node_modules/dir/index.js + node_modules/pkg/main.js", + "key dir is false, require x": "node_modules/pkg/main.js + node_modules/x/index.js", + "key dir is false, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key x is a file, require ./dir": "node_modules/pkg/dir/index.js + node_modules/pkg/main.js", + "key x is a file, require ./x": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key x is a file, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key x is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key x is a file, require x": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key x is a file, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key x is false, require ./dir": "node_modules/pkg/dir/index.js + node_modules/pkg/main.js", + "key x is false, require ./x": "(disabled):node_modules/pkg/x + node_modules/pkg/main.js", + "key x is false, require ./x.js": "node_modules/pkg/main.js + node_modules/pkg/x.js", + "key x is false, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key x is false, require x": "(disabled):node_modules/x/index.js + node_modules/pkg/main.js", + "key x is false, require x.js": "node_modules/pkg/main.js + node_modules/x.js/index.js", + "key x.js is a file, require ./dir": "node_modules/pkg/dir/index.js + node_modules/pkg/main.js", + "key x.js is a file, require ./x": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key x.js is a file, require ./x.js": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key x.js is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key x.js is a file, require x": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key x.js is a file, require x.js": "node_modules/pkg/a.js + node_modules/pkg/main.js", + "key x.js is false, require ./dir": "node_modules/pkg/dir/index.js + node_modules/pkg/main.js", + "key x.js is false, require ./x": "(disabled):node_modules/pkg/x + node_modules/pkg/main.js", + "key x.js is false, require ./x.js": "(disabled):node_modules/pkg/x.js + node_modules/pkg/main.js", + "key x.js is false, require dir": "node_modules/dir/index.js + node_modules/pkg/main.js", + "key x.js is false, require x": "(disabled):node_modules/x/index.js + node_modules/pkg/main.js", + "key x.js is false, require x.js": "(disabled):node_modules/x.js/index.js + node_modules/pkg/main.js", +} +`; + +exports[`bundler browser/BrowserFieldMatrix: require in lib/inner.js of the package 1`] = ` +{ + "key ./dir is a file, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir is a file, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir is a file, require ./dir": "error", + "key ./dir is a file, require ./x": "error", + "key ./dir is a file, require ./x.js": "error", + "key ./dir is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./dir is a file, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./dir is a file, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./dir is false, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir is false, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir is false, require ./dir": "error", + "key ./dir is false, require ./x": "error", + "key ./dir is false, require ./x.js": "error", + "key ./dir is false, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./dir is false, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./dir is false, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./dir/index is a file, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir/index is a file, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir/index is a file, require ./dir": "error", + "key ./dir/index is a file, require ./x": "error", + "key ./dir/index is a file, require ./x.js": "error", + "key ./dir/index is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./dir/index is a file, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./dir/index is a file, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./dir/index is false, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir/index is false, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir/index is false, require ./dir": "error", + "key ./dir/index is false, require ./x": "error", + "key ./dir/index is false, require ./x.js": "error", + "key ./dir/index is false, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./dir/index is false, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./dir/index is false, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./dir/index.js is a file, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir/index.js is a file, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir/index.js is a file, require ./dir": "error", + "key ./dir/index.js is a file, require ./x": "error", + "key ./dir/index.js is a file, require ./x.js": "error", + "key ./dir/index.js is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./dir/index.js is a file, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./dir/index.js is a file, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./dir/index.js is false, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir/index.js is false, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./dir/index.js is false, require ./dir": "error", + "key ./dir/index.js is false, require ./x": "error", + "key ./dir/index.js is false, require ./x.js": "error", + "key ./dir/index.js is false, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./dir/index.js is false, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./dir/index.js is false, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./main is a file, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./main is a file, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./main is a file, require ./dir": "error", + "key ./main is a file, require ./x": "error", + "key ./main is a file, require ./x.js": "error", + "key ./main is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./main is a file, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./main is a file, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./main is false, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./main is false, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./main is false, require ./dir": "error", + "key ./main is false, require ./x": "error", + "key ./main is false, require ./x.js": "error", + "key ./main is false, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./main is false, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./main is false, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./main.js is a file, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./main.js is a file, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./main.js is a file, require ./dir": "error", + "key ./main.js is a file, require ./x": "error", + "key ./main.js is a file, require ./x.js": "error", + "key ./main.js is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./main.js is a file, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./main.js is a file, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./main.js is false, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./main.js is false, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./main.js is false, require ./dir": "error", + "key ./main.js is false, require ./x": "error", + "key ./main.js is false, require ./x.js": "error", + "key ./main.js is false, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./main.js is false, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./main.js is false, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./x is a file, require ../x": "node_modules/pkg/a.js + node_modules/pkg/lib/inner.js", + "key ./x is a file, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./x is a file, require ./dir": "error", + "key ./x is a file, require ./x": "error", + "key ./x is a file, require ./x.js": "error", + "key ./x is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./x is a file, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./x is a file, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./x is false, require ../x": "(disabled):node_modules/pkg/x + node_modules/pkg/lib/inner.js", + "key ./x is false, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key ./x is false, require ./dir": "error", + "key ./x is false, require ./x": "error", + "key ./x is false, require ./x.js": "error", + "key ./x is false, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./x is false, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./x is false, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./x.js is a file, require ../x": "node_modules/pkg/a.js + node_modules/pkg/lib/inner.js", + "key ./x.js is a file, require ../x.js": "node_modules/pkg/a.js + node_modules/pkg/lib/inner.js", + "key ./x.js is a file, require ./dir": "error", + "key ./x.js is a file, require ./x": "error", + "key ./x.js is a file, require ./x.js": "error", + "key ./x.js is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./x.js is a file, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./x.js is a file, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key ./x.js is false, require ../x": "(disabled):node_modules/pkg/x + node_modules/pkg/lib/inner.js", + "key ./x.js is false, require ../x.js": "(disabled):node_modules/pkg/x.js + node_modules/pkg/lib/inner.js", + "key ./x.js is false, require ./dir": "error", + "key ./x.js is false, require ./x": "error", + "key ./x.js is false, require ./x.js": "error", + "key ./x.js is false, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key ./x.js is false, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key ./x.js is false, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key dir is a file, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key dir is a file, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key dir is a file, require ./dir": "error", + "key dir is a file, require ./x": "error", + "key dir is a file, require ./x.js": "error", + "key dir is a file, require dir": "node_modules/pkg/a.js + node_modules/pkg/lib/inner.js", + "key dir is a file, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key dir is a file, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key dir is false, require ../x": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key dir is false, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key dir is false, require ./dir": "error", + "key dir is false, require ./x": "error", + "key dir is false, require ./x.js": "error", + "key dir is false, require dir": "(disabled):node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key dir is false, require x": "node_modules/pkg/lib/inner.js + node_modules/x/index.js", + "key dir is false, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key x is a file, require ../x": "node_modules/pkg/a.js + node_modules/pkg/lib/inner.js", + "key x is a file, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key x is a file, require ./dir": "error", + "key x is a file, require ./x": "error", + "key x is a file, require ./x.js": "error", + "key x is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key x is a file, require x": "node_modules/pkg/a.js + node_modules/pkg/lib/inner.js", + "key x is a file, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key x is false, require ../x": "(disabled):node_modules/pkg/x + node_modules/pkg/lib/inner.js", + "key x is false, require ../x.js": "node_modules/pkg/lib/inner.js + node_modules/pkg/x.js", + "key x is false, require ./dir": "error", + "key x is false, require ./x": "error", + "key x is false, require ./x.js": "error", + "key x is false, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key x is false, require x": "(disabled):node_modules/x/index.js + node_modules/pkg/lib/inner.js", + "key x is false, require x.js": "node_modules/pkg/lib/inner.js + node_modules/x.js/index.js", + "key x.js is a file, require ../x": "node_modules/pkg/a.js + node_modules/pkg/lib/inner.js", + "key x.js is a file, require ../x.js": "node_modules/pkg/a.js + node_modules/pkg/lib/inner.js", + "key x.js is a file, require ./dir": "error", + "key x.js is a file, require ./x": "error", + "key x.js is a file, require ./x.js": "error", + "key x.js is a file, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key x.js is a file, require x": "node_modules/pkg/a.js + node_modules/pkg/lib/inner.js", + "key x.js is a file, require x.js": "node_modules/pkg/a.js + node_modules/pkg/lib/inner.js", + "key x.js is false, require ../x": "(disabled):node_modules/pkg/x + node_modules/pkg/lib/inner.js", + "key x.js is false, require ../x.js": "(disabled):node_modules/pkg/x.js + node_modules/pkg/lib/inner.js", + "key x.js is false, require ./dir": "error", + "key x.js is false, require ./x": "error", + "key x.js is false, require ./x.js": "error", + "key x.js is false, require dir": "node_modules/dir/index.js + node_modules/pkg/lib/inner.js", + "key x.js is false, require x": "(disabled):node_modules/x/index.js + node_modules/pkg/lib/inner.js", + "key x.js is false, require x.js": "(disabled):node_modules/x.js/index.js + node_modules/pkg/lib/inner.js", +} +`; + +exports[`bundler browser/BrowserFieldMatrix: require in the project 1`] = ` +{ + "key ./src/x.js is a file, entry.js requires ./x": "x.js", + "key ./src/x.js is a file, entry.js requires ./x.js": "x.js", + "key ./src/x.js is a file, entry.js requires x": "node_modules/x/index.js", + "key ./src/x.js is a file, src/deep.js requires ../x": "src/deep.js + x.js", + "key ./src/x.js is a file, src/deep.js requires ./x": "a.js + src/deep.js", + "key ./src/x.js is a file, src/deep.js requires ./x.js": "a.js + src/deep.js", + "key ./src/x.js is a file, src/deep.js requires x": "node_modules/x/index.js + src/deep.js", + "key ./x is a file, entry.js requires ./x": "a.js", + "key ./x is a file, entry.js requires ./x.js": "x.js", + "key ./x is a file, entry.js requires x": "a.js", + "key ./x is a file, src/deep.js requires ../x": "a.js + src/deep.js", + "key ./x is a file, src/deep.js requires ./x": "src/deep.js + src/x.js", + "key ./x is a file, src/deep.js requires ./x.js": "src/deep.js + src/x.js", + "key ./x is a file, src/deep.js requires x": "node_modules/x/index.js + src/deep.js", + "key ./x.js is a file, entry.js requires ./x": "a.js", + "key ./x.js is a file, entry.js requires ./x.js": "a.js", + "key ./x.js is a file, entry.js requires x": "node_modules/x/index.js", + "key ./x.js is a file, src/deep.js requires ../x": "a.js + src/deep.js", + "key ./x.js is a file, src/deep.js requires ./x": "src/deep.js + src/x.js", + "key ./x.js is a file, src/deep.js requires ./x.js": "src/deep.js + src/x.js", + "key ./x.js is a file, src/deep.js requires x": "node_modules/x/index.js + src/deep.js", + "key x is a file, entry.js requires ./x": "a.js", + "key x is a file, entry.js requires ./x.js": "x.js", + "key x is a file, entry.js requires x": "a.js", + "key x is a file, src/deep.js requires ../x": "a.js + src/deep.js", + "key x is a file, src/deep.js requires ./x": "src/deep.js + src/x.js", + "key x is a file, src/deep.js requires ./x.js": "src/deep.js + src/x.js", + "key x is a file, src/deep.js requires x": "a.js + src/deep.js", +} +`; diff --git a/test/bundler/browser-field-matrix.ts b/test/bundler/browser-field-matrix.ts new file mode 100644 index 000000000000..a763291e4830 --- /dev/null +++ b/test/bundler/browser-field-matrix.ts @@ -0,0 +1,223 @@ +// The package.json "browser" map as a table. A layout is one import of one package: where the package is, how the +// key is spelled, whether its value is a file or `false`, and how the import is spelled. A bundler's answer for a +// layout is the set of files that its module graph reaches, read from the metafile, so every layout is built in one +// build and nothing is run. +// +// bundler_browser.test.ts pins Bun's table. scripts/browser-field-matrix.ts prints where it differs from esbuild's. +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; + +export type Layout = { group: string; name: string; files: Record }; +/** The answer for each layout: the files the entry point reaches, or "error" when the build fails on it. */ +export type Table = Record; + +const file = (name: string) => `console.log(${JSON.stringify(name)});`; + +function packageFiles(dir: string, packageJson: object, extra: Record = {}) { + return { + [`${dir}/package.json`]: JSON.stringify(packageJson), + [`${dir}/main.js`]: file("main.js"), + [`${dir}/x.js`]: file("x.js"), + [`${dir}/a.js`]: file("a.js"), + [`${dir}/dir/index.js`]: file("dir/index.js"), + [`${dir}/lib/inner.js`]: file("lib/inner.js"), + ...extra, + }; +} + +export function layouts(): Layout[] { + const all: Layout[] = []; + const keys = ["./x.js", "./x", "x.js", "x", "./dir/index.js", "./dir/index", "./dir", "dir", "./main.js", "./main"]; + const values: [string | false, string][] = [ + ["./a.js", "a file"], + [false, "false"], + ]; + + // An import from outside the package. + for (const [dir, name, where] of [ + ["node_modules/pkg", "pkg", "an unscoped"], + ["node_modules/@s/pkg", "@s/pkg", "a scoped"], + ]) + for (const key of keys) + for (const [value, valueName] of values) + for (const spec of ["P/x", "P/x.js", "P/dir", "P/dir/index.js", "P/dir/index", "P"]) + all.push({ + group: `import of ${where} package`, + name: `key ${key} is ${valueName}, import ${spec.replace("P", name)}`, + files: { + "entry.js": `import ${JSON.stringify(spec.replace("P", name))};`, + ...packageFiles(dir, { name: "pkg", main: "./main.js", browser: { [key]: value } }), + }, + }); + + // The same, for a package with "exports". + for (const key of ["./x.js", "./x", "./main.js", "./lib/inner.js"]) + for (const spec of ["pkg", "pkg/x", "pkg/x.js", "pkg/inner"]) + all.push({ + group: "import of a package with exports", + name: `key ${key} is a file, import ${spec}`, + files: { + "entry.js": `import ${JSON.stringify(spec)};`, + ...packageFiles("node_modules/pkg", { + name: "pkg", + exports: { ".": "./main.js", "./x": "./x.js", "./x.js": "./x.js", "./inner": "./lib/inner.js" }, + browser: { [key]: "./a.js" }, + }), + }, + }); + + // A require() inside the package, from its root and from a subdirectory. No key may apply to "./x" from "lib/". + for (const key of keys) + for (const [value, valueName] of values) + for (const spec of ["./x", "./x.js", "x", "x.js", "./dir", "dir", "../x", "../x.js"]) + for (const from of ["main.js", "lib/inner.js"]) { + if (spec.startsWith("../") && from === "main.js") continue; + all.push({ + group: `require in ${from} of the package`, + name: `key ${key} is ${valueName}, require ${spec}`, + files: { + "entry.js": `import "pkg/${from}";`, + ...packageFiles( + "node_modules/pkg", + { name: "pkg", main: "./main.js", browser: { [key]: value } }, + { + [`node_modules/pkg/${from}`]: `require(${JSON.stringify(spec)});`, + "node_modules/x/index.js": file("node_modules/x"), + "node_modules/x.js/index.js": file("node_modules/x.js"), + "node_modules/dir/index.js": file("node_modules/dir"), + }, + ), + }, + }); + } + + // The map of the project itself. + for (const key of ["./x.js", "./x", "x", "./src/x.js"]) + for (const spec of ["./x", "./x.js", "x", "../x"]) + for (const from of ["entry.js", "src/deep.js"]) { + if (spec.startsWith("../") && from === "entry.js") continue; + const files: Record = { + "package.json": JSON.stringify({ name: "project", browser: { [key]: "./a.js" } }), + "x.js": file("x.js"), + "a.js": file("a.js"), + "src/x.js": file("src/x.js"), + "node_modules/x/index.js": file("node_modules/x"), + }; + if (from === "entry.js") files["entry.js"] = `require(${JSON.stringify(spec)});`; + else { + files["entry.js"] = `import "./src/deep.js";`; + files["src/deep.js"] = `require(${JSON.stringify(spec)});`; + } + all.push({ group: "require in the project", name: `key ${key} is a file, ${from} requires ${spec}`, files }); + } + + return all; +} + +type Metafile = { inputs: Record }; +type BuildResult = { metafile?: Metafile; failedIn: string[] }; +/** Builds the given entry points. `base` is the directory that the paths in the result are relative to. */ +type Bundler = { base: string; build(entryPoints: string[]): Promise }; + +const slashes = (path: string) => path.replaceAll("\\", "/"); + +/** Writes every layout to its own directory below `root` and builds them all with `bundler`. */ +export async function table(root: string, all: Layout[], bundler: Bundler): Promise { + const disabled = "(disabled):"; + const entry = (index: number) => join(root, String(index), "entry.js"); + // "3/node_modules/pkg/x.js" for a path below `root`. + const below = (path: string) => { + const absolute = resolve(bundler.base, path); + return absolute.startsWith(root + sep) ? slashes(relative(root, absolute)) : undefined; + }; + // The same with the "(disabled):" prefix kept. Any other path stays as it is. + const key = (path: string) => { + const prefix = path.startsWith(disabled) ? disabled : ""; + const inside = below(path.slice(prefix.length)); + return inside === undefined ? path : prefix + inside; + }; + const layoutOf = (path: string) => { + const index = Number(below(path)?.split("/")[0]); + return index < all.length ? [index] : []; + }; + + for (let index = 0; index < all.length; index++) + for (const [name, contents] of Object.entries(all[index].files)) { + const path = join(root, String(index), name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); + } + + // A layout that fails stops the whole build, so build again without it. + const failed = new Set(); + let metafile: Metafile | undefined; + while (!metafile) { + const remaining = all.map((_, index) => index).filter(index => !failed.has(index)); + const result = await bundler.build(remaining.map(entry)); + if (result.metafile) { + metafile = result.metafile; + break; + } + const before = failed.size; + for (const path of result.failedIn) for (const index of layoutOf(path)) failed.add(index); + // An error with no position names no layout. Find the layouts that fail alone. + if (failed.size === before) + for (const index of remaining) if (!(await bundler.build([entry(index)])).metafile) failed.add(index); + if (failed.size === before) throw new Error("the build fails, and no layout fails alone"); + } + + const imports = new Map(Object.entries(metafile.inputs).map(([path, input]) => [key(path), input.imports])); + const answer: Table = {}; + for (let index = 0; index < all.length; index++) { + if (failed.has(index)) { + answer[all[index].name] = "error"; + continue; + } + const start = `${index}/entry.js`; + const reached = new Set([start]); + for (const path of reached) + for (const record of imports.get(path) ?? []) + reached.add((record.external ? "external:" : "") + key(record.path)); + reached.delete(start); + answer[all[index].name] = [...reached] + .map(path => path.replace(`${index}/`, "")) + .sort() + .join(" + "); + } + return answer; +} + +/** The bun that runs this file, for `--target=browser`. */ +export const bun: Bundler = { + base: process.cwd(), + async build(entrypoints) { + const result = await Bun.build({ entrypoints, target: "browser", metafile: true, throw: false }); + if (result.success) return { metafile: result.metafile as Metafile, failedIn: [] }; + return { failedIn: result.logs.flatMap(log => (log.position ? [log.position.file] : [])) }; + }, +}; + +/** esbuild, given its module (`await import("esbuild")`) and the directory that `table` writes to. */ +export function esbuild(module: any, root: string): Bundler { + return { + base: root, + async build(entryPoints) { + try { + const result = await module.build({ + entryPoints, + bundle: true, + platform: "browser", + format: "esm", + write: false, + outdir: join(root, "out"), + metafile: true, + logLevel: "silent", + absWorkingDir: root, + }); + return { metafile: result.metafile, failedIn: [] }; + } catch (error: any) { + return { failedIn: error.errors.flatMap((e: any) => (e.location ? [e.location.file] : [])) }; + } + }, + }; +} diff --git a/test/bundler/bundler_browser.test.ts b/test/bundler/bundler_browser.test.ts index cc49d15f6469..c73ddb7270fc 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; import { mkdirSync, readdirSync, symlinkSync } from "node:fs"; import path from "node:path"; +import { bun, layouts, table } from "./browser-field-matrix"; import { itBundled } from "./expectBundled"; describe("bundler", () => { @@ -297,21 +298,179 @@ describe("bundler", () => { // module, `(disabled):`, as in esbuild. Bun's own stubs for Node.js // builtins (see "browser/NodeFS") are not that module. itBundled("browser/BrowserFieldDisabledModuleIsNotTheBuiltinStub", { + skipOnEsbuild: true, // esbuild has no stubs for Node.js builtins files: { "/entry.js": /* js */ ` import fromPackage from "pkg"; const own = require("fs"); - console.log(typeof own, typeof fromPackage, Object.keys(fromPackage).length); + console.log(typeof fromPackage, Object.keys(fromPackage).length, own === fromPackage); `, "/node_modules/pkg/package.json": `{ "main": "./index.js", "browser": { "fs": false } }`, "/node_modules/pkg/index.js": `module.exports = require("fs");`, }, target: "browser", onAfterBundle(api) { - api.expectFile("out.js").toInclude("(disabled):fs"); + expect(api.readFile("out.js").match(/^\/\/ \(disabled\):.*$/gm)).toEqual(["// (disabled):fs"]); }, run: { - stdout: "function object 0", + stdout: "object 0 false", + }, + }); + + // A bare name that the map disables is the empty module. The same file is + // also live when another import reaches it by its path. Both are in the + // bundle, whatever the order of the two imports. + for (const order of ["DisabledFirst", "LiveFirst"] as const) { + const requires = [ + `const empty = require("nodeonly");`, + `const live = require("./node_modules/nodeonly/index.js");`, + ]; + if (order === "LiveFirst") requires.reverse(); + itBundled(`browser/BrowserFieldDisabledAndLiveFileInOneImporter${order}`, { + files: { + "/entry.js": /* js */ ` + ${requires.join("\n")} + console.log(JSON.stringify([empty, live]), empty === require("nodeonly")); + `, + "/package.json": `{ "browser": { "nodeonly": false } }`, + "/node_modules/nodeonly/index.js": `module.exports = "live";`, + }, + target: "browser", + onAfterBundle(api) { + api.expectFile("out.js").toMatch(/\/\/ \(disabled\):\S*node_modules\/nodeonly\/index\.js\n/); + api.expectFile("out.js").toMatch(/\/\/ (?!\(disabled\):)\S*node_modules\/nodeonly\/index\.js\n/); + }, + run: { + stdout: `[{},"live"] true`, + }, + }); + // One importer each: the dependency has a map of its own, so the name is live there. + const importers = [`const empty = require("./app.js");`, `const live = require("dep");`]; + if (order === "LiveFirst") importers.reverse(); + itBundled(`browser/BrowserFieldDisabledAndLiveFileInTwoImporters${order}`, { + files: { + "/entry.js": /* js */ ` + ${importers.join("\n")} + console.log(JSON.stringify([empty, live])); + `, + "/app.js": `module.exports = require("nodeonly");`, + "/package.json": `{ "browser": { "nodeonly": false } }`, + "/node_modules/dep/package.json": `{ "main": "./index.js", "browser": { "./unused.js": "./index.js" } }`, + "/node_modules/dep/index.js": `module.exports = require("nodeonly");`, + "/node_modules/nodeonly/index.js": `module.exports = "live";`, + }, + target: "browser", + run: { + stdout: `[{},"live"]`, + }, + }); + } + + // One `false` entry is one empty module, with and without a slash after the + // directory, so the bundle does not depend on which import is seen first. + itBundled("browser/BrowserFieldDisabledDirectoryTrailingSlash", { + files: { + "/entry.js": /* js */ ` + const a = require("./a.js"), b = require("./b.js"); + console.log(a.dir === b.noSlash, b.slash === b.noSlash, a.pkg === b.pkgSlash, JSON.stringify(a.dir)); + `, + "/a.js": `exports.dir = require("./dir"); exports.pkg = require("pkg/dir");`, + "/b.js": `exports.slash = require("./dir/"); exports.noSlash = require("./dir"); exports.pkgSlash = require("pkg/dir/");`, + "/package.json": `{ "browser": { "./dir/index.js": false } }`, + "/dir/index.js": `throw "fail";`, + "/node_modules/pkg/package.json": `{ "browser": { "./dir/index.js": false } }`, + "/node_modules/pkg/dir/index.js": `throw "fail";`, + }, + target: "browser", + run: { + stdout: "true true true {}", + }, + }); + + // An import that does not use the value of the empty module leaves nothing in the bundle. + itBundled("browser/BrowserFieldDisabledModuleUnusedImportIsDropped", { + files: { + "/entry.js": /* js */ ` + import unusedDefault from "./node.js"; + import * as unusedStar from "./node.js"; + import "./node.js"; + console.log("done"); + `, + "/package.json": `{ "browser": { "./node.js": false } }`, + "/node.js": `throw "fail";`, + }, + target: "browser", + onAfterBundle(api) { + api.expectFile("out.js").not.toInclude("(disabled):"); + }, + run: { + stdout: "done", + }, + }); + + // Only an `import`, a `require()` or an `import()` loads the empty module. + // `require.resolve` of a disabled path stays as written, as on main. + itBundled("browser/BrowserFieldDisabledRequireResolveStaysAsWritten", { + files: { + "/entry.js": `module.exports = () => require.resolve("./node.js");`, + "/package.json": `{ "browser": { "./node.js": false } }`, + "/node.js": `throw "fail";`, + }, + target: "browser", + onAfterBundle(api) { + api.expectFile("out.js").toInclude(`.resolve("./node.js")`); + api.expectFile("out.js").not.toInclude("(disabled):"); + }, + }); + + // A script tag that names a disabled path stays as written, as on main. + itBundled("browser/BrowserFieldDisabledHtmlScriptStaysAsWritten", { + skipOnEsbuild: true, // esbuild has no HTML entry points + files: { + "/index.html": ``, + "/ok.js": `console.log("ok");`, + "/node.js": `throw "fail";`, + "/package.json": `{ "browser": { "./node.js": false } }`, + }, + entryPoints: ["/index.html"], + outdir: "/out", + target: "browser", + onAfterBundle(api) { + const html = api.readFile("/out/index.html"); + expect(html).toContain(`