Description
What is the problem this feature will solve?
In this MR #51977 it's being proposed to allow CJS to require(esm)
with the goal of helping people stuck in CJS to use ESM only modules, somehow conflicting with the plethora of dual modules already published and maintained by authors (and I am one of them).
To understand the issue there's no better way than a concrete use case.
Where we are now ...
A project using module a as dependency, where a is published as dual module, can be consumed from pure CJS, where by pure I mean no dynamic import(a)
in the mix, as that usually undesired or not common in CJS land due lack of TLA, as well as pure ESM.
module a
// a.mjs
export default Math.random();
// a.cjs
module.exports = Math.random();
module b
console.log(require("a")); // ./a.cjs
// 0.123456789
In this CJS scenario the module a will always provide the same random number once, no matter how many modules require it.
Now, because it was not possible before to synchronously import module c, the module b has no hazards in a CJS only environment.
Now enters module c as ./c.mjs
// module c depends on module `a`
// and because it's ESM only it will
// consume module `a` as ESM
import random from "a"; // ./a.mjs
console.log(random);
// 0.234567891
export default random * 2;
Where we're potentially going ...
edit test the use case if you want
In a scenario where the flag lands "unflagged" and the CJS is still the default, developers will carelessly believe they can finally require(esm)
without thinking twice about possible consequences ... if no error is encountered due TLA in the required ESM module, they think they're good!
module b after the flag
console.log(require("a")); // ./a.cjs
// 0.123456789
console.log(require("c").default); // ./c.mjs -> ./a.mjs
// NOT 0.246913578
// BUT 0.469135782
Conclusion
It is true that hazards related to dual modules where already possible before but there was no easy way to synchronously require ESM modules so that either they chose dual modules, they used a tool able to normalize everything as CJS or ESM, but any re-published package as CJS would've avoided or inlined somehow ESM modules with TLA.
In short, if the default require
, when it comes to dual modules, still prefers CJS once this flag lands unflagged, we will see dual module authors blamed for issues that were not so easy to bring in before with potentially catastrophic results beyond the dummy Math.random()
use case: databases, cache, file system, workers, you name it ... bootstrapping with ease dual modules will likely create more damage than solve instead anything it's aiming to solve and currently published dual modules cannot just stop providing their CJS counterpart until all users and dependents modules are capable of requiring ESM out of the box.
What is the feature you are proposing to solve the problem?
Before proposing anything I don't understand how this experimental flag is being considered as shippable in its current state (broken ESM require if TLA is used behind, increased dual module hazards surface) instead of fixing at the root level the issue by:
- allow ESM as default in NodeJS
- allow TLA in CJS too so that dynamic imports won't scare anyone anymore and the TLA behind the scene would also just work
Back to this flag though, I would like to propose at least the following solutions:
- the default
require(anything)
, once this flag lands unflagged, is to return the ESM version of the module, if such module is a dual module. This would solve the presented use case / issue because the module b thatrequire("a")
will already have the ESM version so that once module b finally can alsorequire("c")
from the ESM only world, nothing will break and no hazard will be present - there's gonna be a flag to impose the preferred way to
require
a module from CJS so that dual module authors can explicitly indicate in theirpackage.json
that the preferred way torequire
that module is through its ESM code and not its CJS artifact (or vice-versa whenever that's the case). In this case I believe having the"type": "module"
in thepackage.json
of a SHOULD already be enough to tell the newrequire(esm)
ability that even whenrequire(cjs)
was used, and that module is dual module, the returned module is actually the ESM one.
The latter point was breaking before so it should be a no-brainer to consider while the former suggestion might break with default
exports that were not expected before but I am hear to discuss possible solutions there too or propose we give developers time to test and adjust their code, after all they need to do so anyway the moment they finally require(ESM)
.
Having ESM as preferred way to any require will eventually convince developers to publish ESM only modules so that the dual module story can fade away in time, but if that's not the case I see a catch 22 like situation where authors can't stop publishing dual modules and users can't stop worrying about possible hazards introduced by the new feature.
What alternatives have you considered?
As already mentioned, I would rather bring in TLA in CJS behind a flag instead and call it a day, without mixing a tad too much the require
ability of the legacy, non standard, module system that CJS is. This would be the easiest migration for everyone caring to migrate and it will still grant pure CJS software to work without worrying about hazards in the graph.
Thank you.
After Thoughts
It's OK to explore solutions to the problem (we're doing that already) but if NodeJS lands these kind of changes it better be sure that the entirety of the tooling based projects out there also get the memo and implement the right thing or we'll create a new kind of hell to deal with, explain, and resolve ... at least until everything is ESM only and CJS can be just a memory to find in outdated books.