Repository: vercel/nft (javascript)

Issue:
Support for "module-sync" exports condition
Hello, we are currently using `@vercel/nft` to collect dependencies for our application. Thanks the capabilities it provides, which have saved us a significant amount of server resources and deployment time."

But recently we encountered a problem. When a package uses `module-sync` exports, some unexpected behaviors occur.

When using higher versions of Node 22, the exports of `module-sync` are used at runtime. However, the collected dependencies are `require`, `node` or `default`. These exported files may not be consistent with `module-sync`, so running with the collected dependencies might lead to issues where files cannot be found.

---

I would like to know if `@vercel/nft` will support the `module-sync` exports condition. If there are plans, I can try to provide a PR. 

However, what I am currently unsure about is whether we should consider the situation where the build and deployment are on different Node versions. For example, the build uses Node 22 while the deployment uses Node 20. Although this is not a correct practice, it indeed exists widely. If we need to consider this difference, perhaps a configuration option should be provided, or there might already be an existing option that I am unaware of but can adapt to this scenario.

Please correct me if I have any misunderstandings.






Discussion:
We currently have thousands of projects depending on it, but most projects are not using Node 22 at runtime, so the issue has not been widely exposed yet. I am concerned that at some point it might have a significant impact. If possible, please provide me with a conclusion as soon as you can, so I can decide on the subsequent actions.

