Skip to content

Commit 0e6217d

Browse files
authored
feat: support NODE_PATH (#185)
1 parent d78d070 commit 0e6217d

File tree

8 files changed

+428
-79
lines changed

8 files changed

+428
-79
lines changed

napi/index.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ export interface NapiResolveOptions {
194194
* Default `false`
195195
*/
196196
enablePnp?: boolean;
197+
/**
198+
* Whether to enable `NODE_PATH` support
199+
*
200+
* Default `false`
201+
*/
202+
nodePath?: boolean;
197203
}
198204

199205
export interface ResolveResult {

napi/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ impl ResolverFactory {
215215
symlinks: op.symlinks.unwrap_or(default.symlinks),
216216
builtin_modules: op.builtin_modules.unwrap_or(default.builtin_modules),
217217
enable_pnp: op.enable_pnp.unwrap_or_default(),
218+
node_path: op.node_path.unwrap_or_default(),
218219
}
219220
}
220221
}

napi/src/options.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@ pub struct NapiResolveOptions {
157157
///
158158
/// Default `false`
159159
pub enable_pnp: Option<bool>,
160+
161+
/// Whether to enable `NODE_PATH` support
162+
///
163+
/// Default `false`
164+
pub node_path: Option<bool>,
160165
}
161166

162167
#[napi]

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"prettier": "prettier --check .",
1414
"prettier:ci": "prettier --list-different .",
1515
"format": "prettier --write .",
16-
"prepare": "husky"
16+
"prepare": "husky",
17+
"setup:fixtures": "cd fixtures/pnp && yarn && cd ../pnp-global-cache-enabled && yarn"
1718
},
1819
"devDependencies": {
1920
"@actions/core": "^3.0.0",

src/lib.rs

Lines changed: 171 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ use std::{
6868
ffi::OsStr,
6969
fmt,
7070
path::{Component, Path, PathBuf},
71-
sync::Arc,
71+
sync::{Arc, OnceLock},
7272
};
7373

7474
use dashmap::DashSet;
@@ -119,6 +119,8 @@ pub struct ResolverGeneric<Fs> {
119119
/// Paths that have been searched and confirmed to have no `.pnp.cjs` reachable by filesystem walk.
120120
#[cfg(feature = "yarn_pnp")]
121121
pnp_no_manifest_cache: Arc<DashSet<CachedPath>>,
122+
/// Lazily parsed directories from `NODE_PATH` env var.
123+
node_path_dirs: OnceLock<Vec<PathBuf>>,
122124
}
123125

124126
impl<Fs> fmt::Debug for ResolverGeneric<Fs> {
@@ -142,6 +144,7 @@ impl<Fs: Send + Sync + FileSystem + Default> ResolverGeneric<Fs> {
142144
pnp_manifest: Arc::new(arc_swap::ArcSwapOption::empty()),
143145
#[cfg(feature = "yarn_pnp")]
144146
pnp_no_manifest_cache: Arc::new(DashSet::new()),
147+
node_path_dirs: OnceLock::new(),
145148
}
146149
}
147150
}
@@ -155,6 +158,7 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
155158
pnp_manifest: Arc::new(arc_swap::ArcSwapOption::empty()),
156159
#[cfg(feature = "yarn_pnp")]
157160
pnp_no_manifest_cache: Arc::new(DashSet::new()),
161+
node_path_dirs: OnceLock::new(),
158162
}
159163
}
160164

@@ -168,6 +172,7 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
168172
pnp_manifest: Arc::clone(&self.pnp_manifest),
169173
#[cfg(feature = "yarn_pnp")]
170174
pnp_no_manifest_cache: Arc::clone(&self.pnp_no_manifest_cache),
175+
node_path_dirs: self.node_path_dirs.clone(),
171176
}
172177
}
173178

