Skip to content

Commit e96699e

Browse files
committed
module: implement the "module-sync" exports condition
This patch implements a "module-sync" exports condition for packages to supply a sycnrhonous ES module to the Node.js module loader, no matter it's being required or imported. This is similar to the "module" condition that bundlers have been using to support `require(esm)` in Node.js, and allows dual-package authors to opt into ESM-first only newer versions of Node.js that supports require(esm) while avoiding the dual-package hazard. ```json { "type": "module", "exports": { "node": { // On new version of Node.js, both require() and import get // the ESM version "module-sync": "./index.js", // On older version of Node.js, where "module" and // require(esm) are not supported, use the transpiled CJS version // to avoid dual-package hazard. Library authors can decide // to drop support for older versions of Node.js when they think // it's time. "default": "./dist/index.cjs" }, // On any other environment, use the ESM version. "default": "./index.js" } } ``` We end up implementing a condition with a different name instead of reusing "module", because existing code in the ecosystem using the "module" condition sometimes also expect the module resolution for these ESM files to work in CJS style, which is supported by bundlers, but the native Node.js loader has intentionally made ESM resolution different from CJS resolution (e.g. forbidding `import './noext'` or `import './directory'`), so it would be semver-major to implement a `"module"` condition without implementing the forbidden ESM resolution rules. For now, this just implments a new condition as semver-minor so it can be backported to older LTS. Refs: https://webpack.js.org/guides/package-exports/#target-environment-independent-packages
1 parent e35299a commit e96699e

34 files changed

+180
-9
lines changed

β€Ždoc/api/modules.md

+12-6
Original file line numberDiff line numberDiff line change
@@ -335,9 +335,12 @@ LOAD_PACKAGE_IMPORTS(X, DIR)
335335
1. Find the closest package scope SCOPE to DIR.
336336
2. If no scope was found, return.
337337
3. If the SCOPE/package.json "imports" is null or undefined, return.
338-
4. let MATCH = PACKAGE_IMPORTS_RESOLVE(X, pathToFileURL(SCOPE),
339-
["node", "require"]) <a href="esm.md#resolver-algorithm-specification">defined in the ESM resolver</a>.
340-
5. RESOLVE_ESM_MATCH(MATCH).
338+
4. If `--experimental-require-module` is enabled
339+
a. let CONDITIONS = ["node", "require", "module-sync"]
340+
b. Else, let CONDITIONS = ["node", "require"]
341+
5. let MATCH = PACKAGE_IMPORTS_RESOLVE(X, pathToFileURL(SCOPE),
342+
CONDITIONS) <a href="esm.md#resolver-algorithm-specification">defined in the ESM resolver</a>.
343+
6. RESOLVE_ESM_MATCH(MATCH).
341344
342345
LOAD_PACKAGE_EXPORTS(X, DIR)
343346
1. Try to interpret X as a combination of NAME and SUBPATH where the name
@@ -346,9 +349,12 @@ LOAD_PACKAGE_EXPORTS(X, DIR)
346349
return.
347350
3. Parse DIR/NAME/package.json, and look for "exports" field.
348351
4. If "exports" is null or undefined, return.
349-
5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH,
350-
`package.json` "exports", ["node", "require"]) <a href="esm.md#resolver-algorithm-specification">defined in the ESM resolver</a>.
351-
6. RESOLVE_ESM_MATCH(MATCH)
352+
5. If `--experimental-require-module` is enabled
353+
a. let CONDITIONS = ["node", "require", "module-sync"]
354+
b. Else, let CONDITIONS = ["node", "require"]
355+
6. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH,
356+
`package.json` "exports", CONDITIONS) <a href="esm.md#resolver-algorithm-specification">defined in the ESM resolver</a>.
357+
7. RESOLVE_ESM_MATCH(MATCH)
352358
353359
LOAD_PACKAGE_SELF(X, DIR)
354360
1. Find the closest package scope SCOPE to DIR.

β€Ždoc/api/packages.md

+17-2
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,10 @@ specific to least specific as conditions should be defined:
665665
formats include CommonJS, JSON, native addons, and ES modules
666666
if `--experimental-require-module` is enabled. _Always mutually
667667
exclusive with `"import"`._
668+
* `"module-sync"` - matches no matter the package is loaded via `import`,
669+
`import()` or `require()`. The format is expected to be ES modules that does
670+
not contain top-level await in its module graph - if it does,
671+
`ERR_REQUIRE_ASYNC_MODULE` will be thrown when the module is `require()`-ed.
668672
* `"default"` - the generic fallback that always matches. Can be a CommonJS
669673
or ES module file. _This condition should always come last._
670674

@@ -755,7 +759,7 @@ Any number of custom conditions can be set with repeat flags.
755759

756760
### Community Conditions Definitions
757761

758-
Condition strings other than the `"import"`, `"require"`, `"node"`,
762+
Condition strings other than the `"import"`, `"require"`, `"node"`, `"module-sync"`,
759763
`"node-addons"` and `"default"` conditions
760764
[implemented in Node.js core](#conditional-exports) are ignored by default.
761765

@@ -886,6 +890,17 @@ $ node other.js
886890

887891
## Dual CommonJS/ES module packages
888892

893+
<!-- This section should not be in the API documentation:
894+
895+
1. It teaches opinionated practices that some consider dangerous, see
896+
https://github.com/nodejs/node/issues/52174
897+
2. It will soon be obsolete when we unflag --experimental-require-module.
898+
3. It's difficult to understand a multi-file structure via long texts and snippets in
899+
a markdown document.
900+
901+
TODO(?): Move this section to its own repository with example folders.
902+
-->
903+
889904
Prior to the introduction of support for ES modules in Node.js, it was a common
890905
pattern for package authors to include both CommonJS and ES module JavaScript
891906
sources in their package, with `package.json` [`"main"`][] specifying the
@@ -898,7 +913,7 @@ ignores) the top-level `"module"` field.
898913
Node.js can now run ES module entry points, and a package can contain both
899914
CommonJS and ES module entry points (either via separate specifiers such as
900915
`'pkg'` and `'pkg/es-module'`, or both at the same specifier via [Conditional
901-
exports][]). Unlike in the scenario where `"module"` is only used by bundlers,
916+
exports][]). Unlike in the scenario where top-level `"module"` field is only used by bundlers,
902917
or ES module files are transpiled into CommonJS on the fly before evaluation by
903918
Node.js, the files referenced by the ES module entry point are evaluated as ES
904919
modules.

