Skip to content

Commit 88c1393

Browse files
guybedfordjoyeecheung
authored andcommitted
esm: export 'module.exports' on ESM CJS wrapper
PR-URL: nodejs#53848 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
1 parent 2b0f5e3 commit 88c1393

File tree

5 files changed

+87
-47
lines changed

5 files changed

+87
-47
lines changed

doc/api/esm.md

+32-9
Original file line numberDiff line numberDiff line change
@@ -456,18 +456,39 @@ See [Loading ECMAScript modules using `require()`][] for details.
456456
457457
### CommonJS Namespaces
458458
459+
<!-- YAML
460+
added: v14.13.0
461+
changes:
462+
- version: REPLACEME
463+
pr-url: https://github.com/nodejs/node/pull/53848
464+
description: Added `'module.exports'` export marker to CJS namespaces.
465+
-->
466+
459467
CommonJS modules consist of a `module.exports` object which can be of any type.
460468
469+
To support this, when importing CommonJS from an ECMAScript module, a namespace
470+
wrapper for the CommonJS module is constructed, which always provides a
471+
`default` export key pointing to the CommonJS `module.exports` value.
472+
473+
In addition, a heuristic static analysis is performed against the source text of
474+
the CommonJS module to get a best-effort static list of exports to provide on
475+
the namespace from values on `module.exports`. This is necessary since these
476+
namespaces must be constructed prior to the evaluation of the CJS module.
477+
478+
These CommonJS namespace objects also provide the `default` export as a
479+
`'module.exports'` named export, in order to unambiguously indicate that their
480+
representation in CommonJS uses this value, and not the namespace value. This
481+
mirrors the semantics of the handling of the `'module.exports'` export name in
482+
[`require(esm)`][] interop support.
483+
461484
When importing a CommonJS module, it can be reliably imported using the ES
462485
module default import or its corresponding sugar syntax:
463486
464487
<!-- eslint-disable no-duplicate-imports -->
465488
466489
```js
467490
import { default as cjs } from 'cjs';
468-
469-
// The following import statement is "syntax sugar" (equivalent but sweeter)
470-
// for `{ default as cjsSugar }` in the above import statement:
491+
// Identical to the above
471492
import cjsSugar from 'cjs';
472493

473494
console.log(cjs);
@@ -477,10 +498,6 @@ console.log(cjs === cjsSugar);
477498
// true
478499
```
479500
480-
The ECMAScript Module Namespace representation of a CommonJS module is always
481-
a namespace with a `default` export key pointing to the CommonJS
482-
`module.exports` value.
483-
484501
This Module Namespace Exotic Object can be directly observed either when using
485502
`import * as m from 'cjs'` or a dynamic import:
486503
@@ -491,7 +508,7 @@ import * as m from 'cjs';
491508
console.log(m);
492509
console.log(m === await import('cjs'));
493510
// Prints:
494-
// [Module] { default: <module.exports> }
511+
// [Module] { default: <module.exports>, 'module.exports': <module.exports> }
495512
// true
496513
```
497514
@@ -522,7 +539,12 @@ console.log(cjs);
522539

523540
import * as m from './cjs.cjs';
524541
console.log(m);
525-
// Prints: [Module] { default: { name: 'exported' }, name: 'exported' }
542+
// Prints:
543+
// [Module] {
544+
// default: { name: 'exported' },
545+
// 'module.exports': { name: 'exported' },
546+
// name: 'exported'
547+
// }
526548
```
527549
528550
As can be seen from the last example of the Module Namespace Exotic Object being
@@ -1158,6 +1180,7 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][].
11581180
[`package.json`]: packages.md#nodejs-packagejson-field-definitions
11591181
[`path.dirname()`]: path.md#pathdirnamepath
11601182
[`process.dlopen`]: process.md#processdlopenmodule-filename-flags
1183+
[`require(esm)`]: modules.md#loading-ecmascript-modules-using-require
11611184
[`url.fileURLToPath()`]: url.md#urlfileurltopathurl-options
11621185
[cjs-module-lexer]: https://github.com/nodejs/cjs-module-lexer/tree/1.2.2
11631186
[commonjs-extension-resolution-loader]: https://github.com/nodejs/loaders-test/tree/main/commonjs-extension-resolution-loader

