Repository: oxc-project/oxc-resolver (rust)

Issue:
Support non-standard "use exports field for directory resolution" behavior
Vite currently uses `exports` field when resolving directory specifiers.
For example, when `import './dir'` is written, Vite uses `exports` field of `./dir/package.json` if exists.

Node.js does not use `exports` field in this case (https://github.com/nodejs/node/issues/58827). While Vite probably should not have supported this, there are some code that relies on this behavior ([nuxt](https://github.com/nuxt/nuxt/blob/a7d21ece19e69703aac5fbb1f7bf53f0dfc5751f/test/setup-env.ts#L3), [Vitest](https://github.com/vitest-dev/vitest/blob/176133ed0cc5c8d04d0faa2e58d4a4f9cf3b78a6/test/core/test/resolve-web.test.ts#L4-L5)). We prefer to not break these if possible.

To achieve that, it would be nice if oxc-resolver adds an option that opt-in to this behavior.

related: https://github.com/vitejs/vite/pull/20252


Pull request description:
feat(resolver): allow `exports` field in `require('../directory')`
closes #571

This is not part of the spec but some vite projects rely on this behavior.

Opt-in by `allowPackageExportsInDirectoryResolve: true`.

See
* https://github.com/vitejs/vite/pull/20252
* https://github.com/nodejs/node/issues/58827

Patch:
diff --git a/napi/index.d.ts b/napi/index.d.ts
index d9bb42c..2bfe509 100644
--- a/napi/index.d.ts
+++ b/napi/index.d.ts
@@ -191,6 +191,17 @@ export interface NapiResolveOptions {
    * Default `false`
    */
   moduleType?: boolean
+  /**
+   * Allow `exports` field in `require('../directory')`.
+   *
+   * This is not part of the spec but some vite projects rely on this behavior.
+   * See
+   * * <https://github.com/vitejs/vite/pull/20252>
+   * * <https://github.com/nodejs/node/issues/58827>
+   *
+   * Default: `false`
+   */
+  allowPackageExportsInDirectoryResolve?: boolean
 }
 
 export interface ResolveResult {
diff --git a/napi/src/lib.rs b/napi/src/lib.rs
index 3d7b56b..9ecb6eb 100644
--- a/napi/src/lib.rs
+++ b/napi/src/lib.rs
@@ -252,6 +252,9 @@ impl ResolverFactory {
             module_type: op.module_type.unwrap_or(default.module_type),
             #[cfg(feature = "yarn_pnp")]
             pnp_manifest: default.pnp_manifest,
+            allow_package_exports_in_directory_resolve: op
+                .allow_package_exports_in_directory_resolve
+                .unwrap_or(default.allow_package_exports_in_directory_resolve),
         }
     }
 }
diff --git a/napi/src/options.rs b/napi/src/options.rs
index 64107c2..541a714 100644
--- a/napi/src/options.rs
+++ b/napi/src/options.rs
@@ -149,6 +149,16 @@ pub struct NapiResolveOptions {
     ///
     /// Default `false`
     pub module_type: Option<bool>,
+
+    /// Allow `exports` field in `require('../directory')`.
+    ///
+    /// This is not part of the spec but some vite projects rely on this behavior.
+    /// See
+    /// * <https://github.com/vitejs/vite/pull/20252>
+    /// * <https://github.com/nodejs/node/issues/58827>
+    ///
+    /// Default: `false`
+    pub allow_package_exports_in_directory_resolve: Option<bool>,
 }
 
 #[napi]
diff --git a/src/lib.rs b/src/lib.rs
index 9c9ca52..c4886e6 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -617,6 +617,21 @@ impl<C: Cache> ResolverGeneric<C> {
             }
             // f. LOAD_INDEX(X) DEPRECATED
             // g. THROW "not found"
+
+            // Allow `exports` field in `require('../directory')`.
+            // This is not part of the spec but some vite projects rely on this behavior.
+            // See
+            // * <https://github.com/vitejs/vite/pull/20252>
+            // * <https://github.com/nodejs/node/issues/58827>
+            if self.options.allow_package_exports_in_directory_resolve {
+                for exports in package_json.exports_fields(&self.options.exports_fields) {
+                    if let Some(path) =
+                        self.package_exports_resolve(cached_path, ".", &exports, ctx)?
+                    {
+                        return Ok(Some(path));
+                    }
+                }
+            }
         }
         // 2. LOAD_INDEX(X)
         self.load_index(cached_path, ctx)
