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 7ed96c3aaa28..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? @@ -4016,22 +4034,25 @@ impl<'a> LinkerContext<'a> { if status == ImportTrackerStatus::CjsWithoutExports && !self.is_call_record(prev_source_index, named_import.import_record_index) { + // 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. let alias = named_import .alias .expect("infallible: alias present") .slice(); - // Split-borrow with `named_import` (`&self.graph`) — - // `log_disjoint` returns the disjoint `Transpiler.log` backref. - self.log_disjoint().add_range_warning_fmt( - Some(source), + let message = format_args!( + "Import \"{}\" will always be undefined because the file \"{}\" has no exports", + bstr::BStr::new(alias), + bstr::BStr::new(&imported.path.pretty), + ); + self.warn_import_is_undefined( + source, source.range_of_identifier(named_import.alias_loc), - format_args!( - "Import \"{}\" will always be undefined because the file \"{}\" has no exports", - bstr::BStr::new(alias), - bstr::BStr::new(&source.path.pretty), - ), + message, ); } } @@ -4156,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), @@ -4303,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 a9ba4943fb58..9001cb164f6d 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -278,9 +278,13 @@ impl<'a> ParseTask<'a> { ParseTask { ctx: Some(ctx_ref), path, - contents_or_fd: ContentsOrFd::Fd { - dir: resolve_result.dirname_fd, - file: resolve_result.file_fd, + contents_or_fd: if path.is_disabled_module { + 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 @@ -288,7 +292,12 @@ impl<'a> ParseTask<'a> { // jsxImportSource/runtime/etc. from tsconfig.json. jsx: resolve_result.jsx.clone(), source_index, - module_type: resolve_result.module_type, + // 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 + }, emit_decorator_metadata: resolve_result.flags.emit_decorator_metadata(), experimental_decorators: resolve_result.flags.experimental_decorators(), use_define_for_class_fields: resolve_result.flags.use_define_for_class_fields(), @@ -650,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`), @@ -1640,7 +1650,7 @@ pub mod parse_worker { break 'brk false; } - if file_path.namespace == b"node" { + 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 f0e42df43810..9b5515a9e24b 100644 --- a/src/bundler/PathToSourceIndexMap.rs +++ b/src/bundler/PathToSourceIndexMap.rs @@ -1,53 +1,73 @@ use bun_collections::StringHashMap; +use bun_paths::fs::Path; 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. -pub trait PathLike { - fn path_text(&self) -> &[u8]; -} - -// `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 - } -} +/// Pretty-path prefix of the empty module of a disabled file, as in esbuild. +pub(crate) const DISABLED_PRETTY_PREFIX: &[u8] = b"(disabled):"; /// 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, +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 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: &Path<'_>) -> &mut StringHashMap { + if path.is_disabled_module { + &mut self.disabled_modules + } else { + &mut self.map + } + } + + 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: &Path<'_>, + value: V, + ) -> Result<(), bun_alloc::AllocError> { + self.map_for_mut(path).put(path.text, value) + } -impl PathToSourceIndexMap { - pub(crate) fn get_path(&self, path: &impl PathLike) -> Option { - self.get(path.path_text()) + pub(crate) fn get_or_put_path( + &mut self, + 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) @@ -56,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()) } @@ -64,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 0c03a1bea3d3..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() @@ -2571,9 +2573,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_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 @@ -2582,6 +2587,7 @@ pub mod bv2_impl { .filter(|((source, loader), flags)| { loader.is_javascript_like_or_json() && source.path.is_file() + && !source.path.is_disabled_module && !(flags.contains(crate::Graph::InputFileFlags::IS_LOADED_BY_PLUGIN) && bun_sys::exists(source.path.text)) }) @@ -2762,7 +2768,7 @@ pub mod bv2_impl { } let mut had_busted_dir_cache = false; - let resolve_result = loop { + let mut resolve_result = loop { // SAFETY: see `transpiler` note above. match unsafe { &mut *transpiler }.resolver.resolve( source_dir, @@ -2915,7 +2921,13 @@ pub mod bv2_impl { let out_source_index: Option; - let mut path = match resolve_result.path_const() { + let importer = import_record.importer_source_index as usize; + let importer_loader = self.graph.input_files.items_loader()[importer]; + 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() @@ -2950,13 +2962,16 @@ pub mod bv2_impl { // 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(&path) { out_source_index = Some(Index::init(existing)); } else { let path = self .path_with_pretty_initialized(&path, target) .expect("oom"); let loader: Loader = 'brk: { + if path.is_disabled_module { + 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]; @@ -2977,14 +2992,14 @@ pub mod bv2_impl { let idx = self .enqueue_parse_task( &resolve_result, - self.parse_task_path(&resolve_result, path), + path, &mut tmp_source, loader, import_record.original_target, ) .expect("oom"); self.path_to_source_index_map(target) - .put(path.text, idx) + .put_path(&path, idx) .expect("oom"); out_source_index = Some(Index::init(idx)); @@ -3172,8 +3187,7 @@ pub mod bv2_impl { ..Default::default() })?; // Arena-owned; freed on heap reset. - let task_path = self.parse_task_path(result, path); - let task_val = ParseTask::init(result, task_path, source_index, self); + let task_val = ParseTask::init(result, path, source_index, self); // SAFETY: arena outlives the bundle pass; reborrow `*mut` as `&mut`. let task: &mut ParseTask = self.arena_create(task_val); task.loader = Some(loader); @@ -6381,6 +6395,10 @@ pub mod bv2_impl { } pub(crate) fn enqueue_on_load_plugin_if_needed(&mut self, parse: &mut ParseTask) -> bool { + // 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); if had_matches { return true; @@ -6443,17 +6461,30 @@ pub mod bv2_impl { false } - /// A task reads the primary path, also when `active` (`Result::path`) is the secondary one. - fn parse_task_path( + /// The module that an import of `result` loads, or `None` for a disabled, external record. + fn select_import_module<'p, 'r>( &self, - result: &_resolver::Result<'_>, - active: Fs::Path<'a>, - ) -> Fs::Path<'a> { - if result.path_pair.primary.is_disabled { - path_in_arena(&result.path_pair.primary, self.arena()) - } else { - active + result: &'p mut _resolver::Result<'r>, + importer: Loader, + kind: ImportKind, + ) -> 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; } + // 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( @@ -7153,7 +7184,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. - let path: &mut Fs::Path<'a> = match resolve_result.path() { + 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 => { @@ -7257,6 +7292,9 @@ pub mod bv2_impl { } let import_record_loader = 'brk: { + if path.is_disabled_module { + break 'brk Loader::Js; + } let resolved_loader = import_record.loader.unwrap_or_else(|| { path.loader(&transpiler.options.loaders) .unwrap_or(Loader::File) @@ -7283,7 +7321,7 @@ 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) { + if let Some(id) = self.path_to_source_index_map(target).get_path(path) { if self.dev_server.is_some() && loader != Loader::Html { import_record.path = self.graph.input_files.items_source()[id as usize].path; @@ -7297,7 +7335,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_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; @@ -7374,7 +7412,7 @@ pub mod bv2_impl { ) }); let dev_server_is_none = self.dev_server.is_none(); - for (key, value) in resolve_queue.iter() { + for value in resolve_queue.values() { let value: *mut ParseTask = *value; // SAFETY: ParseTask was arena-allocated in `resolve_import_records`; // the arena outlives this loop. @@ -7395,7 +7433,7 @@ pub mod bv2_impl { } else { self.graph.path_to_source_index_map(target) }; - let existing = map.get_or_put(key).expect("oom"); + let existing = map.get_or_put_path(&value.path).expect("oom"); ( existing.found_existing, std::ptr::from_mut::(existing.value_ptr), @@ -8345,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, @@ -8383,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/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/dir_info.rs b/src/resolver/dir_info.rs index b90cba7326e7..878faba84972 100644 --- a/src/resolver/dir_info.rs +++ b/src/resolver/dir_info.rs @@ -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/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/package_json.rs b/src/resolver/package_json.rs index d0f668a69f60..0cc431f57481 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -634,21 +634,9 @@ impl PackageJSON { // The value is an object // Remap all files in the browser field - let mut key_spill: Vec = Vec::new(); 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()); + // Stored as written: `check_browser_map` decides which spellings match. + 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 59dc18d0c94c..e0e90d482fb9 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 @@ -341,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, @@ -356,7 +345,7 @@ pub struct Bufs { pub(crate) dir_info_uncached_filename: PathBuffer, pub(crate) node_bin_path: PathBuffer, pub(crate) dir_info_uncached_path: PathBuffer, - pub(crate) tsconfig_base_url: PathBuffer, + pub(crate) browser_map_index: PathBuffer, pub(crate) relative_abs_path: PathBuffer, pub(crate) load_as_file_or_directory_via_tsconfig_base_path: PathBuffer, pub(crate) node_modules_check: PathBuffer, @@ -414,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 @@ -483,6 +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, + /// The "browser" map entries whose targets are being resolved, by address. + browser_remap_stack: BoundedArray, pub debug_logs: Option, pub elapsed: u64, // tracing @@ -626,6 +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_stack: BoundedArray::default(), // `DebugLogs` owns Vecs — per-worker fresh. debug_logs: None, elapsed: 0, @@ -992,6 +984,7 @@ impl<'a> Resolver<'a> { log, extension_order: options::ExtOrder::DefaultDefault, care_about_browser_field, + browser_remap_stack: BoundedArray::default(), care_about_bin_folder: false, care_about_scripts: false, debug_logs: None, @@ -1899,6 +1892,43 @@ impl<'a> Resolver<'a> { } } + // 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 { + continue; + } + let name = path.name(); + let Ok(Some(dir)) = self.read_dir_info(name.dir) else { + continue; + }; + 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)); + } + if !symlink.is_empty() { + path.set_realpath(symlink); + } + } + // The extension wins over a package.json "type". if !kind.is_from_css() { if let Some(from_ext) = module_type_from_ext(result.path_pair.primary.name().ext) { @@ -2093,15 +2123,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. @@ -2293,22 +2320,15 @@ 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, ) { // 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() @@ -2446,7 +2466,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, ) { @@ -2538,7 +2558,11 @@ impl<'a> Resolver<'a> { return ResultUnion::Success(result); } - if res.package_json.is_some() && 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 + { let base_dir_info = match res.dir_info { Some(d) => d, None => match self.read_dir_info(result.path_pair.primary.name().dir) { @@ -2549,14 +2573,20 @@ impl<'a> Resolver<'a> { if let Some(browser_scope) = base_dir_info.get_enclosing_browser_scope() { if let Some(remap) = self .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( - &browser_scope, + base_dir_info, 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"); + // 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 @@ -2799,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; @@ -2837,6 +2869,7 @@ impl<'a> Resolver<'a> { let prev_extension_order = self.extension_order; // NOTE: defer restore reshaped — restored at end of block + let mut package_dir_info: Option = None; if let Some(ref esm) = esm_ { // This join can fail even though `abs_path` fit, because `/..` normalizes to a shorter path. let abs_package_path: Option<&[u8]> = if is_self_reference { @@ -2850,6 +2883,7 @@ impl<'a> Resolver<'a> { if let Some(abs_package_path) = abs_package_path && let Ok(Some(pkg_dir_info)) = self.dir_info_cached(abs_package_path) { + package_dir_info = Some(pkg_dir_info); self.extension_order = match kind { ast::ImportKind::Url | ast::ImportKind::AtConditional @@ -3011,7 +3045,13 @@ impl<'a> Resolver<'a> { } if self - .load_as_file_or_directory(abs_path, kind, out) + .load_package_file_with_browser_map( + package_dir_info, + abs_path, + kind, + global_cache, + out, + ) .is_success() { self.extension_order = prev_extension_order; @@ -3059,8 +3099,29 @@ impl<'a> Resolver<'a> { bstr::BStr::new(abs_path) )); } + // 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() + .abs_buf_checked(&[path, esm.name], bufs!(esm_absolute_package_path)) + .and_then(|abs_package_path| { + self.dir_info_cached(abs_package_path).ok().flatten() + }) + .filter(|pkg_dir_info| { + pkg_dir_info + .package_json() + .is_none_or(|package_json| package_json.exports.is_none()) + }), + _ => None, + }; if self - .load_as_file_or_directory(abs_path, kind, out) + .load_package_file_with_browser_map( + package_dir_info, + abs_path, + kind, + global_cache, + out, + ) .is_success() { if let Some(d) = self.debug_logs.as_mut() { @@ -4243,7 +4304,14 @@ impl<'a> Resolver<'a> { ) else { return MatchStatus::NotFound; }; - self.load_as_file_or_directory(resolved, kind, out) + // A remap target gets the extension order a relative import of it gets. + let prev_extension_order = self.extension_order; + if strings::path_contains_node_modules_folder(resolved) { + self.extension_order = self.opts.extension_order.kind(kind, true); + } + let status = self.load_as_file_or_directory(resolved, kind, out); + self.extension_order = prev_extension_order; + status } } @@ -5319,72 +5387,223 @@ impl<'a> Resolver<'a> { ) } - pub(crate) fn check_browser_map( + /// 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, - dir_info: &DirInfo::DirInfo, - input_path_: &[u8], - ) -> Option<&'static [u8]> { - let package_json = dir_info.package_json()?; - let browser_map = &package_json.browser_map; + pkg_dir_info: Option, + abs_path: &[u8], + kind: ast::ImportKind, + global_cache: GlobalCache, + out: &mut MatchResult, + ) -> MatchStatus { + 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; + }; - if browser_map.count() == 0 { - return None; + // 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; } - let mut input_path = input_path_; + // 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; + } - if KIND == BrowserMapPathKind::AbsolutePath { - let abs_path = dir_info.abs_path; - // Turn absolute paths into paths relative to the "browser" map location - if !input_path.starts_with(abs_path) { - return None; + // 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, + } + } - input_path = &input_path[abs_path.len()..]; + /// 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() { + *out = MatchResult { + path_pair: PathPair { + 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 true; } - if input_path.is_empty() - || (input_path.len() == 1 && (input_path[0] == b'.' || input_path[0] == SEP)) + // 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() { - // No bundler supports remapping ".", so we don't either - return None; + 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 + } + + /// 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; } + } - // Every candidate below is built in a path buffer. `/index` is the longest one. - if input_path.len() + BROWSER_MAP_INDEX_SUFFIX.len() >= MAX_PATH_BYTES { + /// 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. + pub(crate) fn check_browser_map( + &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()?; + + if package_json.browser_map.count() == 0 { 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); + let input_path: &[u8] = match KIND { + // Turn absolute paths into paths relative to the "browser" map location + 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) { + if checker.check_path(input_path, implicit_extensions) { return Some(checker.remapped); } // First try the import path as a package path - if is_package_path(checker.input_path) { + if is_package_path(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]) { + let candidate = strings::concat_buf_t(abs_to_rel, &[b"./", input_path]).ok()?; + if checker.check_path(candidate, implicit_extensions) { return Some(checker.remapped); } } @@ -5396,19 +5615,34 @@ impl<'a> Resolver<'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, - }; + 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(); + } - 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); + // 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()?; - 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". + if checker.check_path(candidate, ImplicitExtensions::Skip) { + return Some(checker.remapped); } } } @@ -5463,19 +5697,15 @@ 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, ) { // 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)), @@ -5731,19 +5961,15 @@ impl<'a> Resolver<'a> { }; if let Some(remap) = self .check_browser_map::<{ BrowserMapPathKind::AbsolutePath }>( - &browser_scope, + dir_info, index_abs_path, ) { // 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)), @@ -5799,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? @@ -5825,7 +6059,7 @@ impl<'a> Resolver<'a> { file_fd: file.file_fd, ..Default::default() }; - return MatchStatus::Success; + return true; } } } @@ -5842,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!( @@ -5988,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 \"{}\"", @@ -7043,99 +7295,118 @@ pub enum BrowserMapPathKind { AbsolutePath, } -/// Longest suffix the browser-map lookup appends to a candidate. -const BROWSER_MAP_INDEX_SUFFIX: &str = const_format::concatcp!(SEP_STR, "index"); +#[derive(Clone, Copy, PartialEq, Eq)] +enum ImplicitExtensions { + Include, + Skip, +} + +/// 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], - 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; + /// 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; + }; + // SAFETY: `BrowserMap` values are owned by a `PackageJSON` that is never freed. + self.remapped = unsafe { bun_ptr::Interned::assume(result) }.as_bytes(); + true + } - let cleaned = self.cleaned; + /// `path` followed by each implicit extension, in order. + fn lookup_with_extensions(&mut self, path: &[u8], buf: &mut [u8]) -> bool { + let extension_order = self.extension_order; + for ext in extension_order { + let ext: &[u8] = ext; + if let Ok(candidate) = strings::concat_buf_t(buf, &[path, ext]) + && self.lookup(candidate) + { + return true; + } + } + false + } + + /// Probes the path, then with an implicit extension, then as a directory with an "index". + 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, BROWSER_MAP_INDEX_SUFFIX.as_bytes()]; - ResolvePath::join_string_buf( - bufs!(tsconfig_base_url), - &parts, - bun_paths::Platform::AUTO, - ) + let trimmed = strings::trim_right(path_to_check, b"/"); + let index_parts: [&[u8]; 2] = if trimmed.is_empty() || trimmed == b"." { + [b"./index", b""] + } else { + [trimmed, b"/index"] + }; + let Ok(index_path) = strings::concat_buf_t(bufs!(browser_map_index), &index_parts) 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); - - 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; - } - } - } + // If that failed, try adding implicit extensions + implicit_extensions == ImplicitExtensions::Include + && self.lookup_with_extensions(index_path, ext_buf) + } +} - false +/// `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; + } + let rest = &path[base.len()..]; + // `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 7abe44200a5f..65dd501b7b3f 100644 --- a/src/resolver/result.rs +++ b/src/resolver/result.rs @@ -371,6 +371,8 @@ pub struct MatchResult { pub(crate) dir_info: Option, pub(crate) module_type: options::ModuleType, pub(crate) is_external: bool, + /// A "browser" map lookup already ran for `path_pair.primary`. No other lookup applies to it. + pub(crate) browser_remapped: bool, } impl Default for MatchResult { @@ -384,6 +386,7 @@ impl Default for MatchResult { dir_info: None, module_type: options::ModuleType::Unknown, is_external: false, + browser_remapped: false, } } } diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 6e7e8359aa46..3c86e3d3befb 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -1926,3 +1926,27 @@ devTest("recovers from an unresolved css url() and html script src longer than t expect((await dev.fetch("/")).status).toBe(200); }, }); +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"'); + }, +}); 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 92c277d551c6..6f839551252f 100644 --- a/test/bundler/bundler_browser.test.ts +++ b/test/bundler/bundler_browser.test.ts @@ -1,5 +1,10 @@ import assert from "assert"; -import { describe, expect } from "bun:test"; +import type { BunPlugin } from "bun"; +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", () => { @@ -289,6 +294,474 @@ describe("bundler", () => { }, }); + // A path that a package.json "browser" map sets to false is one empty CommonJS + // 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 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) { + expect(api.readFile("out.js").match(/^\/\/ \(disabled\):.*$/gm)).toEqual(["// (disabled):fs"]); + }, + run: { + 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(`