Thanks again for all the support~
The [Node.js documentation](https://nodejs.org/docs/latest-v24.x/api/cli.html#:~:text=The%20default%20Node.js%20conditions%20of%20%22node%22%2C%20%22default%22%2C%20%22import%22%2C%20and%20%22require%22%20will%20always%20apply%20as%20defined.) doesn't mention `module-sync` as a default condition so I don't think nft should default to it.

However, the caller can assign custom conditions here: https://github.com/vercel/nft/blob/main/readme.md#exports--imports
Awesome! Thank you for responding so quickly~
I tried this configuration, and it can make `module-sync` work as my needs.

btw, I saw some introduce in other Node.js documents, it seems to be a default condition.
- Node 22 release blog: https://nodejs.org/en/blog/release/v22.10.0#2024-10-16-version-22100-current-aduh95
- Node Packages API: https://nodejs.org/api/packages.html#conditional-exports

Correct me if I have any misunderstandings. Thank you for responding again.
Since its only supported by Node.js 22 or newer, I don't think it should be default behavior since we support back to 18.

https://github.com/vercel/nft/blob/95141d5b4b165d982ec9b6a876180f667e95e072/package.json#L144-L146
Oh interesting that we always include default/require/import so maybe we should add module-sync 🤔 

https://github.com/vercel/nft/blob/95141d5b4b165d982ec9b6a876180f667e95e072/src/resolve-dependency.ts#L167-L172

I'm trying to think if there is other impact of adding this that might not be backwards compatible. Should it always be included like `default`?
We had same issue recently with Node 22 with only one package and it seems `module-sync` option is not wildly used.
@styfle Any concerns about adding that condition? It seems very safe to do and it there is concerns about older versions we can probably check `process.versions.node` to gate it only for node >= 22
Sounds good. Feel free to make a PR with a test and I'll take a look 👍 

Patch:
diff --git a/src/resolve-dependency.ts b/src/resolve-dependency.ts
--- a/src/resolve-dependency.ts
+++ b/src/resolve-dependency.ts
@@ -1,6 +1,7 @@
 import { isAbsolute, resolve, sep } from 'path';
 import { builtinModules } from 'module';
 import { Job } from './node-file-trace';
+import { getNodeMajorVersion } from './utils/node-version';
 
 // node resolver
 // custom implementation to emit only needed package.json files for resolver
@@ -168,6 +169,7 @@ function getExportsTarget(
         condition === 'default' ||
         (condition === 'require' && cjsResolve) ||
         (condition === 'import' && !cjsResolve) ||
+        (condition === 'module-sync' && getNodeMajorVersion() >= 22) ||
         conditions.includes(condition)
       ) {
         const target = getExportsTarget(
diff --git a/src/utils/node-version.ts b/src/utils/node-version.ts
new file mode 100644
--- /dev/null
+++ b/src/utils/node-version.ts
@@ -0,0 +1,7 @@
+/**
+ * Gets the major version of the current Node.js runtime
+ * @returns The major version number (e.g., 22 for Node.js v22.16.0)
+ */
+export function getNodeMajorVersion(): number {
+  return parseInt(process.versions.node.split('.')[0], 10);
+}


Test patch:
diff --git a/test/unit.test.js b/test/unit.test.js
--- a/test/unit.test.js
+++ b/test/unit.test.js
@@ -25,6 +25,8 @@ const skipOnWindows = [
   'require-symlink',
 ];
 const skipOnMac = [];
+const skipOnNode20AndBelow = ['module-sync-condition-es'];
+const skipOnNode22AndAbove = ['module-sync-condition-es-node20'];
 if (process.platform === 'darwin' && process.arch === 'arm64') {
   skipOnMac.push('microtime-node-gyp');
 }
@@ -68,6 +70,7 @@ afterEach(resetFileIOMocks);
 
 for (const { testName, isRoot } of unitTests) {
   const testSuffix = `${testName} from ${isRoot ? 'root' : 'cwd'}`;
+  const nodeVersion = parseInt(process.versions.node.split('.')[0], 10);
   if (
     process.platform === 'win32' &&
     (isRoot || skipOnWindows.includes(testName))
@@ -79,6 +82,14 @@ for (const { testName, isRoot } of unitTests) {
     console.log(`Skipping unit test on macOS: ${testSuffix}`);
     continue;
   }
+  if (nodeVersion < 22 && skipOnNode20AndBelow.includes(testName)) {
+    console.log(`Skipping unit test on Node.js 20 or below: ${testSuffix}`);
+    continue;
+  }
+  if (nodeVersion >= 22 && skipOnNode22AndAbove.includes(testName)) {
+    console.log(`Skipping unit test on Node.js 22 or above: ${testSuffix}`);
+    continue;
+  }
   const unitPath = join(__dirname, 'unit', testName);
 
   it(`should correctly trace ${testSuffix}`, async () => {
diff --git a/test/unit/module-sync-condition-cjs/fallback.js b/test/unit/module-sync-condition-cjs/fallback.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-cjs/fallback.js
@@ -0,0 +1 @@
+export const test = 'fallback version'; 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-cjs/import.js b/test/unit/module-sync-condition-cjs/import.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-cjs/import.js
@@ -0,0 +1 @@
+export const test = 'import version'; 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-cjs/input.js b/test/unit/module-sync-condition-cjs/input.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-cjs/input.js
@@ -0,0 +1,2 @@
+import { test } from 'test-pkg-sync-es';
+console.log(test); 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-cjs/module-sync.js b/test/unit/module-sync-condition-cjs/module-sync.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-cjs/module-sync.js
@@ -0,0 +1 @@
+export const test = 'module-sync version'; 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-cjs/output.js b/test/unit/module-sync-condition-cjs/output.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-cjs/output.js
@@ -0,0 +1,4 @@
+[
+  "test/unit/module-sync-condition-cjs/input.js",
+  "test/unit/module-sync-condition-cjs/package.json"
+] 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-cjs/package.json b/test/unit/module-sync-condition-cjs/package.json
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-cjs/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "test-pkg-sync-cjs",
+  "type": "commonjs", 
+  "exports": {
+    ".": {
+      "module-sync": "./module-sync.js",
+      "import": "./import.js",
+      "default": "./fallback.js"
+    }
+  }
+} 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es-node20/fallback.js b/test/unit/module-sync-condition-es-node20/fallback.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es-node20/fallback.js
@@ -0,0 +1 @@
+export const test = 'fallback version'; 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es-node20/import.js b/test/unit/module-sync-condition-es-node20/import.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es-node20/import.js
@@ -0,0 +1 @@
+export const test = 'import version'; 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es-node20/input.js b/test/unit/module-sync-condition-es-node20/input.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es-node20/input.js
@@ -0,0 +1,2 @@
+import { test } from 'test-pkg-sync-es';
+console.log(test); 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es-node20/module-sync.js b/test/unit/module-sync-condition-es-node20/module-sync.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es-node20/module-sync.js
@@ -0,0 +1 @@
+export const test = 'module-sync version'; 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es-node20/output.js b/test/unit/module-sync-condition-es-node20/output.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es-node20/output.js
@@ -0,0 +1,5 @@
+[
+  "test/unit/module-sync-condition-es-node20/import.js",
+  "test/unit/module-sync-condition-es-node20/input.js",
+  "test/unit/module-sync-condition-es-node20/package.json"
+] 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es-node20/package.json b/test/unit/module-sync-condition-es-node20/package.json
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es-node20/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "test-pkg-sync-es",
+  "type": "module", 
+  "exports": {
+    ".": {
+      "module-sync": "./module-sync.js",
+      "import": "./import.js",
+      "default": "./fallback.js"
+    }
+  }
+} 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es/fallback.js b/test/unit/module-sync-condition-es/fallback.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es/fallback.js
@@ -0,0 +1 @@
+export const test = 'fallback version'; 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es/import.js b/test/unit/module-sync-condition-es/import.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es/import.js
@@ -0,0 +1 @@
+export const test = 'import version'; 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es/input.js b/test/unit/module-sync-condition-es/input.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es/input.js
@@ -0,0 +1,2 @@
+import { test } from 'test-pkg-sync-es';
+console.log(test); 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es/module-sync.js b/test/unit/module-sync-condition-es/module-sync.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es/module-sync.js
@@ -0,0 +1 @@
+export const test = 'module-sync version'; 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es/output.js b/test/unit/module-sync-condition-es/output.js
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es/output.js
@@ -0,0 +1,5 @@
+[
+  "test/unit/module-sync-condition-es/input.js",
+  "test/unit/module-sync-condition-es/module-sync.js",
+  "test/unit/module-sync-condition-es/package.json"
+] 
\ No newline at end of file
diff --git a/test/unit/module-sync-condition-es/package.json b/test/unit/module-sync-condition-es/package.json
new file mode 100644
--- /dev/null
+++ b/test/unit/module-sync-condition-es/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "test-pkg-sync-es",
+  "type": "module", 
+  "exports": {
+    ".": {
+      "module-sync": "./module-sync.js",
+      "import": "./import.js",
+      "default": "./fallback.js"
+    }
+  }
+} 
\ No newline at end of file