@@ -550,7 +555,11 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
550555
if let Some(path) = self.load_node_modules(cached_path, specifier, ctx).await? {
551556
return Ok(path);
552557
}
553-
// 7. THROW "not found"
558+
// 7. Search NODE_PATH directories as fallback
559+
if let Some(path) = self.load_node_path(specifier, ctx).await? {
560+
return Ok(path);
561+
}
562+
// 8. THROW "not found"
554563
Err(ResolveError::NotFound(specifier.to_string()))
555564
}
556565

@@ -835,50 +844,112 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
835844
else {
836845
continue;
837846
};
838-
// Optimize node_modules lookup by inspecting whether the package exists
839-
// From LOAD_PACKAGE_EXPORTS(X, DIR)
840-
// 1. Try to interpret X as a combination of NAME and SUBPATH where the name
841-
// may have a @scope/ prefix and the subpath begins with a slash (`/`).
842-
if !package_name.is_empty() {
843-
let package_path = cached_path.path().normalize_with(package_name);
844-
let cached_path = self.cache.value(&package_path);
845-
// Try foo/node_modules/package_name
846-
if cached_path.is_dir(&self.cache.fs, ctx).await {
847-
// a. LOAD_PACKAGE_EXPORTS(X, DIR)
848-
if let Some(path) = self
849-
.load_package_exports(specifier, subpath, &cached_path, ctx)
850-
.await?
851-
{
852-
return Ok(Some(path));
853-
}
854-
} else {
855-
// foo/node_modules/package_name is not a directory, so useless to check inside it
856-
if !subpath.is_empty() {
857-
continue;
858-
}
859-
// Skip if the directory lead to the scope package does not exist
860-
// i.e. `foo/node_modules/@scope` is not a directory for `foo/node_modules/@scope/package`
861-
if package_name.starts_with('@') {
862-
if let Some(path) = cached_path.parent() {
863-
if !path.is_dir(&self.cache.fs, ctx).await {
864-
continue;
865-
}
866-
}
867-
}
868-
}
847+
if let Some(path) = self
848+
.resolve_in_module_dir(&cached_path, specifier, package_name, subpath, ctx)
849+
.await?
850+
{
851+
return Ok(Some(path));
869852
}
853+
}
854+
}
855+
Ok(None)
856+
}
870857