lib/internal/modules/esm/translators.js

+9-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const {
44
ArrayPrototypeMap,
5+
ArrayPrototypePush,
56
Boolean,
67
JSONParse,
78
ObjectPrototypeHasOwnProperty,
@@ -227,14 +228,17 @@ function createCJSModuleWrap(url, source, isMain, loadCJS = loadCJSModule) {
227228

228229
const { exportNames, module } = cjsPreparseModuleExports(filename, source);
229230
cjsCache.set(url, module);
230-
const namesWithDefault = exportNames.has('default') ?
231-
[...exportNames] : ['default', ...exportNames];
231+
232+
const wrapperNames = [...exportNames, 'module.exports'];
233+
if (!exportNames.has('default')) {
234+
ArrayPrototypePush(wrapperNames, 'default');
235+
}
232236

233237
if (isMain) {
234238
setOwnProperty(process, 'mainModule', module);
235239
}
236240

237-
return new ModuleWrap(url, undefined, namesWithDefault, function() {
241+
return new ModuleWrap(url, undefined, wrapperNames, function() {
238242
debug(`Loading CJSModule ${url}`);
239243

240244
if (!module.loaded) {
@@ -249,8 +253,7 @@ function createCJSModuleWrap(url, source, isMain, loadCJS = loadCJSModule) {
249253
({ exports } = module);
250254
}
251255
for (const exportName of exportNames) {
252-
if (!ObjectPrototypeHasOwnProperty(exports, exportName) ||
253-
exportName === 'default') {
256+
if (!ObjectPrototypeHasOwnProperty(exports, exportName) || exportName === 'default') {
254257
continue;
255258
}
256259
// We might trigger a getter -> dont fail.
@@ -263,6 +266,7 @@ function createCJSModuleWrap(url, source, isMain, loadCJS = loadCJSModule) {
263266
this.setExport(exportName, value);
264267
}
265268
this.setExport('default', exports);
269+
this.setExport('module.exports', exports);
266270
}, module);
267271
}
268272

test/es-module/test-esm-exports.mjs

+30-23
Original file line numberDiff line numberDiff line change
@@ -8,50 +8,53 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js';
88
[requireFixture, importFixture].forEach((loadFixture) => {
99
const isRequire = loadFixture === requireFixture;
1010

11+
const maybeWrapped = isRequire ? (exports) => exports :
12+
(exports) => ({ ...exports, 'module.exports': exports.default });
13+
1114
const validSpecifiers = new Map([
1215
// A simple mapping of a path.
13-
['pkgexports/valid-cjs', { default: 'asdf' }],
16+
['pkgexports/valid-cjs', maybeWrapped({ default: 'asdf' })],
1417
// A mapping pointing to a file that needs special encoding (%20) in URLs.
15-
['pkgexports/space', { default: 'encoded path' }],
18+
['pkgexports/space', maybeWrapped({ default: 'encoded path' })],
1619
// Verifying that normal packages still work with exports turned on.
1720
isRequire ? ['baz/index', { default: 'eye catcher' }] : [null],
1821
// Fallbacks
19-
['pkgexports/fallbackdir/asdf.js', { default: 'asdf' }],
20-
['pkgexports/fallbackfile', { default: 'asdf' }],
22+
['pkgexports/fallbackdir/asdf.js', maybeWrapped({ default: 'asdf' })],
23+
['pkgexports/fallbackfile', maybeWrapped({ default: 'asdf' })],
2124
// Conditional split for require
2225
['pkgexports/condition', isRequire ? { default: 'encoded path' } :
23-
{ default: 'asdf' }],
26+
maybeWrapped({ default: 'asdf' })],
2427
// String exports sugar
25-
['pkgexports-sugar', { default: 'main' }],
28+
['pkgexports-sugar', maybeWrapped({ default: 'main' })],
2629
// Conditional object exports sugar
2730
['pkgexports-sugar2', isRequire ? { default: 'not-exported' } :
28-
{ default: 'main' }],
31+
maybeWrapped({ default: 'main' })],
2932
// Resolve self
3033
['pkgexports/resolve-self', isRequire ?
3134
{ default: 'self-cjs' } : { default: 'self-mjs' }],
3235
// Resolve self sugar
33-
['pkgexports-sugar', { default: 'main' }],
36+
['pkgexports-sugar', maybeWrapped({ default: 'main' })],
3437
// Path patterns
35-
['pkgexports/subpath/sub-dir1', { default: 'main' }],
36-
['pkgexports/subpath/sub-dir1.js', { default: 'main' }],
37-
['pkgexports/features/dir1', { default: 'main' }],
38-
['pkgexports/dir1/dir1/trailer', { default: 'main' }],
39-
['pkgexports/dir2/dir2/trailer', { default: 'index' }],
40-
['pkgexports/a/dir1/dir1', { default: 'main' }],
41-
['pkgexports/a/b/dir1/dir1', { default: 'main' }],
38+
['pkgexports/subpath/sub-dir1', maybeWrapped({ default: 'main' })],
39+
['pkgexports/subpath/sub-dir1.js', maybeWrapped({ default: 'main' })],
40+
['pkgexports/features/dir1', maybeWrapped({ default: 'main' })],
41+
['pkgexports/dir1/dir1/trailer', maybeWrapped({ default: 'main' })],
42+
['pkgexports/dir2/dir2/trailer', maybeWrapped({ default: 'index' })],
43+
['pkgexports/a/dir1/dir1', maybeWrapped({ default: 'main' })],
44+
['pkgexports/a/b/dir1/dir1', maybeWrapped({ default: 'main' })],
4245

4346
// Deprecated:
4447
// Double slashes:
45-
['pkgexports/a//dir1/dir1', { default: 'main' }],
48+
['pkgexports/a//dir1/dir1', maybeWrapped({ default: 'main' })],
4649
// double slash target
47-
['pkgexports/doubleslash', { default: 'asdf' }],
50+
['pkgexports/doubleslash', maybeWrapped({ default: 'asdf' })],
4851
// Null target with several slashes
49-
['pkgexports/sub//internal/test.js', { default: 'internal only' }],
50-
['pkgexports/sub//internal//test.js', { default: 'internal only' }],
51-
['pkgexports/sub/////internal/////test.js', { default: 'internal only' }],
52+
['pkgexports/sub//internal/test.js', maybeWrapped({ default: 'internal only' })],
53+
['pkgexports/sub//internal//test.js', maybeWrapped({ default: 'internal only' })],
54+
['pkgexports/sub/////internal/////test.js', maybeWrapped({ default: 'internal only' })],
5255
// trailing slash
5356
['pkgexports/trailing-pattern-slash/',
54-
{ default: 'trailing-pattern-slash' }],
57+
maybeWrapped({ default: 'trailing-pattern-slash' })],
5558
]);
5659

5760
if (!isRequire) {
@@ -214,11 +217,15 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js';
214217

215218
const { requireFromInside, importFromInside } = fromInside;
216219
[importFromInside, requireFromInside].forEach((loadFromInside) => {
220+
const isRequire = loadFromInside === requireFromInside;
221+
const maybeWrapped = isRequire ? (exports) => exports :
222+
(exports) => ({ ...exports, 'module.exports': exports.default });
223+
217224
const validSpecifiers = new Map([
218225
// A file not visible from outside of the package
219-
['../not-exported.js', { default: 'not-exported' }],
226+
['../not-exported.js', maybeWrapped({ default: 'not-exported' })],
220227
// Part of the public interface
221-
['pkgexports/valid-cjs', { default: 'asdf' }],
228+
['pkgexports/valid-cjs', maybeWrapped({ default: 'asdf' })],
222229
]);
223230
for (const [validSpecifier, expected] of validSpecifiers) {
224231
if (validSpecifier === null) continue;

test/es-module/test-esm-imports.mjs

+11-8
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,26 @@ const { requireImport, importImport } = importer;
99
[requireImport, importImport].forEach((loadFixture) => {
1010
const isRequire = loadFixture === requireImport;
1111

12+
const maybeWrapped = isRequire ? (exports) => exports :
13+
(exports) => ({ ...exports, 'module.exports': exports.default });
14+
1215
const internalImports = new Map([
1316
// Base case
14-
['#test', { default: 'test' }],
17+
['#test', maybeWrapped({ default: 'test' })],
1518
// import / require conditions
16-
['#branch', { default: isRequire ? 'requirebranch' : 'importbranch' }],
19+
['#branch', maybeWrapped({ default: isRequire ? 'requirebranch' : 'importbranch' })],
1720
// Subpath imports
18-
['#subpath/x.js', { default: 'xsubpath' }],
21+
['#subpath/x.js', maybeWrapped({ default: 'xsubpath' })],
1922
// External imports
20-
['#external', { default: 'asdf' }],
23+
['#external', maybeWrapped({ default: 'asdf' })],
2124
// External subpath imports
22-
['#external/subpath/asdf.js', { default: 'asdf' }],
25+
['#external/subpath/asdf.js', maybeWrapped({ default: 'asdf' })],
2326
// Trailing pattern imports
24-
['#subpath/asdf.asdf', { default: 'test' }],
27+
['#subpath/asdf.asdf', maybeWrapped({ default: 'test' })],
2528
// Leading slash
26-
['#subpath//asdf.asdf', { default: 'test' }],
29+
['#subpath//asdf.asdf', maybeWrapped({ default: 'test' })],
2730
// Double slash
28-
['#subpath/as//df.asdf', { default: 'test' }],
31+
['#subpath/as//df.asdf', maybeWrapped({ default: 'test' })],
2932
]);
3033

3134
for (const [validSpecifier, expected] of internalImports) {

test/fixtures/es-modules/cjs-exports.mjs

+5-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { strictEqual, deepEqual } from 'assert';
33
import m, { π } from './exports-cases.js';
44
import * as ns from './exports-cases.js';
55

6-
deepEqual(Object.keys(ns), ['?invalid', 'default', 'invalid identifier', 'isObject', 'package', 'z', 'π', '\u{d83c}\u{df10}']);
6+
deepEqual(Object.keys(ns), ['?invalid', 'default', 'invalid identifier', 'isObject', 'module.exports', 'package', 'z', 'π', '\u{d83c}\u{df10}']);
7+
strictEqual(ns['module.exports'], ns.default);
78
strictEqual(π, 'yes');
89
strictEqual(typeof m.isObject, 'undefined');
910
strictEqual(m.π, 'yes');
@@ -21,7 +22,8 @@ strictEqual(typeof m2, 'object');
2122
strictEqual(m2.default, 'the default');
2223
strictEqual(ns2.__esModule, true);
2324
strictEqual(ns2.name, 'name');
24-
deepEqual(Object.keys(ns2), ['__esModule', 'case2', 'default', 'name', 'pi']);
25+
strictEqual(ns2['module.exports'], ns2.default);
26+
deepEqual(Object.keys(ns2), ['__esModule', 'case2', 'default', 'module.exports', 'name', 'pi']);
2527

2628
import m3, { __esModule as __esModule3, name as name3 } from './exports-cases3.js';
2729
import * as ns3 from './exports-cases3.js';
@@ -32,5 +34,6 @@ deepEqual(Object.keys(m3), ['name', 'default', 'pi', 'case2']);
3234
strictEqual(ns3.__esModule, true);
3335
strictEqual(ns3.name, 'name');
3436
strictEqual(ns3.case2, 'case2');
37+
strictEqual(ns3['module.exports'], ns3.default);
3538

3639
console.log('ok');

0 commit comments

Comments
 (0)