From d0e606930acb701790c286340226b1e3e7060b4f Mon Sep 17 00:00:00 2001 From: Akshit Khokhani <67451993+akshitkhokhani@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:49:43 -0500 Subject: [PATCH] fix(javascript): honor module-sync package exports (#1361) --- docs/javascript-artifact-reconstruction.md | 2 ++ .../JavaScriptArtifactPathResolution.ts | 4 ++- .../javascriptPackageExportTargets.test.ts | 21 ++++++++++++ .../javascriptPackagePrecedence.test.ts | 33 ++++++++++++++++++- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 3a996e5f9..68c65ca75 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -307,6 +307,8 @@ such as `./web3:app.js` still names a local artifact. Package `exports` fallback arrays are supported both at the top level and under the root `"."` entry. The resolver selects targets in declared order using the same conditional and invalid-entry handling as nested exports arrays. +For imports and requires, the active Node conditions include `module-sync`; +earlier active conditions retain precedence over later ones. ESM relative module paths and selected package exports targets use URL suffix handling and one percent-decoding pass. CommonJS relative paths and legacy package main diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index c28cd45c5..f87cc2455 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -640,7 +640,8 @@ const exportTargets = ( * Node selects an exports target by walking the declared keys in order and * taking the first whose condition is active for the calling resolver. * "default" is always active, and "node" plus "node-addons" are active for the - * built-in resolver that owns installed-package imports and requires. + * built-in resolver that owns installed-package imports and requires. The + * supported Node runtimes also activate "module-sync" for both loaders. */ const packageExportConditions = ( moduleKind: ResolveArtifactPathInput["moduleKind"], @@ -648,6 +649,7 @@ const packageExportConditions = ( new Set([ "node", "node-addons", + "module-sync", ...(moduleKind === undefined ? [] : [moduleKind]), "default", ]); diff --git a/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts b/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts index 4d22c8a7a..aa8596cb6 100644 --- a/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts +++ b/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts @@ -103,6 +103,27 @@ const cases: readonly { exports: { browser: "./../outside.cjs", default: "./actual.cjs" }, expected: "node_modules/fixture/actual.cjs", }, + { + name: "active module-sync rejects an invalid target instead of default", + exports: { "module-sync": "./../outside.cjs", default: "./actual.cjs" }, + expected: null, + nodeError: "ERR_INVALID_PACKAGE_TARGET", + rejectedTarget: "./../outside.cjs", + }, + { + name: "module-sync null blocks default", + exports: { "module-sync": null, default: "./actual.cjs" }, + expected: null, + nodeError: "ERR_PACKAGE_PATH_NOT_EXPORTED", + }, + { + name: "unmatched nested module-sync permits default", + exports: { + "module-sync": { browser: "./missing.cjs" }, + default: "./actual.cjs", + }, + expected: "node_modules/fixture/actual.cjs", + }, ...["./bad%.cjs", "./bad%C3.cjs"].map((target) => ({ name: `malformed URL ${target} is selected before URL decoding`, exports: [target, "./actual.cjs"], diff --git a/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts b/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts index 4c730fb73..8bc2511e1 100644 --- a/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts +++ b/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts @@ -202,6 +202,36 @@ it.each([ exports: { import: "./exported.mjs", require: "./actual.cjs" }, }, }, + { + name: "import selects module-sync before default", + kind: "import", + metadata: { + exports: { "module-sync": "./sync.mjs", default: "./actual.cjs" }, + }, + }, + { + name: "require selects nested module-sync before default", + kind: "require", + metadata: { + exports: { + ".": { node: { "module-sync": "./sync.mjs", default: "./actual.cjs" } }, + }, + }, + }, + { + name: "an earlier import condition precedes module-sync", + kind: "import", + metadata: { + exports: { import: "./exported.mjs", "module-sync": "./sync.mjs" }, + }, + }, + { + name: "an earlier default condition precedes module-sync", + kind: "require", + metadata: { + exports: { default: "./actual.cjs", "module-sync": "./sync.mjs" }, + }, + }, ] as const)( "matches the native Node loader when $name", async ({ kind, metadata }) => { @@ -210,7 +240,7 @@ it.each([ const source = kind === "import" ? `import value from "fixture"; console.log(JSON.stringify({value, resolved: import.meta.resolve("fixture")}));` - : `const value = require("fixture"); console.log(JSON.stringify({value, resolved: require.resolve("fixture")}));`; + : `const value = require("fixture"); console.log(JSON.stringify({value: typeof value === "string" ? value : value.default, resolved: require.resolve("fixture")}));`; const files = { "package.json": JSON.stringify({ name: "app", @@ -226,6 +256,7 @@ it.each([ "node_modules/fixture/index.js": "module.exports = 'index.js';", "node_modules/fixture/bundler.mjs": "export default 'bundler.mjs';", "node_modules/fixture/exported.mjs": "export default 'exported.mjs';", + "node_modules/fixture/sync.mjs": "export default 'sync.mjs';", }; await Promise.all( Object.entries(files).map(async ([path, text]) => {