Description
What is the problem this feature will solve?
First of all, I'm incredibly excited for #51977 :) It's great to finally see the biggest pain point when using Node.js being addressed.
However, Node.js is not the first tool to implement require(esm)
and it would be great if it could follow the existing ecosystem convention. Given this code:
// main.cjs
const mod = require("./dep.mjs");
console.log(Object.getOwnPropertyDescriptors(mod));
// dep.mjs
export let foo = 1;
Webpack, Rollup, Parcel, esbuild and Bun will all log an object with an __esModule: true
property. This convention makes sure that the code will behave the same whether dep.mjs
runs as native ESM or as ESM-compiled-to-CJS.
While this doesn't really matter when the CJS and ESM files are in the same project, it's very helpful at the boundary between one library and another. Consider this case:
import add from "add-numbers";
console.log("2 + 2 is", add(2, 2));
// add-numbers
export default (x, y) => x + y;
When running as ESM, this obviously logs 2 + 2 is 4
. When running as ESM-compiled-to-CJS, this also logs the same result because the two files are compiled to (roughly)
const _addNumbers1 = require("add-numbers");
const _addNumbers = _addNumbers1.__esModule ? _addNumbers1 : { default: _addNumbers1 };
console.log("2 + 2 is", _addNumbers.default(2, 2));
// add-numbers
exports.__esModule = true;
exports.default = (x, y) => x + y;
Now, let's assume that the maintainer of add-numbers
learns that Node.js now supports require(esm)
, and decides to release the new version of their library as ESM since this won't have anymore the annoying effect of forcing its consumers to migrate to ESM.
If their user update to the new version, when running in Node.js their code will now stop working, throwing something like _addNumbers.default is not a function
(because _addNumbers1
is not the module namespace object). Instead, they have to change their code to this:
import add from "add-numbers";
console.log("2 + 2 is", add.default(2, 2));
so that their existing build process transpiles it to
const _addNumbers1 = require("add-numbers");
const _addNumbers = _addNumbers1.__esModule ? _addNumbers1 : { default: _addNumbers1 };
console.log("2 + 2 is", _addNumbers.default.default(2, 2));
Their code will now work with the ESM version of add-numbers
when running in Node.js, but:
- it required extra code changes
- it now won't work outside of Node.js (i.e. in bundlers), because when bundled the namespace object will be
_addNumbers
and not_addNumbers.default
Note that Node.js already has this problem when migrating to ESM the other way around (i.e. when migrating the consumer before the library), but it is for reasonable historic reasons (that is, the original Node.js CJS-ESM integration was just "assign module.exports
to the default export" without trying to make CJS being importable as if it was an ESM file with various exports), and it looks like it would be unfixable even through some opt-in mechanism.
What is the feature you are proposing to solve the problem?
Instead of returning the module namespace as-is, Node.js should wrap it in something that adds an __esModule: true
property to it. There are three ways of doing it:
- Cloning the namespace object:
{ __proto__: null, __esModule: true, ...namespace }
- Wrapping the module in an intermediate module:
export let __esModule = true; export { default } from "./that-module"; export * from "./that-module";
- Wrapping the namespace object:
Object.create(namespace, { __esModule: { value: true } })
I would personally recommend the third one, because:
- it supports live bindings (the first one does not)
- it makes
__esModule
non-enumerable (the second one does not)
This wrapping could either be done unconditionally, or only if the module doesn't already have an __esModule
export (which is very rare, as it's incompatible with all tools that compile ESM to CJS).
What alternatives have you considered?
Joyee suggested to instead update the detection in tools from
const _addNumbers = _addNumbers1.__esModule ? _addNumbers1 : { default: _addNumbers1 };
to
const _addNumbers = _addNumbers1.__esModule || require("util").types.isModuleNamespaceObject(_addNumbers1) ? _addNumbers1 : { default: _addNumbers1 };
However, this a few disadvantages:
- the major (more social than technical) one is that it requires every single tool out there that compiles ESM to CommonJS to be updated.
- the second (minor) one is that it makes the "was this a module?" detection CommonJS-specific. Right now tools use the same
require()
wrapper when compiling to CommonJS, AMD or UMD -- CommonJS and AMD/UMD would now need different detections to accomodate the difference of one of the platforms with ESM-CJS interop. - the third one is that
require("util").types.isModuleNamespaceObject
is Node.js-specific. That check will not work when bundled and sent to the browser, unless users figure out how to polyfill Node.js builtin modules in their tool (and still, googling for "webpack require util not found" leads to a StackOverflow answer that suggests anode:util
polyfill that does not support isModuleNamespaceObject, because it's unpolyfillable).