module: support 'module.exports' interop export in require(esm)

PR-URL: https://github.com/nodejs/node/pull/54563
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Joyee Cheung <joyeec9h3@gmail.com>
This commit is contained in:
Guy Bedford 2024-07-14 13:00:49 -07:00
parent 2755551c3c
commit d24c7313f7
54 changed files with 308 additions and 6 deletions

View File

@ -175,6 +175,9 @@ changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/55085
description: require() now supports loading synchronous ES modules by default.
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/54563
description: Support `'module.exports'` interop export in `require(esm)`.
-->
The `.mjs` extension is reserved for [ECMAScript Modules][].
@ -204,10 +207,9 @@ export function distance(a, b) { return (b.x - a.x) ** 2 + (b.y - a.y) ** 2; }
```mjs
// point.mjs
class Point {
export default class Point {
constructor(x, y) { this.x = x; this.y = y; }
}
export default Point;
```
A CommonJS module can load them with `require()`:
@ -236,6 +238,66 @@ This property is experimental and can change in the future. It should only be us
by tools converting ES modules into CommonJS modules, following existing ecosystem
conventions. Code authored directly in CommonJS should avoid depending on it.
When a ES Module contains both named exports and a default export, the result returned by `require()`
is the module namespace object, which places the default export in the `.default` property, similar to
the results returned by `import()`.
To customize what should be returned by `require(esm)` directly, the ES Module can export the
desired value using the string name `"module.exports"`.
<!-- eslint-disable @stylistic/js/semi -->
```mjs
// point.mjs
export default class Point {
constructor(x, y) { this.x = x; this.y = y; }
}
// `distance` is lost to CommonJS consumers of this module, unless it's
// added to `Point` as a static property.
export function distance(a, b) { return (b.x - a.x) ** 2 + (b.y - a.y) ** 2; }
export { Point as 'module.exports' }
```
<!-- eslint-disable node-core/no-duplicate-requires -->
```cjs
const Point = require('./point.mjs');
console.log(Point); // [class Point]
// Named exports are lost when 'module.exports' is used
const { distance } = require('./point.mjs');
console.log(distance); // undefined
```
Notice in the example above, when the `module.exports` export name is used, named exports
will be lost to CommonJS consumers. To allow CommonJS consumers to continue accessing
named exports, the module can make sure that the default export is an object with the
named exports attached to it as properties. For example with the example above,
`distance` can be attached to the default export, the `Point` class, as a static method.
<!-- eslint-disable @stylistic/js/semi -->
```mjs
export function distance(a, b) { return (b.x - a.x) ** 2 + (b.y - a.y) ** 2; }
export default class Point {
constructor(x, y) { this.x = x; this.y = y; }
static distance = distance;
}
export { Point as 'module.exports' }
```
<!-- eslint-disable node-core/no-duplicate-requires -->
```cjs
const Point = require('./point.mjs');
console.log(Point); // [class Point]
const { distance } = require('./point.mjs');
console.log(distance); // [Function: distance]
```
If the module being `require()`'d contains top-level `await`, or the module
graph it `import`s contains top-level `await`,
[`ERR_REQUIRE_ASYNC_MODULE`][] will be thrown. In this case, users should

View File

