Skip to content

Commit

Permalink
module: support __cjsUnwrapDefault interop flag in require(esm)
Browse files Browse the repository at this point in the history
  • Loading branch information
guybedford committed Sep 6, 2024
1 parent 01bf4a1 commit be6bc32
Show file tree
Hide file tree
Showing 48 changed files with 274 additions and 6 deletions.
53 changes: 51 additions & 2 deletions doc/api/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,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()` under `--experimental-detect-module`:
Expand Down Expand Up @@ -233,6 +232,56 @@ 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.

To create an ESM module that should provide an arbitrary value to CommonJS, the
`__cjsUnwrapDefault: true` marker can be used instead:

```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 const __cjsUnwrapDefault = true;
```

```cjs
const Point = require('./point.mjs');
console.log(Point); // [class Point]

// Named exports are lost when __cjsUnwrapDefault is used
const { distance } = require('./point.mjs');

Check failure on line 255 in doc/api/modules.md

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'./point.mjs' require is duplicated
console.log(distance); // undefined
```

Notice in the example above, when `__cjsUnwrapDefault` 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.

```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 const __cjsUnwrapDefault = true;
```

```cjs
const Point = require('./point.mjs');
console.log(Point); // [class Point]

const { distance } = require('./point.mjs');

Check failure on line 280 in doc/api/modules.md

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'./point.mjs' require is duplicated
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
Expand Down
11 changes: 7 additions & 4 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -1416,10 +1416,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 (namespace.__cjsUnwrapDefault) {
mod.exports = namespace.default;
} else if (!ObjectHasOwn(namespace, 'default') || ObjectHasOwn(namespace, '__esModule')) {
mod.exports = namespace;
} else {
mod.exports = createRequiredModuleFacade(wrap);
Expand Down
51 changes: 51 additions & 0 deletions test/es-module/test-require-as-esm-interop.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Flags: --experimental-require-module
import { mustCall } from '../common/index.mjs';

Check failure on line 2 in test/es-module/test-require-as-esm-interop.mjs

View workflow job for this annotation

GitHub Actions / lint-js-and-md

'mustCall' is defined but never used
import assert from 'assert';
import { directRequireFixture, importFixture } from '../fixtures/pkgexports.mjs';

const tests = {
'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 const __cjsModule = true` 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 __cjsModule = true,
// 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);

}

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

3 changes: 3 additions & 0 deletions test/fixtures/node_modules/interop-cjs-esm/fauxesmdefault.js

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.

4 changes: 4 additions & 0 deletions test/fixtures/node_modules/interop-cjs-esm/fauxesmmixed.js

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.

3 changes: 3 additions & 0 deletions test/fixtures/node_modules/interop-cjs-esm/fauxesmnamed.js

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

3 changes: 3 additions & 0 deletions test/fixtures/node_modules/interop-cjs-esm/object-esm.js

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

3 changes: 3 additions & 0 deletions test/fixtures/node_modules/interop-cjs-esm/object.js

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

15 changes: 15 additions & 0 deletions test/fixtures/node_modules/interop-cjs-esm/package.json

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

3 changes: 3 additions & 0 deletions test/fixtures/node_modules/interop-cjs-esm/string-esm.js

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

3 changes: 3 additions & 0 deletions test/fixtures/node_modules/interop-cjs-esm/string.js

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/node_modules/interop-cjs/fauxesmdefault.js

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/node_modules/interop-cjs/fauxesmmixed-esm.js

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/node_modules/interop-cjs/fauxesmmixed.js

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/node_modules/interop-cjs/fauxesmnamed-esm.js

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/node_modules/interop-cjs/fauxesmnamed.js

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/node_modules/interop-cjs/object-esm.js

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/node_modules/interop-cjs/object.js

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

15 changes: 15 additions & 0 deletions test/fixtures/node_modules/interop-cjs/package.json

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/node_modules/interop-cjs/string-esm.js

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/node_modules/interop-cjs/string.js

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.

4 changes: 4 additions & 0 deletions test/fixtures/node_modules/interop-cjsdep-fauxesmmixed/dep.js

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.

3 changes: 3 additions & 0 deletions test/fixtures/node_modules/interop-cjsdep-fauxesmnamed/dep.js

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.

5 changes: 5 additions & 0 deletions test/fixtures/node_modules/interop-cjsdep-object-esm/dep.js

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.

2 changes: 2 additions & 0 deletions test/fixtures/node_modules/interop-cjsdep-object/dep.js

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

4 changes: 4 additions & 0 deletions test/fixtures/node_modules/interop-cjsdep-object/package.json

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

2 changes: 2 additions & 0 deletions test/fixtures/node_modules/interop-cjsdep-string-esm/dep.js

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/node_modules/interop-cjsdep-string/dep.js

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.

4 changes: 4 additions & 0 deletions test/fixtures/node_modules/interop-cjsdep-string/package.json

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

4 changes: 4 additions & 0 deletions test/fixtures/pkgexports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) };
}
Expand Down

0 comments on commit be6bc32

Please sign in to comment.