871-
// Try as file or directory for all other cases
872-
// b. LOAD_AS_FILE(DIR/X)
873-
// c. LOAD_AS_DIRECTORY(DIR/X)
874-
let node_module_file = cached_path.path().normalize_with(specifier);
875-
let cached_path = self.cache.value(&node_module_file);
858+
/// Try resolving a specifier within a single module directory (e.g. a node_modules dir or a NODE_PATH dir).
859+
async fn resolve_in_module_dir(
860+
&self,
861+
cached_path: &CachedPath,
862+
specifier: &str,
863+
package_name: &str,
864+
subpath: &str,
865+
ctx: &mut Ctx,
866+
) -> ResolveResult {
867+
// Optimize lookup by inspecting whether the package exists
868+
// From LOAD_PACKAGE_EXPORTS(X, DIR)
869+
// 1. Try to interpret X as a combination of NAME and SUBPATH where the name
870+
// may have a @scope/ prefix and the subpath begins with a slash (`/`).
871+
if !package_name.is_empty() {
872+
let package_path = cached_path.path().normalize_with(package_name);
873+
let pkg_cached = self.cache.value(&package_path);
874+
if pkg_cached.is_dir(&self.cache.fs, ctx).await {
875+
// a. LOAD_PACKAGE_EXPORTS(X, DIR)
876876
if let Some(path) = self
877-
.load_as_file_or_directory(&cached_path, specifier, ctx)
877+
.load_package_exports(specifier, subpath, &pkg_cached, ctx)
878878
.await?
879879
{
880880
return Ok(Some(path));
881881
}
882+
} else {
883+
// package_name is not a directory, so useless to check inside it
884+
if !subpath.is_empty() {
885+
return Ok(None);
886+
}
887+
// Skip if the directory leading to the scope package does not exist
888+
// i.e. `@scope` is not a directory for `@scope/package`
889+
if package_name.starts_with('@') {
890+
if let Some(path) = pkg_cached.parent() {
891+
if !path.is_dir(&self.cache.fs, ctx).await {
892+
return Ok(None);
893+
}
894+
}
895+
}
896+
}
897+
}
898+
899+
// Try as file or directory for all other cases
900+
// b. LOAD_AS_FILE(DIR/X)
901+
// c. LOAD_AS_DIRECTORY(DIR/X)
902+
let node_module_file = cached_path.path().normalize_with(specifier);
903+
let file_cached = self.cache.value(&node_module_file);
904+
self
905+
.load_as_file_or_directory(&file_cached, specifier, ctx)
906+
.await
907+
}
908+
909+
/// Parse `NODE_PATH` env var into directory list. Uses `;` on Windows, `:` on POSIX.
910+
fn parse_node_path_env() -> Vec<PathBuf> {
911+
std::env::var_os("NODE_PATH")
912+
.map(|path| {
913+
std::env::split_paths(&path)
914+
.filter(|paths| paths.is_absolute())
915+
.collect::<Vec<PathBuf>>()
916+
})
917+
.unwrap_or_default()
918+
}
919+
920+
/// Lazily get NODE_PATH directories. Parsed once on first access.
921+
fn node_path_dirs(&self) -> &[PathBuf] {
922+
if !self.options.node_path {
923+
return &[];
924+
}
925+
self.node_path_dirs.get_or_init(Self::parse_node_path_env)
926+
}
927+
928+
#[cfg(test)]
929+
fn with_node_path_dirs(self, dirs: Vec<PathBuf>) -> Self {
930+
let _ = self.node_path_dirs.set(dirs);
931+
self
932+
}
933+
934+
/// Search NODE_PATH directories for a bare specifier.
935+
/// NODE_PATH dirs are absolute paths searched directly (no parent directory walking).
936+
#[cfg_attr(feature="enable_instrument", tracing::instrument(level=tracing::Level::DEBUG, skip_all, fields(specifier = specifier)))]
937+
async fn load_node_path(&self, specifier: &str, ctx: &mut Ctx) -> ResolveResult {
938+
let dirs = self.node_path_dirs();
939+
if dirs.is_empty() {
940+
return Ok(None);
941+
}
942+
let (package_name, subpath) = Self::parse_package_specifier(specifier);
943+
for dir in dirs {
944+
let cached_path = self.cache.value(dir);
945+
if !cached_path.is_dir(&self.cache.fs, ctx).await {
946+
continue;
947+
}
948+
if let Some(path) = self
949+
.resolve_in_module_dir(&cached_path, specifier, package_name, subpath, ctx)
950+
.await?
951+
{
952+
return Ok(Some(path));
882953
}
883954
}
884955
Ok(None)
@@ -1527,52 +1598,74 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
15271598
else {
15281599
continue;
15291600
};
1530-
// 2. Set parentURL to the parent folder URL of parentURL.
1531-
let package_path = cached_path.path().normalize_with(package_name);
1532-
let cached_path = self.cache.value(&package_path);
1533-
// 3. If the folder at packageURL does not exist, then
1534-
// 1. Continue the next loop iteration.
1535-
if cached_path.is_dir(&self.cache.fs, ctx).await {
1536-
// 4. Let pjson be the result of READ_PACKAGE_JSON(packageURL).
1537-
if let Some(package_json) = cached_path
1538-
.package_json(&self.cache.fs, &self.options, ctx)
1539-
.await?
1540-
{
1541-
// 5. If pjson is not null and pjson.exports is not null or undefined, then
1542-
// 1. Return the result of PACKAGE_EXPORTS_RESOLVE(packageURL, packageSubpath, pjson.exports, defaultConditions).
1543-
for exports in package_json.exports_fields(&self.options.exports_fields) {
1544-
if let Some(path) = self
1545-
.package_exports_resolve(cached_path.path(), &format!(".{subpath}"), exports, ctx)
1546-
.await?
1547-
{
1548-
return Ok(Some(path));
1549-
}
1550-
}
1551-
// 6. Otherwise, if packageSubpath is equal to ".", then
1552-
if subpath == "." {
1553-
// 1. If pjson.main is a string, then
1554-
for main_field in package_json.main_fields(&self.options.main_fields) {
1555-
// 1. Return the URL resolution of main in packageURL.
1556-
let path = cached_path.path().normalize_with(main_field);
1557-
let cached_path = self.cache.value(&path);
1558-
if cached_path.is_file(&self.cache.fs, ctx).await
1559-
&& self.check_restrictions(cached_path.path())
1560-
{
1561-
return Ok(Some(cached_path.clone()));
1562-
}
1563-
}
1564-
}
1565-
}
1566-
let subpath = format!(".{subpath}");
1567-
ctx.with_fully_specified(false);
1568-
return self.require(&cached_path, &subpath, ctx).await.map(Some);
1601+
if let Some(path) = self
1602+
.package_resolve_in_dir(&cached_path, package_name, subpath, ctx)
1603+
.await?
1604+
{
1605+
return Ok(Some(path));
15691606
}
15701607
}
15711608
}
15721609