diff --git a/src/options.rs b/src/options.rs
index 317d7f4..b2b972a 100644
--- a/src/options.rs
+++ b/src/options.rs
@@ -162,6 +162,16 @@ pub struct ResolveOptions {
     ///
     /// Default: `false`
     pub module_type: bool,
+
+    /// Allow `exports` field in `require('../directory')`.
+    ///
+    /// This is not part of the spec but some vite projects rely on this behavior.
+    /// See
+    /// * <https://github.com/vitejs/vite/pull/20252>
+    /// * <https://github.com/nodejs/node/issues/58827>
+    ///
+    /// Default: `false`
+    pub allow_package_exports_in_directory_resolve: bool,
 }
 
 impl ResolveOptions {
@@ -484,6 +494,7 @@ impl Default for ResolveOptions {
             symlinks: true,
             builtin_modules: false,
             module_type: false,
+            allow_package_exports_in_directory_resolve: false,
         }
     }
 }
@@ -554,6 +565,13 @@ impl fmt::Display for ResolveOptions {
         if self.builtin_modules {
             write!(f, "builtin_modules:{:?},", self.builtin_modules)?;
         }
+        if self.allow_package_exports_in_directory_resolve {
+            write!(
+                f,
+                "allow_package_exports_in_directory_resolve:{:?},",
+                self.allow_package_exports_in_directory_resolve
+            )?;
+        }
         Ok(())
     }
 }
@@ -604,10 +622,11 @@ mod test {
             restrictions: vec![Restriction::Path(PathBuf::from("restrictions"))],
             roots: vec![PathBuf::from("roots")],
             builtin_modules: true,
+            allow_package_exports_in_directory_resolve: true,
             ..ResolveOptions::default()
         };
 
-        let expected = r#"tsconfig:TsconfigOptions { config_file: "tsconfig.json", references: Auto },alias:[("a", [Ignore])],alias_fields:[["browser"]],condition_names:["require"],enforce_extension:Enabled,exports_fields:[["exports"]],imports_fields:[["imports"]],extension_alias:[(".js", [".ts"])],extensions:[".js", ".json", ".node"],fallback:[("fallback", [Ignore])],fully_specified:true,main_fields:["main"],main_files:["index"],modules:["node_modules"],resolve_to_context:true,prefer_relative:true,prefer_absolute:true,restrictions:[Path("restrictions")],roots:["roots"],symlinks:true,builtin_modules:true,"#;
+        let expected = r#"tsconfig:TsconfigOptions { config_file: "tsconfig.json", references: Auto },alias:[("a", [Ignore])],alias_fields:[["browser"]],condition_names:["require"],enforce_extension:Enabled,exports_fields:[["exports"]],imports_fields:[["imports"]],extension_alias:[(".js", [".ts"])],extensions:[".js", ".json", ".node"],fallback:[("fallback", [Ignore])],fully_specified:true,main_fields:["main"],main_files:["index"],modules:["node_modules"],resolve_to_context:true,prefer_relative:true,prefer_absolute:true,restrictions:[Path("restrictions")],roots:["roots"],symlinks:true,builtin_modules:true,allow_package_exports_in_directory_resolve:true,"#;
         assert_eq!(format!("{options}"), expected);
 
         let options = ResolveOptions {
@@ -635,6 +654,7 @@ mod test {
             symlinks: false,
             tsconfig: None,
             module_type: false,
+            allow_package_exports_in_directory_resolve: false,
         };
 
         assert_eq!(format!("{options}"), "");


Test patch:
diff --git a/src/tests/exports_field.rs b/src/tests/exports_field.rs
index eceef3f..fed4440 100644
--- a/src/tests/exports_field.rs
+++ b/src/tests/exports_field.rs
@@ -287,6 +287,18 @@ fn extension_alias_throw_error() {
     }
 }
 
+#[test]
+fn directory() {
+    let f = super::fixture();
+    let resolver = Resolver::new(ResolveOptions {
+        allow_package_exports_in_directory_resolve: true,
+        ..ResolveOptions::default()
+    });
+    let resolution = resolver.resolve(f.join("foo"), "../exports-field");
+    let path = resolution.unwrap().full_path();
+    assert_eq!(path, f.join("exports-field").join("a.js"));
+}
+
 // Small script for generating the test cases from enhanced-resolve
 // for (c of testCases) {
 //  console.log("TestCase {")