@ -1427,10 +1427,13 @@ function loadESMFromCJS(mod, filename) {
// createRequiredModuleFacade() to `wrap` which is a ModuleWrap wrapping
// over the original module.
// We don't do this to modules that don't have default exports to avoid
// the unnecessary overhead. If __esModule is already defined, we will
// also skip the extension to allow users to override it.
if (!ObjectHasOwn(namespace, 'default') || ObjectHasOwn(namespace, '__esModule')) {
// We don't do this to modules that are marked as CJS ESM or that
// don't have default exports to avoid the unnecessary overhead.
// If __esModule is already defined, we will also skip the extension
// to allow users to override it.
if (ObjectHasOwn(namespace, 'module.exports')) {
mod.exports = namespace['module.exports'];
} else if (!ObjectHasOwn(namespace, 'default') || ObjectHasOwn(namespace, '__esModule')) {
mod.exports = namespace;
} else {
mod.exports = createRequiredModuleFacade(wrap);

View File

@ -0,0 +1,51 @@
// Flags: --experimental-require-module
import '../common/index.mjs';
import assert from 'assert';
import { directRequireFixture, importFixture } from '../fixtures/pkgexports.mjs';
const tests = {
'false': false,
'string': 'cjs',
'object': { a: 'cjs a', b: 'cjs b' },
'fauxesmdefault': { default: 'faux esm default' },
'fauxesmmixed': { default: 'faux esm default', a: 'faux esm a', b: 'faux esm b' },
'fauxesmnamed': { a: 'faux esm a', b: 'faux esm b' }
};
// This test demonstrates interop between CJS and CJS represented as ESM
// under the new `export { ... as 'module.exports'}` pattern, for the above cases.
for (const [test, exactShape] of Object.entries(tests)) {
// Each case represents a CJS dependency, which has the expected shape in CJS:
assert.deepStrictEqual(directRequireFixture(`interop-cjsdep-${test}`), exactShape);
// Each dependency is reexported through CJS as if it is a library being consumed,
// which in CJS is fully shape-preserving:
assert.deepStrictEqual(directRequireFixture(`interop-cjs/${test}`), exactShape);
// Now we have ESM conversions of these dependencies, using `export ... as "module.exports"`
// staring with the conversion of those dependencies into ESM under require(esm):
assert.deepStrictEqual(directRequireFixture(`interop-cjsdep-${test}-esm`), exactShape);
// When importing these ESM conversions, from require(esm), we should preserve the shape:
assert.deepStrictEqual(directRequireFixture(`interop-cjs/${test}-esm`), exactShape);
// Now if the importer itself is converted into ESM, it should still be able to load the original
// CJS and reexport it, preserving the shape:
assert.deepStrictEqual(directRequireFixture(`interop-cjs-esm/${test}`), exactShape);
// And then if we have the converted CJS to ESM importing from converted CJS to ESM,
// that should also work:
assert.deepStrictEqual(directRequireFixture(`interop-cjs-esm/${test}-esm`), exactShape);
// Finally, the CJS ESM representation under `import()` should match all these cases equivalently,
// where the CJS module is exported as the default export:
const esmCjsImport = await importFixture(`interop-cjsdep-${test}`);
assert.deepStrictEqual(esmCjsImport.default, exactShape);
assert.deepStrictEqual((await importFixture(`interop-cjsdep-${test}`)).default, exactShape);
assert.deepStrictEqual((await importFixture(`interop-cjs/${test}`)).default, exactShape);
assert.deepStrictEqual((await importFixture(`interop-cjsdep-${test}-esm`)).default, exactShape);
assert.deepStrictEqual((await importFixture(`interop-cjs/${test}-esm`)).default, exactShape);
assert.deepStrictEqual((await importFixture(`interop-cjs-esm/${test}`)).default, exactShape);
assert.deepStrictEqual((await importFixture(`interop-cjs-esm/${test}-esm`)).default, exactShape);
}

View File

@ -0,0 +1,3 @@
import dep from 'interop-cjsdep-false-esm';
export default dep;
export { dep as 'module.exports' }

3
test/fixtures/node_modules/interop-cjs-esm/false.js generated vendored Normal file
View File

@ -0,0 +1,3 @@
import dep from 'interop-cjsdep-false';
export default dep;
export { dep as 'module.exports' }

View File

@ -0,0 +1,3 @@
import dep from 'interop-cjsdep-fauxesmdefault-esm';
export default dep;
export { dep as 'module.exports' }

View File

@ -0,0 +1,3 @@
import dep from 'interop-cjsdep-fauxesmdefault';
export default dep;
export { dep as 'module.exports' }

View File

@ -0,0 +1,4 @@
import dep from 'interop-cjsdep-fauxesmmixed-esm';
export default dep;
export { dep as 'module.exports' }

View File

@ -0,0 +1,3 @@
import dep from 'interop-cjsdep-fauxesmmixed';
export default dep;
export { dep as 'module.exports' }

View File

@ -0,0 +1,3 @@
import dep from 'interop-cjsdep-fauxesmnamed-esm';
export default dep;
export { dep as 'module.exports' }

View File

@ -0,0 +1,3 @@
import dep from 'interop-cjsdep-fauxesmnamed';
export default dep;
export { dep as 'module.exports' }

View File

@ -0,0 +1,3 @@
import dep from 'interop-cjsdep-object-esm';
export default dep;
export { dep as 'module.exports' }

3
test/fixtures/node_modules/interop-cjs-esm/object.js generated vendored Normal file
View File

@ -0,0 +1,3 @@
import dep from 'interop-cjsdep-object';
export default dep;
export { dep as 'module.exports' }

View File

@ -0,0 +1,17 @@
{
"type": "module",
"exports": {
"./false-esm": "./false-esm.js",
"./false": "./false.js",
"./fauxesmdefault-esm": "./fauxesmdefault-esm.js",
"./fauxesmdefault": "./fauxesmdefault.js",
"./fauxesmmixed-esm": "./fauxesmmixed-esm.js",
"./fauxesmmixed": "./fauxesmmixed.js",
"./fauxesmnamed-esm": "./fauxesmnamed-esm.js",
"./fauxesmnamed": "./fauxesmnamed.js",
"./object-esm": "./object-esm.js",
"./object": "./object.js",
"./string-esm": "./string-esm.js",
"./string": "./string.js"
}
}

View File

@ -0,0 +1,3 @@
import dep from 'interop-cjsdep-string-esm';
export default dep;
export { dep as 'module.exports' }

3
test/fixtures/node_modules/interop-cjs-esm/string.js generated vendored Normal file
View File

@ -0,0 +1,3 @@
import dep from 'interop-cjsdep-string';
export default dep;
export { dep as 'module.exports' }

1
test/fixtures/node_modules/interop-cjs/false-esm.js generated vendored Normal file
View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-false-esm');

1
test/fixtures/node_modules/interop-cjs/false.js generated vendored Normal file
View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-false');

View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-fauxesmdefault-esm');

View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-fauxesmdefault');

View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-fauxesmmixed-esm');

View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-fauxesmmixed');

View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-fauxesmnamed-esm');

View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-fauxesmnamed');

1
test/fixtures/node_modules/interop-cjs/object-esm.js generated vendored Normal file
View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-object-esm');

1
test/fixtures/node_modules/interop-cjs/object.js generated vendored Normal file
View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-object');

17
test/fixtures/node_modules/interop-cjs/package.json generated vendored Normal file
View File

@ -0,0 +1,17 @@
{
"type": "commonjs",
"exports": {
"./false-esm": "./false-esm.js",
"./false": "./false.js",
"./fauxesmdefault-esm": "./fauxesmdefault-esm.js",
"./fauxesmdefault": "./fauxesmdefault.js",
"./fauxesmmixed-esm": "./fauxesmmixed-esm.js",
"./fauxesmmixed": "./fauxesmmixed.js",
"./fauxesmnamed-esm": "./fauxesmnamed-esm.js",
"./fauxesmnamed": "./fauxesmnamed.js",
"./object-esm": "./object-esm.js",
"./object": "./object.js",
"./string-esm": "./string-esm.js",
"./string": "./string.js"
}
}

1
test/fixtures/node_modules/interop-cjs/string-esm.js generated vendored Normal file
View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-string-esm');

1
test/fixtures/node_modules/interop-cjs/string.js generated vendored Normal file
View File

@ -0,0 +1 @@
module.exports = require('interop-cjsdep-string');

View File

@ -0,0 +1,3 @@
const output = false;
export default output;
export { output as 'module.exports' }

View File

@ -0,0 +1,4 @@
{
"type": "module",
"exports": "./dep.js"
}

View File

@ -0,0 +1 @@
module.exports = false;

View File

@ -0,0 +1,4 @@
{
"type": "commonjs",
"exports": "./dep.js"
}

View File

@ -0,0 +1,7 @@
const exports = {};
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = 'faux esm default';
export default exports;
export { exports as 'module.exports' }

View File

@ -0,0 +1,4 @@
{
"type": "module",
"exports": "./dep.js"
}

View File

@ -0,0 +1,2 @@
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = 'faux esm default';

View File

@ -0,0 +1,4 @@
{
"type": "commonjs",
"exports": "./dep.js"
}

View File

@ -0,0 +1,9 @@
const exports = {};
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = 'faux esm default';
exports.a = 'faux esm a';
exports.b = 'faux esm b';
export default exports;
export { exports as 'module.exports' }

View File

@ -0,0 +1,4 @@
{
"type": "module",
"exports": "./dep.js"
}

View File

@ -0,0 +1,4 @@
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = 'faux esm default';
exports.a = 'faux esm a';
exports.b = 'faux esm b';

View File

@ -0,0 +1,4 @@
{
"type": "commonjs",
"exports": "./dep.js"
}

View File

@ -0,0 +1,8 @@
const exports = {};
Object.defineProperty(exports, '__esModule', { value: true });
exports.a = 'faux esm a';
exports.b = 'faux esm b';
export default exports;
export { exports as 'module.exports' }

View File

@ -0,0 +1,4 @@
{
"type": "module",
"exports": "./dep.js"
}

View File

@ -0,0 +1,3 @@
Object.defineProperty(exports, '__esModule', { value: true });
exports.a = 'faux esm a';
exports.b = 'faux esm b';

View File

@ -0,0 +1,4 @@
{
"type": "commonjs",
"exports": "./dep.js"
}

View File

@ -0,0 +1,8 @@
const output = {
a: 'cjs a',
b: 'cjs b'
};
export default output;
export { output as 'module.exports' }

View File

@ -0,0 +1,4 @@
{
"type": "module",
"exports": "./dep.js"
}

View File

@ -0,0 +1,2 @@
exports.a = 'cjs a';
exports.b = 'cjs b';

View File

@ -0,0 +1,4 @@
{
"type": "commonjs",
"exports": "./dep.js"
}

View File

@ -0,0 +1,3 @@
const output = 'cjs';
export default output;
export { output as 'module.exports' }

View File

@ -0,0 +1,4 @@
{
"type": "module",
"exports": "./dep.js"
}

View File

@ -0,0 +1 @@
module.exports = 'cjs';

View File

@ -0,0 +1,4 @@
{
"type": "commonjs",
"exports": "./dep.js"
}

View File

@ -3,6 +3,10 @@ import { createRequire } from 'module';
const rawRequire = createRequire(fileURLToPath(import.meta.url));
export function directRequireFixture(specifier) {
return rawRequire(specifier);
}
export async function requireFixture(specifier) {
return { default: rawRequire(specifier ) };
}