From c857ed319a32d34106217c18f73d4b471a74e3eb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:14:42 +0000 Subject: [PATCH 1/3] resolver: read package.json "exports" for a package found through NODE_PATH The NODE_PATH loop of load_node_modules joined each entry with the raw specifier and called load_as_file_or_directory, which reads "main" and index files only. A package found there had its "exports" ignored: "main" won over the "." export, an exported subpath was not found, and a package that has only "exports" was invisible, so a later entry answered. For each entry, the loop now asks the package folder for its "exports" first. It uses the step of the node_modules lookup: the same conditions and the same retry without ".js". The extension order is that of the place of the folder. When "exports" has no file for the subpath, the plain lookup still runs. As in Node, the folder has the whole text in front of the subpath as its name, so `npkg@1.2.3` names a folder of that name and not `npkg`. A package whose "exports" is false or a number goes to the plain lookup as before. The node_modules walk and the auto-install arm are not changed. The enumerator of computed specifiers of `bun build --compile --aot` offers the exported subpaths of a package in a NODE_PATH directory. --- docs/runtime/module-resolution.mdx | 2 + src/resolver/resolver.rs | 105 +++++++ src/sema/resolve/code/computed.rs | 53 +++- src/sema/resolve/code/computed/tests.rs | 63 ++++ src/sema/resolve/code/memory_host.rs | 19 +- src/sema/resolve/code/search.rs | 33 +++ .../self-reference-and-node-path.test.ts | 37 ++- test/bundler/bundler_regressions.test.ts | 70 ++++- test/js/bun/resolve/resolve.test.ts | 274 +++++++++++++++++- 9 files changed, 633 insertions(+), 23 deletions(-) diff --git a/docs/runtime/module-resolution.mdx b/docs/runtime/module-resolution.mdx index 77760cb4b9ad..c985a8b21998 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 in a `NODE_PATH` directory first, as it does for a package in `node_modules`. + ### 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..01f1862a90d1 100644 --- a/src/sema/resolve/code/computed.rs +++ b/src/sema/resolve/code/computed.rs @@ -221,6 +221,18 @@ impl<'a, 'h> CandidateSearch<'a, 'h> { } } for node_path in &resolver.code_options().node_paths { + // 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(manifest) = manifest + && let Some(exports) = &manifest.exports + { + self.without_charge(|search| { + search.add_exported_candidates(manifest, exports, name) + })?; + } + // The plain lookup still finds a file that "exports" does not name. self.walk_below(node_path, b"", SpecifierForm::Path)?; } Ok(()) @@ -248,24 +260,47 @@ impl<'a, 'h> CandidateSearch<'a, 'h> { let Some(node_modules) = resolver.find_node_modules(ancestor) else { continue; }; - for name in self.list_installed_names(&node_modules) { - let manifest = resolver.code_manifest(&join_unrooted(&node_modules, &name)); - if let Some(manifest) = manifest - && let Some(exports) = &manifest.exports - { - self.try_specifier(&name)?; - self.add_exported_candidates(manifest, exports, &name)?; - } - } + self.add_exporting_packages_by_start_of_name(&node_modules)?; // It comes to the folder of each package, which is its name, and finds nothing in one that has `exports`. self.walk_below(&node_modules, b"", SpecifierForm::Path)?; } for node_path in &resolver.code_options().node_paths { + self.without_charge(|search| { + search.add_exporting_packages_by_start_of_name(node_path) + })?; self.walk_below(node_path, b"", SpecifierForm::Path)?; } Ok(()) } + /// Runs `pass` and gives back what it took of the limit, because the walk that follows lists the same folders. + fn without_charge( + &mut self, + pass: impl FnOnce(&mut Self) -> Result<(), ComputedUnknown>, + ) -> Result<(), ComputedUnknown> { + let visited = (self.visited_files, self.visited_directories); + pass(self)?; + (self.visited_files, self.visited_directories) = visited; + Ok(()) + } + + fn add_exporting_packages_by_start_of_name( + &mut self, + folder: &[u8], + ) -> Result<(), ComputedUnknown> { + let resolver = self.resolver; + for name in self.list_installed_names(folder) { + let manifest = resolver.code_manifest(&join_unrooted(folder, &name)); + if let Some(manifest) = manifest + && let Some(exports) = &manifest.exports + { + self.try_specifier(&name)?; + self.add_exported_candidates(manifest, exports, &name)?; + } + } + Ok(()) + } + fn list_installed_names(&self, node_modules: &[u8]) -> Vec> { let host = self.resolver.host; let (scope, start_of_name) = split_after_last_slash(self.request.prefix); diff --git a/src/sema/resolve/code/computed/tests.rs b/src/sema/resolve/code/computed/tests.rs index 63d4643c0693..bf88175612de 100644 --- a/src/sema/resolve/code/computed/tests.rs +++ b/src/sema/resolve/code/computed/tests.rs @@ -500,3 +500,66 @@ fn packages_in_node_path_are_listed() { "" ); } + +#[test] +#[cfg_attr(miri, ignore)] +fn exported_subpaths_of_packages_in_node_path_are_listed() { + let host = MemoryHost::new(&[ + ("/p/main.ts", ""), + ( + "/global/pkg/package.json", + r#"{ "exports": { "./alias": "./lib/target.js" } }"#, + ), + ("/global/pkg/lib/target.js", ""), + ("/global/pkg/plain.js", ""), + ]) + .node_path("/global"); + let with_node_path = CodeOptions { + node_paths: vec![b"/global".to_vec()], + ..CodeOptions::bun_run(b"/p") + }; + let candidates = |prefix, suffix| { + let pattern = ComputedPattern { + importer: "/p/main.ts", + prefix, + suffix, + kind: Import, + }; + resolve_computed_with(&host, &with_node_path, pattern) + }; + assert_eq!(candidates("pkg/al", ""), "/global/pkg/lib/target.js"); + assert_eq!(candidates("pk", "/alias"), "/global/pkg/lib/target.js"); + assert_eq!(candidates("pkg/pl", ""), "/global/pkg/plain.js"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn exported_subpaths_in_node_path_take_nothing_of_the_limit_of_the_walk() { + let host = MemoryHost::new(&[ + ("/p/main.ts", ""), + ( + "/global/pkg/package.json", + r#"{ "exports": { "./*": "./*" } }"#, + ), + ("/global/pkg/a.js", ""), + ("/global/pkg/b.js", ""), + ("/global/pkg/c.js", ""), + ]) + .node_path("/global"); + let pattern = ComputedPattern { + importer: "/p/main.ts", + prefix: "pkg/", + suffix: "", + kind: Import, + }; + // The walk alone visits four files, and so does the pass over "exports". + let options = CodeOptions { + node_paths: vec![b"/global".to_vec()], + max_computed_candidates: 5, + ..CodeOptions::bun_run(b"/p") + }; + assert_eq!( + resolve_computed_with(&host, &options, pattern), + "/global/pkg/a.js, /global/pkg/b.js, /global/pkg/c.js, /global/pkg/package.json" + ); +} 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..32a1089c048a 100644 --- a/test/aot/resolver/self-reference-and-node-path.test.ts +++ b/test/aot/resolver/self-reference-and-node-path.test.ts @@ -1,6 +1,8 @@ -import type { DirectoryTree } from "harness"; -import { delimiter, join } from "node:path"; -import { describe } from "../with-compiler"; +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 { describe, test } from "../with-compiler"; import { packageFiles, testResolution, type Pin, type ResolutionCase } from "./resolution-cases"; type Answer = string | null | [onImport: string | null, onRequire: string | null]; @@ -137,8 +139,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 +164,28 @@ const cases: ResolutionCase[] = [ ]; describe("code resolution", () => testResolution(cases)); + +test("a computed specifier finds what a package in NODE_PATH exports", async () => { + using tree = tempDir("aot-node-path-exports", { + "global/pkg/package.json": JSON.stringify({ exports: { "./alias": "./lib/target.js" } }), + ...emptyFiles(["global/pkg/lib/target.js", "global/pkg/plain.js", "app/importer.js"]), + }); + const root = realpathSync(String(tree)); + const script = `import { resolveComputedSpecifier } from "bun:internal-for-testing"; +const importer = ${JSON.stringify(join(root, "app", "importer.js"))}; +console.log(JSON.stringify(["pkg/al", "pk", "pkg/pl"].map(prefix => resolveComputedSpecifier(importer, prefix, prefix === "pk" ? "/alias" : ""))));`; + 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({ exitCode, stderr: exitCode === 0 ? "" : stderr }).toEqual({ exitCode: 0, stderr: "" }); + + const found = (JSON.parse(stdout) as { candidates: string[] }[]).map(answer => + answer.candidates.map(path => relative(root, path).replaceAll(sep, "/")), + ); + expect(found).toEqual([["global/pkg/lib/target.js"], ["global/pkg/lib/target.js"], ["global/pkg/plain.js"]]); +}); 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..7e2fe75415c8 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("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("finds the same file for a package in a NODE_PATH entry as for the package 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("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", () => { From fa8fbbc952333f1f586799c4caa22e50ccea073f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:49:01 +0000 Subject: [PATCH 2/3] AOT: try only the exact "exports" keys of a package in NODE_PATH For a computed specifier, the pass over "exports" of a package in NODE_PATH listed the folder of each key with a star and of each folder key, and counted those files toward the limit of candidates. With a "./*" key and more files than the limit, `require("pkg/icons/" + name)` then kept no module, where main lists the files of `icons/`, and the executable threw MODULE_NOT_FOUND. A package that node_modules and NODE_PATH both reach was counted twice. The compiler now tries only the keys that name one subpath, after the walk that main has. It lists no folder, so the limit counts what it counts on main. A file that a key with a star or a folder key has in another folder than the walk lists is not found yet. Two rows of test/aot/resolver/self-reference-and-node-path.test.ts record that. docs: the NODE_PATH sentence no longer compares with node_modules. test: the computed-specifier test asserts that stderr is empty. --- docs/runtime/module-resolution.mdx | 2 +- src/sema/resolve/code/computed.rs | 63 +++++------ src/sema/resolve/code/computed/tests.rs | 103 ++++++++++++------ .../self-reference-and-node-path.test.ts | 38 +++++-- 4 files changed, 129 insertions(+), 77 deletions(-) diff --git a/docs/runtime/module-resolution.mdx b/docs/runtime/module-resolution.mdx index c985a8b21998..44e40267d66b 100644 --- a/docs/runtime/module-resolution.mdx +++ b/docs/runtime/module-resolution.mdx @@ -268,7 +268,7 @@ 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 in a `NODE_PATH` directory first, as it does for a package in `node_modules`. +Bun reads the `exports` field of a package that it finds through `NODE_PATH`. ### Custom conditions diff --git a/src/sema/resolve/code/computed.rs b/src/sema/resolve/code/computed.rs index 01f1862a90d1..692e28e5e3d3 100644 --- a/src/sema/resolve/code/computed.rs +++ b/src/sema/resolve/code/computed.rs @@ -221,19 +221,14 @@ 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(manifest) = manifest - && let Some(exports) = &manifest.exports - { - self.without_charge(|search| { - search.add_exported_candidates(manifest, exports, name) - })?; + if let Some(exports) = manifest.and_then(|manifest| manifest.exports.as_ref()) { + self.try_exact_exported_keys(exports, name)?; } - // The plain lookup still finds a file that "exports" does not name. - self.walk_below(node_path, b"", SpecifierForm::Path)?; } Ok(()) } @@ -260,43 +255,43 @@ impl<'a, 'h> CandidateSearch<'a, 'h> { let Some(node_modules) = resolver.find_node_modules(ancestor) else { continue; }; - self.add_exporting_packages_by_start_of_name(&node_modules)?; + for name in self.list_installed_names(&node_modules) { + let manifest = resolver.code_manifest(&join_unrooted(&node_modules, &name)); + if let Some(manifest) = manifest + && let Some(exports) = &manifest.exports + { + self.try_specifier(&name)?; + self.add_exported_candidates(manifest, exports, &name)?; + } + } // It comes to the folder of each package, which is its name, and finds nothing in one that has `exports`. self.walk_below(&node_modules, b"", SpecifierForm::Path)?; } for node_path in &resolver.code_options().node_paths { - self.without_charge(|search| { - search.add_exporting_packages_by_start_of_name(node_path) - })?; 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(()) } - /// Runs `pass` and gives back what it took of the limit, because the walk that follows lists the same folders. - fn without_charge( + /// 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, - pass: impl FnOnce(&mut Self) -> Result<(), ComputedUnknown>, - ) -> Result<(), ComputedUnknown> { - let visited = (self.visited_files, self.visited_directories); - pass(self)?; - (self.visited_files, self.visited_directories) = visited; - Ok(()) - } - - fn add_exporting_packages_by_start_of_name( - &mut self, - folder: &[u8], + exports: &ExportsEntry<'h>, + name: &[u8], ) -> Result<(), ComputedUnknown> { - let resolver = self.resolver; - for name in self.list_installed_names(folder) { - let manifest = resolver.code_manifest(&join_unrooted(folder, &name)); - if let Some(manifest) = manifest - && let Some(exports) = &manifest.exports - { - self.try_specifier(&name)?; - self.add_exported_candidates(manifest, exports, &name)?; - } + 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 bf88175612de..cd28ba1bab5a 100644 --- a/src/sema/resolve/code/computed/tests.rs +++ b/src/sema/resolve/code/computed/tests.rs @@ -501,65 +501,106 @@ 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 exported_subpaths_of_packages_in_node_path_are_listed() { +fn exact_exported_subpaths_of_packages_in_node_path_are_listed() { let host = MemoryHost::new(&[ ("/p/main.ts", ""), ( "/global/pkg/package.json", - r#"{ "exports": { "./alias": "./lib/target.js" } }"#, + 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 with_node_path = CodeOptions { - node_paths: vec![b"/global".to_vec()], - ..CodeOptions::bun_run(b"/p") - }; - let candidates = |prefix, suffix| { - let pattern = ComputedPattern { - importer: "/p/main.ts", - prefix, - suffix, - kind: Import, - }; - resolve_computed_with(&host, &with_node_path, pattern) - }; + 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 exported_subpaths_in_node_path_take_nothing_of_the_limit_of_the_walk() { +fn exports_of_package_in_node_path_list_no_folder() { let host = MemoryHost::new(&[ ("/p/main.ts", ""), ( "/global/pkg/package.json", - r#"{ "exports": { "./*": "./*" } }"#, + 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"); - let pattern = ComputedPattern { - importer: "/p/main.ts", - prefix: "pkg/", - suffix: "", - kind: Import, - }; - // The walk alone visits four files, and so does the pass over "exports". - let options = CodeOptions { - node_paths: vec![b"/global".to_vec()], - max_computed_candidates: 5, - ..CodeOptions::bun_run(b"/p") - }; + // 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!( - resolve_computed_with(&host, &options, pattern), - "/global/pkg/a.js, /global/pkg/b.js, /global/pkg/c.js, /global/pkg/package.json" + node_path_candidates(&host, 8, "pk", "/alias"), + "/global/pka/target.js, /global/pkb/target.js, /global/pkc/target.js" ); } 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 32a1089c048a..fdec7d47df84 100644 --- a/test/aot/resolver/self-reference-and-node-path.test.ts +++ b/test/aot/resolver/self-reference-and-node-path.test.ts @@ -2,7 +2,8 @@ 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 { describe, test } from "../with-compiler"; +import { eachRow, gap } from "../supported"; +import { describe } from "../with-compiler"; import { packageFiles, testResolution, type Pin, type ResolutionCase } from "./resolution-cases"; type Answer = string | null | [onImport: string | null, onRequire: string | null]; @@ -165,15 +166,32 @@ const cases: ResolutionCase[] = [ describe("code resolution", () => testResolution(cases)); -test("a computed specifier finds what a package in NODE_PATH exports", async () => { +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" } }), - ...emptyFiles(["global/pkg/lib/target.js", "global/pkg/plain.js", "app/importer.js"]), + "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"; -const importer = ${JSON.stringify(join(root, "app", "importer.js"))}; -console.log(JSON.stringify(["pkg/al", "pk", "pkg/pl"].map(prefix => resolveComputedSpecifier(importer, prefix, prefix === "pk" ? "/alias" : ""))));`; +console.log(JSON.stringify(resolveComputedSpecifier(${request})));`; await using proc = Bun.spawn({ cmd: [bunExe(), "--no-install", "-e", script], cwd: root, @@ -182,10 +200,8 @@ console.log(JSON.stringify(["pkg/al", "pk", "pkg/pl"].map(prefix => resolveCompu stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ exitCode, stderr: exitCode === 0 ? "" : stderr }).toEqual({ exitCode: 0, stderr: "" }); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); - const found = (JSON.parse(stdout) as { candidates: string[] }[]).map(answer => - answer.candidates.map(path => relative(root, path).replaceAll(sep, "/")), - ); - expect(found).toEqual([["global/pkg/lib/target.js"], ["global/pkg/lib/target.js"], ["global/pkg/plain.js"]]); + const { candidates } = JSON.parse(stdout) as { candidates: string[] }; + expect(candidates.map(path => relative(root, path).replaceAll(sep, "/"))).toEqual(found); }); From b95d4a625904177e4316671e6a7247f18227ea64 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:48:11 +0000 Subject: [PATCH 3/3] test: run the three NODE_PATH "exports" tests of resolve.test.ts concurrently Each test has its own temporary directory, and the third has its own registry server. The title of the second test is shorter so that the declaration stays on one line. --- test/js/bun/resolve/resolve.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 7e2fe75415c8..e502f3299b65 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -430,7 +430,7 @@ describe("NODE_PATH test", () => { expect(stdout.toString().trim()).toBe("NODE_PATH from lib works"); }); - it("reads the exports of a package that it finds through NODE_PATH", async () => { + 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 }); @@ -587,7 +587,7 @@ describe("NODE_PATH test", () => { expect(exitCode).toBe(0); }); - it("finds the same file for a package in a NODE_PATH entry as for the package in node_modules", async () => { + 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: { @@ -673,7 +673,7 @@ describe("NODE_PATH test", () => { expect(exitCode).toBe(0); }); - it("loads a package that a NODE_PATH entry exports without a request to the registry", async () => { + 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,