diff --git a/docs/runtime/module-resolution.mdx b/docs/runtime/module-resolution.mdx index 77760cb4b9ad..44e40267d66b 100644 --- a/docs/runtime/module-resolution.mdx +++ b/docs/runtime/module-resolution.mdx @@ -268,6 +268,8 @@ NODE_PATH=./packages:./lib bun run src/index.js # Unix/macOS NODE_PATH="./packages;./lib" bun run src/index.js # Windows ``` +Bun reads the `exports` field of a package that it finds through `NODE_PATH`. + ### Custom conditions The `--conditions` flag specifies the conditions to use when resolving packages from `package.json` `"exports"`. diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 7457a8f06aab..4426028c64e8 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -3064,6 +3064,22 @@ impl<'a> Resolver<'a> { if !node_path.is_empty() { let delim = if cfg!(windows) { b';' } else { b':' }; for path in node_path.split(|&b| b == delim).filter(|s| !s.is_empty()) { + // A subpath that did not fit the buffer is empty. + if let Some(esm) = esm_.as_ref() + && !esm.subpath.is_empty() + && let Some(status) = self.load_node_path_package_exports( + path, + import_path, + esm.subpath, + kind, + out, + ) + { + if let Some(d) = self.debug_logs.as_mut() { + d.decrease_indent(); + } + return status; + } let Some(abs_path) = self .fs_ref() .abs_buf_checked(&[path, import_path], bufs!(node_modules_check)) @@ -3845,6 +3861,95 @@ impl<'a> Resolver<'a> { unreachable!("TODO: implement enqueueDependencyToResolve for non-root packages") } + /// The file that "exports" of a package in `node_path`, a `NODE_PATH` entry, has for `subpath`, which is `import_path` without the name of the package. `None` without one: the caller goes on to `load_as_file_or_directory`. + #[cold] + #[inline(never)] + fn load_node_path_package_exports( + &mut self, + node_path: &[u8], + import_path: &[u8], + subpath: &[u8], + kind: ast::ImportKind, + out: &mut MatchResult, + ) -> Option { + use crate::package_json::EntryData; + + // As in Node, the folder has the whole text in front of the subpath as its name: `npkg@1.2.3`, not `npkg`. + let name = import_path.get(..(import_path.len() + 1).checked_sub(subpath.len())?)?; + let abs_package_path = self + .fs_ref() + .abs_buf_checked(&[node_path, name], bufs!(esm_absolute_package_path))?; + // `dir_info_cached` opens a file of this name on every call, because it does not remember ENOTDIR. + let is_listed_file = bun_paths::dirname(abs_package_path) + .and_then(|parent| self.dir_info_cached(parent).ok().flatten()) + .and_then(|parent| { + parent.get_entry(self.generation, bun_paths::basename(abs_package_path)) + }) + .is_some_and(|found| { + found.entry().kind(self.rfs_ref(), self.store_fd) != Fs::file_system::EntryKind::Dir + }); + if is_listed_file { + return None; + } + let package = self.dir_info_cached(abs_package_path).ok().flatten()?; + let package_json = package.package_json()?; + // Node loads "main" when "exports" is `false` or a number. + let exports_map = package_json.exports.as_ref().filter(|exports| { + matches!( + exports.root.data, + EntryData::String(_) | EntryData::Array(_) | EntryData::Map(_) + ) + })?; + if let Some(debug) = self.debug_logs.as_mut() { + debug.add_note_fmt(format_args!( + "Reading \"exports\" of the package in the NODE_PATH directory \"{}\"", + bstr::BStr::new(abs_package_path) + )); + } + + let prev_extension_order = self.extension_order; + // A `NODE_PATH` entry need not be in node_modules. + self.extension_order = self + .opts + .extension_order + .kind(kind, package.is_inside_node_modules()); + // The node_modules lookup also reads `pkg/key.js` as the key "./key". + let without_js = (bun_paths::extension(subpath) == b".js" && subpath.len() > 3) + .then(|| &subpath[..subpath.len() - 3]); + let mut is_found = false; + for key in core::iter::once(subpath).chain(without_js) { + let esm_resolution = ESModule { + conditions: match kind { + ast::ImportKind::Require | ast::ImportKind::RequireResolve => { + &self.opts.conditions.require + } + ast::ImportKind::At | ast::ImportKind::AtConditional => { + &self.opts.conditions.style + } + _ => &self.opts.conditions.import, + }, + debug_logs: self.debug_logs.as_mut(), + stack_check: StackCheck::init(), + } + .resolve(b"/", key, &exports_map.root); + is_found = self + .handle_esm_resolution( + esm_resolution, + abs_package_path, + kind, + package_json, + subpath, + out, + ) + .is_success(); + if is_found { + break; + } + } + self.extension_order = prev_extension_order; + is_found.then_some(MatchStatus::Success) + } + fn handle_esm_resolution( &mut self, mut esm_resolution: crate::package_json::Resolution, diff --git a/src/sema/resolve/code/computed.rs b/src/sema/resolve/code/computed.rs index b78bf27f0718..692e28e5e3d3 100644 --- a/src/sema/resolve/code/computed.rs +++ b/src/sema/resolve/code/computed.rs @@ -222,6 +222,13 @@ impl<'a, 'h> CandidateSearch<'a, 'h> { } for node_path in &resolver.code_options().node_paths { self.walk_below(node_path, b"", SpecifierForm::Path)?; + // The folder has the written name: in a `NODE_PATH` entry a version is a part of it. + let manifest = Some(join_unrooted(node_path, name)) + .filter(|package_directory| resolver.is_dir(package_directory)) + .and_then(|package_directory| resolver.code_manifest(&package_directory)); + if let Some(exports) = manifest.and_then(|manifest| manifest.exports.as_ref()) { + self.try_exact_exported_keys(exports, name)?; + } } Ok(()) } @@ -262,6 +269,29 @@ impl<'a, 'h> CandidateSearch<'a, 'h> { } for node_path in &resolver.code_options().node_paths { self.walk_below(node_path, b"", SpecifierForm::Path)?; + // After the walk: it ends the search in a folder where more packages start with the prefix than the limit allows, and then no manifest is read. + for name in self.list_installed_names(node_path) { + let manifest = resolver.code_manifest(&join_unrooted(node_path, &name)); + if let Some(exports) = manifest.and_then(|manifest| manifest.exports.as_ref()) { + self.try_exact_exported_keys(exports, &name)?; + } + } + } + Ok(()) + } + + /// Tries the keys of "exports" of a package in a `NODE_PATH` entry that name one subpath each. A key with a star or a key of a folder lists no folder here: of its files, only those that the walk lists are found. + fn try_exact_exported_keys( + &mut self, + exports: &ExportsEntry<'h>, + name: &[u8], + ) -> Result<(), ComputedUnknown> { + let ExportsEntry::Map(map) = *exports else { + return Ok(()); + }; + let subpath_keys = map.entries.iter().filter(|it| it.0.starts_with(b"./")); + for &(key, _) in subpath_keys.filter(|it| !strings::contains_char(it.0, b'*')) { + self.try_exported_specifier(&[name, &key[1..]].concat())?; } Ok(()) } diff --git a/src/sema/resolve/code/computed/tests.rs b/src/sema/resolve/code/computed/tests.rs index 63d4643c0693..cd28ba1bab5a 100644 --- a/src/sema/resolve/code/computed/tests.rs +++ b/src/sema/resolve/code/computed/tests.rs @@ -500,3 +500,107 @@ fn packages_in_node_path_are_listed() { "" ); } + +fn node_path_candidates(host: &MemoryHost, limit: u32, prefix: &str, suffix: &str) -> String { + let options = CodeOptions { + node_paths: vec![b"/global".to_vec()], + max_computed_candidates: limit, + ..CodeOptions::bun_run(b"/p") + }; + let pattern = ComputedPattern { + importer: "/p/main.ts", + prefix, + suffix, + kind: Import, + }; + resolve_computed_with(host, &options, pattern) +} + +#[test] +#[cfg_attr(miri, ignore)] +fn exact_exported_subpaths_of_packages_in_node_path_are_listed() { + let host = MemoryHost::new(&[ + ("/p/main.ts", ""), + ( + "/global/pkg/package.json", + r#"{ "exports": { ".": "./lib/root.js", "./alias": "./lib/target.js" } }"#, + ), + ("/global/pkg/lib/root.js", ""), + ("/global/pkg/lib/target.js", ""), + ("/global/pkg/plain.js", ""), + ]) + .node_path("/global"); + let candidates = |prefix, suffix| node_path_candidates(&host, 1024, prefix, suffix); + assert_eq!(candidates("pkg/al", ""), "/global/pkg/lib/target.js"); + assert_eq!(candidates("pk", "/alias"), "/global/pkg/lib/target.js"); + assert_eq!(candidates("pk", "g"), "/global/pkg/lib/root.js"); + assert_eq!(candidates("pkg/pl", ""), "/global/pkg/plain.js"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn exports_of_package_in_node_path_list_no_folder() { + let host = MemoryHost::new(&[ + ("/p/main.ts", ""), + ( + "/global/pkg/package.json", + r#"{ "exports": { "./*": "./*", "./icons/": "./icons/" } }"#, + ), + ("/global/pkg/a.js", ""), + ("/global/pkg/b.js", ""), + ("/global/pkg/c.js", ""), + ("/global/pkg/icons/x.js", ""), + ("/global/pkg/icons/y.js", ""), + ]) + .node_path("/global"); + // The walk lists the two files of `icons`. The package has more files than the limit. + assert_eq!( + node_path_candidates(&host, 2, "pkg/icons/", ""), + "/global/pkg/icons/x.js, /global/pkg/icons/y.js" + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn package_in_node_modules_and_in_node_path_counts_once() { + let host = MemoryHost::new(&[ + ("/p/main.ts", ""), + ( + "/p/node_modules/pkg/package.json", + r#"{ "exports": { "./icons/*": "./dist/icons/*" } }"#, + ), + ("/p/node_modules/pkg/dist/icons/x.js", ""), + ("/p/node_modules/pkg/dist/icons/y.js", ""), + ]) + .link("/global/pkg", "/p/node_modules/pkg") + .node_path("/global"); + assert_eq!( + node_path_candidates(&host, 2, "pkg/icons/", ""), + "/p/node_modules/pkg/dist/icons/x.js, /p/node_modules/pkg/dist/icons/y.js" + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn walk_over_node_path_ends_search_before_manifests_are_read() { + let manifest = r#"{ "exports": { "./alias": "./target.js" } }"#; + let host = MemoryHost::new(&[ + ("/p/main.ts", ""), + ("/global/pka/package.json", manifest), + ("/global/pka/target.js", ""), + ("/global/pkb/package.json", manifest), + ("/global/pkb/target.js", ""), + ("/global/pkc/package.json", manifest), + ("/global/pkc/target.js", ""), + ]) + .node_path("/global"); + assert_eq!( + node_path_candidates(&host, 2, "pk", "/alias"), + "All(TooManyCandidates)" + ); + assert_eq!(host.reads("/global/pka/package.json"), 0); + assert_eq!( + node_path_candidates(&host, 8, "pk", "/alias"), + "/global/pka/target.js, /global/pkb/target.js, /global/pkc/target.js" + ); +} diff --git a/src/sema/resolve/code/memory_host.rs b/src/sema/resolve/code/memory_host.rs index 59acb10a3538..fcc24bfbb617 100644 --- a/src/sema/resolve/code/memory_host.rs +++ b/src/sema/resolve/code/memory_host.rs @@ -239,19 +239,24 @@ impl CodeHost for MemoryHost { }; } let in_node_paths = || { - let files = self.node_paths.iter().map(|directory| { + for directory in &self.node_paths { + let exported = super::search::find_exported_in_node_path(self, search, directory); + if exported.is_some() { + return exported; + } let specifier = &join(directory, search.specifier); - find_file_plainly( + let found = find_file_plainly( self, &FileSearch { specifier, ..*search }, - ) - }); - files - .into_iter() - .find(|it| matches!(it, FoundCode::File { .. })) + ); + if matches!(found, FoundCode::File { .. }) { + return Some(found); + } + } + None }; let found = self.find_through_imports(search); let found = found.unwrap_or_else(|| find_file_plainly(self, search)); diff --git a/src/sema/resolve/code/search.rs b/src/sema/resolve/code/search.rs index 0ad07f7eb521..cce1305ac8aa 100644 --- a/src/sema/resolve/code/search.rs +++ b/src/sema/resolve/code/search.rs @@ -347,6 +347,39 @@ impl CodeOptions { } } +/// What `bun run` finds by the "exports" of the package that the specifier names in `node_path`, a `NODE_PATH` entry. `None` where it goes on to the file or the folder of that name. +#[cfg(test)] +pub(super) fn find_exported_in_node_path( + host: &H, + search: &FileSearch<'_>, + node_path: &[u8], +) -> Option { + // The folder has the written name: in a `NODE_PATH` entry a version is a part of it. + installed_name(search.specifier)?; + let name = written_package_name(search.specifier)?; + let subpath = [b".".as_slice(), &search.specifier[name.len()..]].concat(); + if subpath.len() > SUBPATH_CAPACITY { + return None; + } + let plain = PlainSearch { + host, + parse_manifest: H::parse_package_json, + orders: &BUN_RUN.extension_orders, + kind: search.kind, + prefers_module_field: search.prefers_module_field, + holds: &|condition| BUN_RUN.matches_condition(condition, search.kind), + arena: OnceCell::new(), + }; + let directory = join_unrooted(node_path, name); + let manifest = plain.read_manifest(&directory)?; + let exports = plain.read_exports(&manifest)?; + if matches!(exports, ExportsEntry::Invalid) { + return None; + } + let found = plain.find_in_exports(&directory, &exports, &subpath); + matches!(found, FoundCode::File { .. }).then_some(found) +} + #[derive(Copy, Clone, PartialEq, Eq, Debug)] enum KeyKind { Exact, diff --git a/test/aot/resolver/self-reference-and-node-path.test.ts b/test/aot/resolver/self-reference-and-node-path.test.ts index 1c2523e798b6..fdec7d47df84 100644 --- a/test/aot/resolver/self-reference-and-node-path.test.ts +++ b/test/aot/resolver/self-reference-and-node-path.test.ts @@ -1,5 +1,8 @@ -import type { DirectoryTree } from "harness"; -import { delimiter, join } from "node:path"; +import { expect } from "bun:test"; +import { bunEnv, bunExe, tempDir, type DirectoryTree } from "harness"; +import { realpathSync } from "node:fs"; +import { delimiter, join, relative, sep } from "node:path"; +import { eachRow, gap } from "../supported"; import { describe } from "../with-compiler"; import { packageFiles, testResolution, type Pin, type ResolutionCase } from "./resolution-cases"; @@ -137,8 +140,8 @@ const cases: ResolutionCase[] = [ "@scope/pkg": "global-a/@scope/pkg/index.js", // The extension order outside node_modules. "pair": "global-a/pair.ts", - // "exports" is not read in a NODE_PATH directory. - "with-exports": "global-a/with-exports/main.js", + // "exports" is read in a NODE_PATH directory. A path that it does not name is still looked up as a file. + "with-exports": "global-a/with-exports/exported.js", "with-exports/hidden": "global-a/with-exports/hidden.js", // A relative entry starts at the working directory, which is the root. "only-c": "global-c/only-c/index.js", @@ -162,3 +165,43 @@ const cases: ResolutionCase[] = [ ]; describe("code resolution", () => testResolution(cases)); + +const noFolderIsListed = + "bun run loads the file. The compiler lists no folder for a key with a star or for a folder key of a package in NODE_PATH, so an executable does not hold the file."; + +eachRow<[prefix: string, suffix: string, found: string[]]>([ + ["pkg/al", "", ["global/pkg/lib/target.js"]], + ["pk", "/alias", ["global/pkg/lib/target.js"]], + ["pkg/pl", "", ["global/pkg/plain.js"]], + gap(noFolderIsListed, ["pkg/icons/", "", ["global/pkg/dist/icons/x.js"]]), + gap(noFolderIsListed, ["pkg/d/", "", ["global/pkg/dist/d/y.js"]]), +])("a computed specifier from %j to %j finds what a package in NODE_PATH exports", async (prefix, suffix, found) => { + using tree = tempDir("aot-node-path-exports", { + "global/pkg/package.json": JSON.stringify({ + exports: { "./alias": "./lib/target.js", "./icons/*": "./dist/icons/*", "./d/": "./dist/d/" }, + }), + ...emptyFiles([ + "global/pkg/lib/target.js", + "global/pkg/plain.js", + "global/pkg/dist/icons/x.js", + "global/pkg/dist/d/y.js", + "app/importer.js", + ]), + }); + const root = realpathSync(String(tree)); + const request = [join(root, "app", "importer.js"), prefix, suffix].map(text => JSON.stringify(text)).join(", "); + const script = `import { resolveComputedSpecifier } from "bun:internal-for-testing"; +console.log(JSON.stringify(resolveComputedSpecifier(${request})));`; + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-install", "-e", script], + cwd: root, + env: { ...bunEnv, NODE_PATH: join(root, "global") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + + const { candidates } = JSON.parse(stdout) as { candidates: string[] }; + expect(candidates.map(path => relative(root, path).replaceAll(sep, "/"))).toEqual(found); +}); diff --git a/test/bundler/bundler_regressions.test.ts b/test/bundler/bundler_regressions.test.ts index bab50d9bffeb..3b6134d02d35 100644 --- a/test/bundler/bundler_regressions.test.ts +++ b/test/bundler/bundler_regressions.test.ts @@ -1,4 +1,5 @@ -import { describe } from "bun:test"; +import { describe, expect } from "bun:test"; +import { delimiter } from "node:path"; import { itBundled } from "./expectBundled"; describe("bundler", () => { @@ -284,6 +285,73 @@ describe("bundler", () => { }, }); + // The fixture of packagejson/NodePathsESBuildIssue2752 without its "browser" map row, which no "exports" decides. + itBundled("regression/NODE_PATHPackageExports", { + files: { + "/src/entry.js": ` + import "pkg1"; + import "pkg2"; + import "@scope/pkg4"; + `, + "/usr/lib/pkg/pkg1/package.json": `{ "main": "./foo.js" }`, + "/usr/lib/pkg/pkg1/foo.js": `console.log('pkg1')`, + "/lib/pkg/pkg2/package.json": `{ "exports": { ".": "./bar.js" } }`, + "/lib/pkg/pkg2/bar.js": `console.log('pkg2')`, + "/tmp/pkg/@scope/pkg4/package.json": `{ "exports": { ".": { "import": "./bat.js" } } }`, + "/tmp/pkg/@scope/pkg4/bat.js": `console.log('pkg4')`, + }, + entryPoints: ["/src/entry.js"], + backend: "cli", + env: { + NODE_PATH: ["{{root}}/usr/lib/pkg", "{{root}}/lib/pkg", "{{root}}/tmp/pkg"].join(delimiter), + }, + run: { + stdout: "pkg1\npkg2\npkg4", + }, + }); + + itBundled("regression/NODE_PATHPackageExportsStyle", { + files: { + "/src/entry.css": `@import "csspkg";`, + "/lib/csspkg/package.json": `{ "main": "./legacy.css", "exports": { ".": { "style": "./style.css", "default": "./def.js" } } }`, + "/lib/csspkg/style.css": `.from-style { color: red; }`, + "/lib/csspkg/legacy.css": `.from-main { color: blue; }`, + "/lib/csspkg/def.js": ``, + }, + entryPoints: ["/src/entry.css"], + outfile: "/out.css", + backend: "cli", + env: { + NODE_PATH: "{{root}}/lib", + }, + onAfterBundle(api) { + const css = api.readFile("/out.css"); + expect({ style: css.includes(".from-style"), main: css.includes(".from-main") }).toEqual({ + style: true, + main: false, + }); + }, + }); + + // As in node_modules, the "browser" map of the package is read for a file that "exports" names. + itBundled("regression/NODE_PATHPackageExportsBrowserMap", { + files: { + "/src/entry.js": `import "bpkg/x.js";`, + "/lib/bpkg/package.json": `{ "exports": { "./x.js": "./x.js" }, "browser": { "./x.js": "./x-browser.js" } }`, + "/lib/bpkg/x.js": `console.log("x")`, + "/lib/bpkg/x-browser.js": `console.log("x-browser")`, + }, + entryPoints: ["/src/entry.js"], + target: "browser", + backend: "cli", + env: { + NODE_PATH: "{{root}}/lib", + }, + run: { + stdout: "x-browser", + }, + }); + itBundled("regression/NamespaceTracking#12337", { files: { "/entry.ts": /* ts */ ` diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index bee8739d8625..e502f3299b65 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -2,7 +2,7 @@ import { pathToFileURL } from "bun"; import { describe, expect, it, test } from "bun:test"; import { chmodSync, chownSync, mkdirSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "fs"; import { bunEnv, bunExe, bunRun, isLinux, isMacOS, isRoot, isWindows, joinP, tempDir, tempDirWithFiles } from "harness"; -import { join, resolve, sep } from "path"; +import { delimiter, join, resolve, sep } from "path"; const fixture = (...segs: string[]) => resolve(import.meta.dir, "fixtures", ...segs); @@ -429,6 +429,278 @@ describe("NODE_PATH test", () => { expect(exitCode).toBe(0); expect(stdout.toString().trim()).toBe("NODE_PATH from lib works"); }); + + it.concurrent("reads the exports of a package that it finds through NODE_PATH", async () => { + // Longer than the 512 bytes that the resolver keeps of a subpath. + const longSubpath = ["a", "b", "c"].map(letter => Buffer.alloc(200, letter).toString()).join("/") + "/x.js"; + const withMain = (manifest: object) => JSON.stringify({ main: "./legacy.js", ...manifest }); + const empty = (folder: string, names: string[]) => Object.fromEntries(names.map(name => [`${folder}/${name}`, ""])); + using dir = tempDir("node-path-exports", { + "gp1/npkg/package.json": withMain({ + exports: { ".": "./m.js", "./mapped": "./real-target.js", "./priv/*": null }, + }), + "gp1/npkg/m.js": `module.exports = "EXPORTS-DOT";`, + "gp1/npkg/legacy.js": `module.exports = "LEGACY-MAIN";`, + ...empty("gp1/npkg", ["real-target.js", "hidden.js", "priv/secret.js"]), + "gp1/@scope/pkg/package.json": JSON.stringify({ exports: { ".": "./idx.js" } }), + ...empty("gp1/@scope/pkg", ["idx.js"]), + "gp1/str/package.json": withMain({ exports: "./m.js" }), + ...empty("gp1/str", ["m.js", "legacy.js"]), + "gp1/arr/package.json": withMain({ exports: ["./m.js"] }), + ...empty("gp1/arr", ["m.js", "legacy.js"]), + "gp1/rootcond/package.json": withMain({ exports: { import: "./esm.mjs", require: "./cjs.cjs" } }), + ...empty("gp1/rootcond", ["esm.mjs", "cjs.cjs", "legacy.js"]), + "gp1/condonly/package.json": withMain({ exports: { ".": { import: "./esm.mjs" } } }), + ...empty("gp1/condonly", ["esm.mjs", "legacy.js"]), + "gp1/unbuilt/package.json": withMain({ exports: { ".": "./dist/index.js" } }), + ...empty("gp1/unbuilt", ["legacy.js"]), + "gp1/efalse/package.json": withMain({ exports: false }), + ...empty("gp1/efalse", ["legacy.js"]), + "gp1/enum/package.json": withMain({ exports: 7 }), + ...empty("gp1/enum", ["legacy.js"]), + "gp1/mixed/package.json": withMain({ exports: { ".": "./m.js", "import": "./esm.mjs" } }), + ...empty("gp1/mixed", ["m.js", "esm.mjs", "legacy.js"]), + "gp1/legacy-only/package.json": JSON.stringify({ main: "./lib.js" }), + ...empty("gp1/legacy-only", ["lib.js", "sub.js"]), + "gp1/MyClass.js": "", + "gp1/lit@1.0.0/index.js": "", + "gp1/ver@2.0.0/package.json": withMain({ exports: { ".": "./m.js" } }), + ...empty("gp1/ver@2.0.0", ["m.js", "legacy.js"]), + "gp1/beside.js": "", + "gp1/beside/package.json": withMain({ exports: { ".": "./m.js" } }), + ...empty("gp1/beside", ["m.js", "legacy.js"]), + "gp1/pexp/package.json": JSON.stringify({ exports: { "./d/": "./d/", "./w/*": "./w/*" } }), + ...empty("gp1/pexp", ["d/y.ts", "d/y.js", "w/x.ts", "w/x.js"]), + "gp1/dual/package.json": JSON.stringify({ exports: { ".": "./m.js" } }), + ...empty("gp1/dual", ["m.js"]), + "gp2/dual/package.json": JSON.stringify({ main: "./m.js" }), + ...empty("gp2/dual", ["m.js", "hidden.js"]), + "gp2/only2/package.json": JSON.stringify({ exports: { ".": "./m.js" } }), + ...empty("gp2/only2", ["m.js"]), + "store/linked/package.json": withMain({ exports: { ".": "./m.js" } }), + ...empty("store/linked", ["m.js", "legacy.js"]), + ...(isWindows + ? {} + : { + "gp1/wild/package.json": JSON.stringify({ exports: { "./*": "./*" } }), + [`gp1/wild/${longSubpath}`]: "", + }), + "app/package.json": JSON.stringify({ + name: "app", + imports: { "#root": "npkg", "#dep": "npkg/mapped", "#hid": "npkg/hidden.js" }, + }), + "app/probe.mjs": ` + import { mkdirSync, writeFileSync } from "node:fs"; + import { createRequire } from "node:module"; + import { join, relative, sep } from "node:path"; + const require = createRequire(import.meta.url); + const [root, ...specifiers] = process.argv.slice(2); + const answer = find => { + try { + return relative(root, find()).replaceAll(sep, "/"); + } catch (e) { + return "ERR:" + e.code; + } + }; + const answers = specifier => [ + answer(() => require.resolve(specifier)), + answer(() => Bun.resolveSync(specifier, import.meta.dirname)), + ]; + const rows = Object.fromEntries(specifiers.map(specifier => [specifier, answers(specifier)])); + const loaded = [require("npkg"), (await import("npkg")).default]; + + // A package that is made after the resolver has read the folder beside it. + const late = join(root, "gp1", "late"); + mkdirSync(late); + writeFileSync(join(late, "package.json"), JSON.stringify({ main: "./legacy.js", exports: { ".": "./m.js" } })); + writeFileSync(join(late, "m.js"), ""); + writeFileSync(join(late, "legacy.js"), ""); + console.log(JSON.stringify({ loaded, rows, late: answers("late") })); + `, + }); + const root = realpathSync(String(dir)); + symlinkSync(join(root, "store", "linked"), join(root, "gp1", "linked"), "junction"); + + // Each row: what require.resolve() finds, then what Bun.resolveSync() finds. + const same = (path: string) => [path, path]; + const notFound = ["ERR:MODULE_NOT_FOUND", "ERR:ERR_MODULE_NOT_FOUND"]; + const rows: Record = { + "npkg": same("gp1/npkg/m.js"), + "npkg/mapped": same("gp1/npkg/real-target.js"), + "@scope/pkg": same("gp1/@scope/pkg/idx.js"), + "str": same("gp1/str/m.js"), + "arr": same("gp1/arr/m.js"), + "rootcond": ["gp1/rootcond/cjs.cjs", "gp1/rootcond/esm.mjs"], + // As in node_modules: "pkg/key.js" is also read as the key "./key". + "npkg/mapped.js": same("gp1/npkg/real-target.js"), + // Where "exports" has no file for the subpath, the lookup by "main" and by file name still answers. + "npkg/package.json": same("gp1/npkg/package.json"), + "npkg/hidden.js": same("gp1/npkg/hidden.js"), + "npkg/priv/secret.js": same("gp1/npkg/priv/secret.js"), + "condonly": ["gp1/condonly/legacy.js", "gp1/condonly/esm.mjs"], + "unbuilt": same("gp1/unbuilt/legacy.js"), + // As in Node, the folder has the whole text in front of the subpath as its name. + "npkg@1.2.3": notFound, + "npkg@": notFound, + "@scope/pkg@": notFound, + "lit@1.0.0": same("gp1/lit@1.0.0/index.js"), + "ver@2.0.0": same("gp1/ver@2.0.0/m.js"), + // Node loads "main" when "exports" is not a map, a text or a list. + "efalse": same("gp1/efalse/legacy.js"), + "enum": same("gp1/enum/legacy.js"), + "mixed": same("gp1/mixed/legacy.js"), + "legacy-only": same("gp1/legacy-only/lib.js"), + "legacy-only/sub.js": same("gp1/legacy-only/sub.js"), + "MyClass.js": same("gp1/MyClass.js"), + // The folder of a package comes before a file of its name. + "beside": same("gp1/beside/m.js"), + // Outside node_modules ".ts" comes before ".js", for a key that ends in "/" and for a key with "*". + "pexp/d/y": same("gp1/pexp/d/y.ts"), + "pexp/w/x": same("gp1/pexp/w/x.ts"), + // The first entry that has the package answers, also when that package has no "main". + "dual": same("gp1/dual/m.js"), + "dual/hidden.js": same("gp2/dual/hidden.js"), + "only2": same("gp2/only2/m.js"), + // A package manager links the folder of a package from its store. + "linked": same("store/linked/m.js"), + // What "imports" of the importer's package.json names. + "#root": same("gp1/npkg/m.js"), + "#dep": same("gp1/npkg/real-target.js"), + "#hid": same("gp1/npkg/hidden.js"), + ...(isWindows ? {} : { [`wild/${longSubpath}`]: same(`gp1/wild/${longSubpath}`) }), + }; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-install", "probe.mjs", root, ...Object.keys(rows)], + env: { ...bunEnv, NODE_PATH: [join(root, "gp1"), join(root, "gp2")].join(delimiter) }, + cwd: join(root, "app"), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + loaded: ["EXPORTS-DOT", "EXPORTS-DOT"], + rows, + late: same("gp1/late/m.js"), + }); + expect(exitCode).toBe(0); + }); + + it.concurrent("finds the same file for a package in a NODE_PATH entry as for one in node_modules", async () => { + const manifest = JSON.stringify({ + main: "./legacy.js", + exports: { + ".": "./m.js", + "./mapped": "./real-target.js", + "./b/*": "./dir/*", + "./b/*.js": null, + "./x.js": null, + "./x": "./real.js", + "./w/*": "./w/*", + "./d/": "./d/", + }, + }); + const names = [ + "m.js", + "legacy.js", + "real-target.js", + "dir/q.js", + "real.js", + "w/x.ts", + "w/x.js", + "d/y.ts", + "d/y.js", + ]; + const packageIn = (folder: string) => ({ + [`${folder}/par/package.json`]: manifest, + ...Object.fromEntries(names.map(name => [`${folder}/par/${name}`, ""])), + }); + using dir = tempDir("node-path-parity", { + ...packageIn("app/node_modules"), + ...packageIn("global/node_modules"), + "elsewhere/probe.mjs": ` + import { createRequire } from "node:module"; + import { join, relative, sep } from "node:path"; + const [root, ...specifiers] = process.argv.slice(2); + const answers = (from, folder) => { + const require = createRequire(join(from, "importer.js")); + const answer = find => { + try { + return relative(join(root, folder), find()).replaceAll(sep, "/"); + } catch (e) { + return "ERR:" + e.code; + } + }; + const rows = specifiers.map(specifier => [ + specifier, + [answer(() => require.resolve(specifier)), answer(() => Bun.resolveSync(specifier, from))], + ]); + return Object.fromEntries(rows); + }; + console.log(JSON.stringify({ + inNodeModules: answers(join(root, "app"), "app/node_modules"), + throughNodePath: answers(join(root, "elsewhere"), "global/node_modules"), + })); + `, + }); + const root = realpathSync(String(dir)); + // Each of these is a file that "exports" names, or one that the node_modules lookup finds beside "exports". + const specifiers = [ + "par", + "par/mapped", + "par/mapped.js", + "par/package.json", + "par/b/q.js", + "par/x.js", + "par/w/x", + "par/d/y", + ]; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-install", "probe.mjs", root, ...specifiers], + env: { ...bunEnv, NODE_PATH: join(root, "global", "node_modules") }, + cwd: join(root, "elsewhere"), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const { inNodeModules, throughNodePath } = JSON.parse(stdout); + expect(Object.keys(inNodeModules)).toEqual(specifiers); + expect(inNodeModules["par"]).toEqual(["par/m.js", "par/m.js"]); + expect(throughNodePath).toEqual(inNodeModules); + expect(exitCode).toBe(0); + }); + + it.concurrent("loads a package that a NODE_PATH entry exports without a request to the registry", async () => { + const requests: string[] = []; + using registry = Bun.serve({ + port: 0, + fetch(request) { + requests.push(new URL(request.url).pathname); + return new Response("not found", { status: 404 }); + }, + }); + using dir = tempDir("node-path-autoinstall", { + "gp/only-exports/package.json": JSON.stringify({ exports: { ".": "./m.js" } }), + "gp/only-exports/m.js": `module.exports = "FROM-NODE-PATH";`, + "app/index.js": `console.log(require("only-exports"));`, + "app/bunfig.toml": `[install]\nregistry = "http://127.0.0.1:${registry.port}/"\n`, + }); + const root = realpathSync(String(dir)); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: { ...bunEnv, NODE_PATH: join(root, "gp"), BUN_INSTALL_CACHE_DIR: join(root, ".bun-cache") }, + cwd: join(root, "app"), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr, requests }).toEqual({ stdout: "FROM-NODE-PATH\n", stderr: "", requests: [] }); + expect(exitCode).toBe(0); + }); }); it("can resolve with source directories that do not exist", () => {