Skip to content

Commit

Permalink
module: implement the "module-sync" exports condition
Browse files Browse the repository at this point in the history
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
PR-URL: #54648
Fixes: #52173
Refs: https://github.com/joyeecheung/test-module-condition
Refs: #52697
Reviewed-By: Jacob Smith <[email protected]>
Reviewed-By: Jan Krems <[email protected]>
Reviewed-By: Chengzhong Wu <[email protected]>
  • Loading branch information
joyeecheung authored Sep 25, 2024
1 parent 773e7c6 commit 0b9249e
Show file tree
Hide file tree
Showing 34 changed files with 180 additions and 9 deletions.
18 changes: 12 additions & 6 deletions doc/api/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,12 @@ LOAD_PACKAGE_IMPORTS(X, DIR)
1. Find the closest package scope SCOPE to DIR.
2. If no scope was found, return.
3. If the SCOPE/package.json "imports" is null or undefined, return.
4. let MATCH = PACKAGE_IMPORTS_RESOLVE(X, pathToFileURL(SCOPE),
["node", "require"]) <a href="esm.md#resolver-algorithm-specification">defined in the ESM resolver</a>.
5. RESOLVE_ESM_MATCH(MATCH).
4. If `--experimental-require-module` is enabled
a. let CONDITIONS = ["node", "require", "module-sync"]
b. Else, let CONDITIONS = ["node", "require"]
5. let MATCH = PACKAGE_IMPORTS_RESOLVE(X, pathToFileURL(SCOPE),
CONDITIONS) <a href="esm.md#resolver-algorithm-specification">defined in the ESM resolver</a>.
6. RESOLVE_ESM_MATCH(MATCH).
LOAD_PACKAGE_EXPORTS(X, DIR)
1. Try to interpret X as a combination of NAME and SUBPATH where the name
Expand All @@ -346,9 +349,12 @@ LOAD_PACKAGE_EXPORTS(X, DIR)
return.
3. Parse DIR/NAME/package.json, and look for "exports" field.
4. If "exports" is null or undefined, return.
5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH,
`package.json` "exports", ["node", "require"]) <a href="esm.md#resolver-algorithm-specification">defined in the ESM resolver</a>.
6. RESOLVE_ESM_MATCH(MATCH)
5. If `--experimental-require-module` is enabled
a. let CONDITIONS = ["node", "require", "module-sync"]
b. Else, let CONDITIONS = ["node", "require"]
6. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH,
`package.json` "exports", CONDITIONS) <a href="esm.md#resolver-algorithm-specification">defined in the ESM resolver</a>.
7. RESOLVE_ESM_MATCH(MATCH)
LOAD_PACKAGE_SELF(X, DIR)
1. Find the closest package scope SCOPE to DIR.
Expand Down
19 changes: 17 additions & 2 deletions doc/api/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,10 @@ specific to least specific as conditions should be defined:
formats include CommonJS, JSON, native addons, and ES modules
if `--experimental-require-module` is enabled. _Always mutually
exclusive with `"import"`._
* `"module-sync"` - matches no matter the package is loaded via `import`,
`import()` or `require()`. The format is expected to be ES modules that does
not contain top-level await in its module graph - if it does,
`ERR_REQUIRE_ASYNC_MODULE` will be thrown when the module is `require()`-ed.
* `"default"` - the generic fallback that always matches. Can be a CommonJS
or ES module file. _This condition should always come last._

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

### Community Conditions Definitions

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

Expand Down Expand Up @@ -886,6 +890,17 @@ $ node other.js

## Dual CommonJS/ES module packages

<!-- This section should not be in the API documentation:
1. It teaches opinionated practices that some consider dangerous, see
https://github.com/nodejs/node/issues/52174
2. It will soon be obsolete when we unflag --experimental-require-module.
3. It's difficult to understand a multi-file structure via long texts and snippets in
a markdown document.
TODO(?): Move this section to its own repository with example folders.
-->