β€Žlib/internal/modules/esm/get_format.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ function underNodeModules(url) {
9191
let typelessPackageJsonFilesWarnedAbout;
9292
function warnTypelessPackageJsonFile(pjsonPath, url) {
9393
typelessPackageJsonFilesWarnedAbout ??= new SafeSet();
94-
if (!typelessPackageJsonFilesWarnedAbout.has(pjsonPath)) {
94+
if (!underNodeModules(url) && !typelessPackageJsonFilesWarnedAbout.has(pjsonPath)) {
9595
const warning = `Module type of ${url} is not specified and it doesn't parse as CommonJS.\n` +
9696
'Reparsing as ES module because module syntax was detected. This incurs a performance overhead.\n' +
9797
`To eliminate this warning, add "type": "module" to ${pjsonPath}.`;

β€Žlib/internal/modules/esm/utils.js

+3
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ function initializeDefaultConditions() {
8383
...userConditions,
8484
]);
8585
defaultConditionsSet = new SafeSet(defaultConditions);
86+
if (getOptionValue('--experimental-require-module')) {
87+
defaultConditionsSet.add('module-sync');
88+
}
8689
}
8790

8891
/**

β€Žlib/internal/modules/helpers.js

+3
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ function initializeCjsConditions() {
8181
...addonConditions,
8282
...userConditions,
8383
]);
84+
if (getOptionValue('--experimental-require-module')) {
85+
cjsConditions.add('module-sync');
86+
}
8487
}
8588

8689
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Flags: --experimental-require-module
2+
3+
import '../common/index.mjs';
4+
import assert from 'node:assert';
5+
import * as staticImport from '../fixtures/es-modules/module-condition/import.mjs';
6+
import { import as _import } from '../fixtures/es-modules/module-condition/dynamic_import.js';
7+
8+
async function dynamicImport(id) {
9+
const result = await _import(id);
10+
return result.resolved;
11+
}
12+
13+
assert.deepStrictEqual({ ...staticImport }, {
14+
import_module_require: 'import',
15+
module_and_import: 'module',
16+
module_and_require: 'module',
17+
module_import_require: 'module',
18+
module_only: 'module',
19+
module_require_import: 'module',
20+
require_module_import: 'module',
21+
});
22+
23+
assert.strictEqual(await dynamicImport('import-module-require'), 'import');
24+
assert.strictEqual(await dynamicImport('module-and-import'), 'module');
25+
assert.strictEqual(await dynamicImport('module-and-require'), 'module');
26+
assert.strictEqual(await dynamicImport('module-import-require'), 'module');
27+
assert.strictEqual(await dynamicImport('module-only'), 'module');
28+
assert.strictEqual(await dynamicImport('module-require-import'), 'module');
29+
assert.strictEqual(await dynamicImport('require-module-import'), 'module');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Flags: --experimental-require-module
2+
'use strict';
3+
4+
require('../common');
5+
const assert = require('assert');
6+
7+
const loader = require('../fixtures/es-modules/module-condition/require.cjs');
8+
9+
assert.strictEqual(loader.require('import-module-require').resolved, 'module');
10+
assert.strictEqual(loader.require('module-and-import').resolved, 'module');
11+
assert.strictEqual(loader.require('module-and-require').resolved, 'module');
12+
assert.strictEqual(loader.require('module-import-require').resolved, 'module');
13+
assert.strictEqual(loader.require('module-only').resolved, 'module');
14+
assert.strictEqual(loader.require('module-require-import').resolved, 'module');
15+
assert.strictEqual(loader.require('require-module-import').resolved, 'require');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
function load(id) {
2+
return import(id);
3+
}
4+
5+
export { load as import };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { resolved as import_module_require } from 'import-module-require';
2+
export { resolved as module_and_import } from 'module-and-import';
3+
export { resolved as module_and_require } from 'module-and-require';
4+
export { resolved as module_import_require } from 'module-import-require';
5+
export { resolved as module_only } from 'module-only';
6+
export { resolved as module_require_import } from 'module-require-import';
7+
export { resolved as require_module_import } from 'require-module-import';

β€Žtest/fixtures/es-modules/module-condition/node_modules/import-module-require/import.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/import-module-require/module.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/import-module-require/package.json

+11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/import-module-require/require.cjs

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-and-import/import.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-and-import/module.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-and-import/package.json

+10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-and-require/module.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-and-require/package.json

+10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-and-require/require.cjs

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-import-require/import.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-import-require/module.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-import-require/package.json

+11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-import-require/require.cjs

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-only/module.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-only/package.json

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-require-import/import.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-require-import/module.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-require-import/package.json

+11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/module-require-import/require.cjs

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/require-module-import/import.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/require-module-import/module.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/require-module-import/package.json

+11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/es-modules/module-condition/node_modules/require-module-import/require.cjs

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
exports.require = require;

0 commit comments

Comments
Β (0)