1610+
// Fallback: search NODE_PATH directories
1611+
for dir in self.node_path_dirs() {
1612+
let cached_path = self.cache.value(dir);
1613+
if !cached_path.is_dir(&self.cache.fs, ctx).await {
1614+
continue;
1615+
}
1616+
if let Some(path) = self
1617+
.package_resolve_in_dir(&cached_path, package_name, subpath, ctx)
1618+
.await?
1619+
{
1620+
return Ok(Some(path));
1621+
}
1622+
}
1623+
15731624
Err(ResolveError::NotFound(specifier.to_string()))
15741625
}
15751626

1627+
/// Try ESM package resolution within a single module directory.
1628+
async fn package_resolve_in_dir(
1629+
&self,
1630+
cached_path: &CachedPath,
1631+
package_name: &str,
1632+
subpath: &str,
1633+
ctx: &mut Ctx,
1634+
) -> ResolveResult {
1635+
let package_path = cached_path.path().normalize_with(package_name);
1636+
let cached_path = self.cache.value(&package_path);
1637+
if !cached_path.is_dir(&self.cache.fs, ctx).await {
1638+
return Ok(None);
1639+
}
1640+
if let Some(package_json) = cached_path
1641+
.package_json(&self.cache.fs, &self.options, ctx)
1642+
.await?
1643+
{
1644+
for exports in package_json.exports_fields(&self.options.exports_fields) {
1645+
if let Some(path) = self
1646+
.package_exports_resolve(cached_path.path(), &format!(".{subpath}"), exports, ctx)
1647+
.await?
1648+
{
1649+
return Ok(Some(path));
1650+
}
1651+
}
1652+
if subpath == "." {
1653+
for main_field in package_json.main_fields(&self.options.main_fields) {
1654+
let path = cached_path.path().normalize_with(main_field);
1655+
let main_cached = self.cache.value(&path);
1656+
if main_cached.is_file(&self.cache.fs, ctx).await
1657+
&& self.check_restrictions(main_cached.path())
1658+
{
1659+
return Ok(Some(main_cached.clone()));
1660+
}
1661+
}
1662+
}
1663+
}
1664+
let subpath = format!(".{subpath}");
1665+
ctx.with_fully_specified(false);
1666+
self.require(&cached_path, &subpath, ctx).await.map(Some)
1667+
}
1668+
15761669
/// PACKAGE_EXPORTS_RESOLVE(packageURL, subpath, exports, conditions)
15771670
fn package_exports_resolve<'a>(
15781671
&'a self,

0 commit comments

Comments
 (0)