Skip to content

Commit

Permalink
esm: add option to interpret __esModule like Babel
Browse files Browse the repository at this point in the history
Adds an option `--cjs-import-interop`. When enabled, a CJS module will
be translated to ESM slightly differently with respect to default
exports.

- When the module defines `module.exports.__esModule` as a truthy value,
  the value of `module.exports.default` is used as the default export.
- Otherwise, `module.exports` is used as the default export.
  (existing behavior)

It allows better interoperation between full ES modules and CJS modules
transformed from ES modules by Babel or tsc. Consider the following
example:

```javascript
// Transformed from:
// export default "Hello";
Object.defineProperty(module.exports, "__esModule", { value: true });
module.exports.default = "Hello";
```

When imported from the following module:

```javascript
import greeting from "./hello.cjs";
console.log(greeting);
```

With `--cjs-import-interop`, it will print "Hello".

Fixes: nodejs#40891
  • Loading branch information
qnighy committed Nov 20, 2021
1 parent a37b9c8 commit 06f06f7
Show file tree
Hide file tree
Showing 7 changed files with 82 additions and 1 deletion.
9 changes: 9 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ If this flag is passed, the behavior can still be set to not abort through
[`process.setUncaughtExceptionCaptureCallback()`][] (and through usage of the
`domain` module that uses it).

### `--cjs-import-interop`

<!-- YAML
added: REPLACEME
-->

Enables the CommonJS default export recognition.

### `--completion-bash`

<!-- YAML
Expand Down Expand Up @@ -1545,6 +1553,7 @@ Node.js options that are allowed are:

<!-- node-options-node start -->

* `--cjs-import-interop`
* `--conditions`, `-C`
* `--diagnostic-dir`
* `--disable-proto`
Expand Down
5 changes: 5 additions & 0 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,11 @@ When importing [CommonJS modules](#commonjs-namespaces), the
available, provided by static analysis as a convenience for better ecosystem
compatibility.
If `--cjs-import-interop` is provided and the imported CommonJS module has
`__esModule` exports as a truthy value, then the CommonJS module is treated as
derived from an ES module. In this case, the `module.exports.default` value is
used as the default export instead of `module.exports`.
### `require`
The CommonJS module `require` always treats the files it references as CommonJS.
Expand Down
12 changes: 11 additions & 1 deletion lib/internal/modules/esm/translators.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const { maybeCacheSourceMap } = require('internal/source_map/source_map_cache');
const moduleWrap = internalBinding('module_wrap');
const { ModuleWrap } = moduleWrap;
const { getOptionValue } = require('internal/options');
const cjsImportInterop =
getOptionValue('--cjs-import-interop');
const experimentalImportMetaResolve =
getOptionValue('--experimental-import-meta-resolve');
const asyncESM = require('internal/process/esm_loader');
Expand Down Expand Up @@ -194,6 +196,14 @@ translators.set('commonjs', async function commonjsStrategy(url, source,
}
}

// We might trigger a getter -> dont fail.
let esModule = false;
if (cjsImportInterop) {
try {
esModule = !!exports.__esModule;
} catch {}
}

for (const exportName of exportNames) {
if (!ObjectPrototypeHasOwnProperty(exports, exportName) ||
exportName === 'default')
Expand All @@ -205,7 +215,7 @@ translators.set('commonjs', async function commonjsStrategy(url, source,
} catch {}
this.setExport(exportName, value);
}
this.setExport('default', exports);
this.setExport('default', esModule ? exports.default : exports);
});
});

Expand Down
4 changes: 4 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,10 @@ DebugOptionsParser::DebugOptionsParser() {
}

EnvironmentOptionsParser::EnvironmentOptionsParser() {
AddOption("--cjs-import-interop",
"Supports interop with modules wiht __esModule",
&EnvironmentOptions::cjs_import_interop,
kAllowedInEnvironment);
AddOption("--conditions",
"additional user conditions for conditional exports and imports",
&EnvironmentOptions::conditions,
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class EnvironmentOptions : public Options {
std::vector<std::string> conditions;
std::string dns_result_order;
bool enable_source_maps = false;
bool cjs_import_interop = false;
bool experimental_json_modules = false;
bool experimental_modules = false;
std::string experimental_specifier_resolution;
Expand Down
15 changes: 15 additions & 0 deletions test/es-module/test-esm-cjs-exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ child.on('close', common.mustCall((code, signal) => {
assert.strictEqual(stdout, 'ok\n');
}));

const entryInterop = fixtures.path('/es-modules/cjs-exports-interop.mjs');

child = spawn(process.execPath, ["--cjs-import-interop", entryInterop]);
child.stderr.setEncoding('utf8');
let stdout2 = '';
child.stdout.setEncoding('utf8');
child.stdout.on('data', (data) => {
stdout2 += data;
});
child.on('close', common.mustCall((code, signal) => {
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
assert.strictEqual(stdout2, 'ok\n');
}));

const entryInvalid = fixtures.path('/es-modules/cjs-exports-invalid.mjs');
child = spawn(process.execPath, [entryInvalid]);
let stderr = '';
Expand Down
37 changes: 37 additions & 0 deletions test/fixtures/es-modules/cjs-exports-interop.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { strictEqual, deepEqual } from 'assert';

import m, { π } from './exports-cases.js';
import * as ns from './exports-cases.js';

deepEqual(Object.keys(ns), ['?invalid', 'default', 'invalid identifier', 'isObject', 'package', 'z', 'π', '\u{d83c}\u{df10}']);
strictEqual(π, 'yes');
strictEqual(typeof m.isObject, 'undefined');
strictEqual(m.π, 'yes');
strictEqual(m.z, 'yes');
strictEqual(m.package, 10);
strictEqual(m['invalid identifier'], 'yes');
strictEqual(m['?invalid'], 'yes');

import m2, { __esModule as __esModule2, name as name2 } from './exports-cases2.js';
import * as ns2 from './exports-cases2.js';

strictEqual(__esModule2, true);
strictEqual(name2, 'name');
strictEqual(typeof ns2, 'object');
strictEqual(m2, 'the default');
strictEqual(ns2.__esModule, true);
strictEqual(ns2.name, 'name');
deepEqual(Object.keys(ns2), ['__esModule', 'case2', 'default', 'name', 'pi']);

import m3, { __esModule as __esModule3, name as name3 } from './exports-cases3.js';
import * as ns3 from './exports-cases3.js';

strictEqual(__esModule3, true);
strictEqual(name3, 'name');
deepEqual(Object.keys(ns3), ['__esModule', 'case2', 'default', 'name', 'pi']);
strictEqual(m3, 'the default');
strictEqual(ns3.__esModule, true);
strictEqual(ns3.name, 'name');
strictEqual(ns3.case2, 'case2');

console.log('ok');

0 comments on commit 06f06f7

Please sign in to comment.