@@ -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
7474use 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
124126impl < 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