Description
What is the problem this feature will solve?
The current ESM-CJS interop has two problems when it comes to files authored as ESM and then compiled to CJS
Problem 1
Due to how ESM works, Node.js needs to statically detect the list of bindings exported form CJS modules. This static analysis is performed by cjs-module-lexer
, which recognizes patterns produced by popular tools at the time that this helper library was authored.
This has two main limitations:
- build tools cannot change their generated code, that is usually not considered part of the public API, because it would make Node.js not recognize exports anymore. Example: Use old namespace reexport code pattern for better Node named export sniffing rollup/rollup#4826
- Node.js still does not recognize every pattern generated by popular tools, see for example Unable to detect exports from bundled webpack output cjs-module-lexer#88
In addition to these limitations, cjs-module-lexer
has to perform a full lexing pass on the imported CJS file, which has a non-zero cost.
Problem 2
The default export exposed by Node.js for CJS files is always module.exports
. This was a good choice at the beginning, to give a way of importing CJS from ESM, but now that Node.js detects named exports it's increasing friction when using the two modules systems together.
Consider this library, authored as ESM and published as CJS:
// as ESM
export default function circ(radius) {
return radius * PI;
}
export const PI = 3.14;
// compiled to CJS and published
exports.__esModule = true;
exports.default = function circ(radius) {
return radius * PI;
};
const PI = exports.PI = 3.14;
When importing this library from ESM, the named exports PI "just works":
import { PI } from "lib";
console.log(PI);
However, to use the default export you need to first import the library and then, separately, grab the default export from it:
import _lib from "lib";
const circ = _lib.default;
console.log(circ);
Developers sometimes try to rewrite it to use the "destructuring import" syntax with default as if it was a named import, but it obviously still points to module.exports
:
import { default as circ } from "lib";
Unfortunately Node.js cannot change it's behavior and use exports.default
as the default export (when exports.__esModule
is defined to signal that the CJS file was originally an ES module, as every single other tool does) for backwards compatibility.
What is the feature you are proposing to solve the problem?
Node.js should have some sort of comment/directive at the beginning of the file to let build tools communicate what is the list of exports that a file should expose.
For example, the library example above could be compiled by tools to the following:
"exports:default,PI";
exports.__esModule = true;
exports.default = function circ(radius) {
return radius * PI;
};
const PI = exports.PI = 3.14;
Then Node.js would know that when imported as ESM this file should have a default
export (pointing to exports.default
) and a PI
export (pointing to exports.PI
). Tools would be free to change their output code, and the "exports:..."
directive would give an unambiguous and easy-to-parse signal to Node.js about the original intention of the code author.
From a tool author perspective, I have a few opinion about how this should work more in details:
"exports:foo,bar";
should cause the module to only havefoo
andbar
exports, and not adefault
export pointing tomodule.exports
. This is so that adding a default export to the module will not be a breaking change.- the list of exports need a separator (I'm using
,
just because it looks nice), but,
could also appear in an export name. For this reason, all\
s and,
s need to be escaped:// input const foo = 3; export { foo as "abc \\ , def", foo }; // CJS output "exports:abc \\\\ \\, def,foo" const foo = exports["abc \\ ,"] = exports.foo = 3;
- it would be great if there was still a way to say "expose
module.exports
as the default export", so that tools could start emitting the new directive without breaking changes (and then they would stop enabling that flag when the compiled library is ready for a major release). For example:"exports:abc,def" // means export const abc = exports.abc; export const def = exports.def;
"exports!:abc,def" // means export const abc = exports.abc; export const def = exports.def; export default module.exports;
Additionally, I noticed that cjs-module-exports
also returns a list of modules re-exported with export * from
. This is necessary for Node.js to get the list of re-exported bindings. We would need to have a separate directive for that. For example:
export const foo = 1, bar = 2;
export * from "./dep.js";
export * from "mod";
// becomes
"exports:foo,bar";
"exports*:./dep.js,mod";
What alternatives have you considered?
Instead of a directive this could be a comment, like for linking source maps. However, it is important that this comment/directive must be at the beginning of the file, so that Node.js doesn't need to scan/parse the whole file to find it.
Somebody was already thinking about this in the past (I vaguely remember a discussion about it in a Babel issue with a Node.js collaborator), but I cannot find any references to it.
Some people that might be interested in the discussion:
@guybedford, @lukastaegert (Rollup), @kdy1 (SWC), @TheLarkInn (Webpack), @patak-dev (Vite), @evanw (esbuild), @andrewbranch (TypeScript) me (Babel)