Repository: node
Issue: Improve DX of importing ESM-compiled-to-CJS from native ESM
Labels: module,feature request,esm,never-stale

### 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`](https://github.com/nodejs/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: https://github.com/rollup/rollup/pull/4826
- Node.js still does not recognize every pattern generated by popular tools, see for example https://github.com/nodejs/cjs-module-lexer/issues/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:
```js
// as ESM
export default function circ(radius) {
  return radius * PI;
}
export const PI = 3.14;
```
```js
// 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":
```js
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:
```js
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`:
```js
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:
```js
"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 have `foo` and `bar` exports, and not a `default` export pointing to `module.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:
  ```js
  // 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:
  ```js
  "exports:abc,def"
  // means
  export const abc = exports.abc;
  export const def = exports.def;
  ```
  ```js
  "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:
```js
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)