Prior to the introduction of support for ES modules in Node.js, it was a common
pattern for package authors to include both CommonJS and ES module JavaScript
sources in their package, with `package.json` [`"main"`][] specifying the
Expand All @@ -898,7 +913,7 @@ ignores) the top-level `"module"` field.
Node.js can now run ES module entry points, and a package can contain both
CommonJS and ES module entry points (either via separate specifiers such as
`'pkg'` and `'pkg/es-module'`, or both at the same specifier via [Conditional
exports][]). Unlike in the scenario where `"module"` is only used by bundlers,
exports][]). Unlike in the scenario where top-level `"module"` field is only used by bundlers,
or ES module files are transpiled into CommonJS on the fly before evaluation by
Node.js, the files referenced by the ES module entry point are evaluated as ES
modules.
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/modules/esm/get_format.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ function underNodeModules(url) {
let typelessPackageJsonFilesWarnedAbout;
function warnTypelessPackageJsonFile(pjsonPath, url) {
typelessPackageJsonFilesWarnedAbout ??= new SafeSet();
if (!typelessPackageJsonFilesWarnedAbout.has(pjsonPath)) {
if (!underNodeModules(url) && !typelessPackageJsonFilesWarnedAbout.has(pjsonPath)) {
const warning = `Module type of ${url} is not specified and it doesn't parse as CommonJS.\n` +
'Reparsing as ES module because module syntax was detected. This incurs a performance overhead.\n' +
`To eliminate this warning, add "type": "module" to ${pjsonPath}.`;
Expand Down
3 changes: 3 additions & 0 deletions lib/internal/modules/esm/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ function initializeDefaultConditions() {
...userConditions,
]);
defaultConditionsSet = new SafeSet(defaultConditions);
if (getOptionValue('--experimental-require-module')) {
defaultConditionsSet.add('module-sync');
}
}

/**
Expand Down
3 changes: 3 additions & 0 deletions lib/internal/modules/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ function initializeCjsConditions() {
...addonConditions,
...userConditions,
]);
if (getOptionValue('--experimental-require-module')) {
cjsConditions.add('module-sync');
}
}

/**
Expand Down
29 changes: 29 additions & 0 deletions test/es-module/test-import-module-conditional-exports-module.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Flags: --experimental-require-module

import '../common/index.mjs';
import assert from 'node:assert';
import * as staticImport from '../fixtures/es-modules/module-condition/import.mjs';
import { import as _import } from '../fixtures/es-modules/module-condition/dynamic_import.js';

async function dynamicImport(id) {
const result = await _import(id);
return result.resolved;
}

assert.deepStrictEqual({ ...staticImport }, {
import_module_require: 'import',
module_and_import: 'module',
module_and_require: 'module',
module_import_require: 'module',
module_only: 'module',
module_require_import: 'module',
require_module_import: 'module',
});

assert.strictEqual(await dynamicImport('import-module-require'), 'import');
assert.strictEqual(await dynamicImport('module-and-import'), 'module');
assert.strictEqual(await dynamicImport('module-and-require'), 'module');
assert.strictEqual(await dynamicImport('module-import-require'), 'module');
assert.strictEqual(await dynamicImport('module-only'), 'module');
assert.strictEqual(await dynamicImport('module-require-import'), 'module');
assert.strictEqual(await dynamicImport('require-module-import'), 'module');
15 changes: 15 additions & 0 deletions test/es-module/test-require-module-conditional-exports-module.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Flags: --experimental-require-module
'use strict';

require('../common');
const assert = require('assert');

const loader = require('../fixtures/es-modules/module-condition/require.cjs');

assert.strictEqual(loader.require('import-module-require').resolved, 'module');
assert.strictEqual(loader.require('module-and-import').resolved, 'module');
assert.strictEqual(loader.require('module-and-require').resolved, 'module');
assert.strictEqual(loader.require('module-import-require').resolved, 'module');
assert.strictEqual(loader.require('module-only').resolved, 'module');
assert.strictEqual(loader.require('module-require-import').resolved, 'module');
assert.strictEqual(loader.require('require-module-import').resolved, 'require');
5 changes: 5 additions & 0 deletions test/fixtures/es-modules/module-condition/dynamic_import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
function load(id) {
return import(id);
}

export { load as import };
7 changes: 7 additions & 0 deletions test/fixtures/es-modules/module-condition/import.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { resolved as import_module_require } from 'import-module-require';
export { resolved as module_and_import } from 'module-and-import';
export { resolved as module_and_require } from 'module-and-require';
export { resolved as module_import_require } from 'module-import-require';
export { resolved as module_only } from 'module-only';
export { resolved as module_require_import } from 'module-require-import';
export { resolved as require_module_import } from 'require-module-import';

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/fixtures/es-modules/module-condition/require.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
exports.require = require;

0 comments on commit 0b9249e

Please sign in to comment.