Repository navigation
module: increased surface for hazards with require(esm) experimental flag #52173
Description
Activity
- addedfeature requestIssues requesting new Node.js features.Issues requesting new Node.js features.
on Mar 21, 2024 (thanks for moving the discussion from Twitter to GitHub!)
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.When Joyee tried to make
require()accept theimportcondition rather than justrequire(), we found that it breaks one of the top-30 ESM packages that she tested (#51977 (comment)).I'm a maintainer of that package so I could easily fix that, but the problem boils down to the
requireandimportversions not having exactly the same API: the CJS version has a__esModule: trueexport while the ESM version doesn't. #52166 would fix it, so maybe it's worth considering again the breaking change.Another case where you would not want
require()to respect theimportcondition is when you have a sync CJS entrypoint and an async ESM entrypoint (for example, because they both dotest ? require("./variant-a") : require("./variant-b")andtest ? await import("./variant-a") : await import("./variant-b")). In this case, ifrequire()resolved to the ESM version it would then start throwing while loading it. Again, there is a possible solution for package authors: given that conditions are matched in the order they are defined inpackage.json, you could choose if you want to give priority toimportorrequire:Priority to CJS Priority to ESM { "name": "foo", "exports": { "require": "./foo.cjs", "import": "./foo.mjs" } }{ "name": "foo", "exports": {, "import": "./foo.mjs", "require": "./foo.cjs" } }This ability to choose priority applies both in the case of just extending
requireto also look at theimportcondition, and in the case of just adding a new condition such asrequire-esm.
Now, because it was not possible before to synchronously import module c, the module b has no hazards in a CJS only environment.
The
require(esm)change does not affect CJS-only environments, since it's about mixing CJS and ESM. In mixed environments, such as the example you are giving, the hazard is already present using only synchronous code:import ... fromdeclarations andrequire()are enough to trigger it (see the example in #52174).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
Regardless of
require()resolution priority, dual modules can be dropped exactly in the moment all the Node.js versions you care about supportrequire(esm), right?given that conditions are matched in the order they are defined in package.json, you could choose if you want to give priority to import or require
is this a theoretical or that's actually implemented in that flag already? I see your links are still open here and there
In mixed environments, such as the example you are giving, the hazard is already present using only synchronous code
there's no hazard if the first
require("a")points at./a.mjsinstead ... dual module per se is not an hazard, is the hybrid env that imports here and there stuff differently that causes the hazard ... here I am suggesting to pivot to ESM always onrequirewhenever that's possible. The change / order of the explicitimportworks though, I still need to update tons of modules but that's already something, if it works the way you suggested.dual modules can be dropped exactly in the moment all the Node.js versions you care about support require(esm), right?
it'll take years, I suppose, but yes. New modules won't be published as dual, old modules with hundred million weekly downloads aren't so "easy-peasy" to change.
is this a theoretical or that's actually implemented in that flag already? I see your links are still open here and there
It's reality that export conditions are matched in order, it's theoretical that
require()could match theimportcondition.there's no hazard if the first
require("a")points at./a.mjsinsteadI agree, but that's not what already happens today without the flag, so today we are already affected by the problem even without using dynamic import (again, see the docs issue I linked because it contains an example :) )
@nicolo-ribaudo this might be a hell of a lucky coincidence but thanks to the fact I use
npx modulestrapto bootstrap any project of mine it looks like all my dual packages are already like this:"exports": { ".": { "types": "./types/index.d.ts", "import": "./esm/index.js", "default": "./cjs/index.js" }, "./package.json": "./package.json" },
Am I correct in understanding you that this means there's literally nothing I should do to change because
require("flatted"), as example, orrequire("linkedom")or others will automatically prefer to import theESMversion, as that's first (well, after types, eventually, but that's not a NodeJS affair) in the package export definition?I might rest my case if that's the case, still if this is how that flag lands everyone should be aware of this potential issue and be sure modules authors also understand how to solve it ... they have time to just eventually swap export definitions without breaking anything in the meantime, the breaking will eventually land once that flag becomes the default.
If
requirematched also theimportcondition then yes, you would would have nothing to change becauseimportwould be the first matched condition in your list.so, the current state is that given this exports definition in the module package that is being either imported or required:
{ "name": "whatever", "type": "module", "exports": { ".": { "types": "./types/index.d.ts", "import": "./esm/index.js", "default": "./cjs/index.js" }, "./package.json": "./package.json" } }
once this flag gets unflagged the only file that will ever be required or imported is
./esm/index.js... right?edit explicitly:
// ./cjs/index.js -> exports.ref = {}; // with flag this points at ./esm/index.js instead const { ref: cjs } = require("whatever"); // ./esm/index.js -> export const ref = {}; import { ref as esm } from "whatever"; // nothing to see here, it's all good! console.assert(cjs === esm, "hazards in the house");
If this is the case then my proposal about signaling from module authors what should be preferred is already satisfied ... I just want to be sure this is the case so that at least I don't need to worry about this breaking change in the near future, thank you.
No, the behavior of that package doesn't currently change under the flag.
What I was proposing above is to instead explore make the flag break backwards compatibility, so that in your example both
requireandimportwould match theimportcondition and thatconsole.assert(that currently, even without this flag, fails) would pass.that currently, even without this flag, fails
of course it does ... that was the whole point, I want it to not fail and force
require("whatever")to point at./esm/index.jsinstead ... can we agree module authors should have this ability? If not, this flag is going to be hostile for users, they add hazards without understanding what's going on, and for module authors, they get the blame for something they can't even control within the Open Source software they provide.I hope we agree here that make authors able to chose what "future require" should prefer must be a requirement for this flag.
btw ...
that currently, even without this flag, fails
if your point is that it should have a
createRequireupfront it's fine ... I wanted to meta describe the desired result from all worlds, no matter if that whatever module is required or imported ... I hope that clarifies.if your point is that it should have a createRequire upfront it's fine
No, my point is that today, in sync-only-code, there is no guarantee that a dual package that uses
importorrequireconditions will be loaded only in one of the two versions. A library depending on yours might be written in CJS and load the CJS version; a library depending on yours might be written in ESM and load the ESM version; and the final users importing those two libraries will see both versions of yours being loaded.
can we agree module authors should have this ability?
Yes, I agree! I disagree that this is a breaking change introduced by the flag, but I agree that module authours should have this ability. And I see four concrete ways to do so, I'll list them here.
Do nothing
This might seem counter intuitive, but I just realized that even if Node.js does nothing you can still publish packages that have the behavior you want.
You need to prefix your transpiled CJS files with a
require()of the ESM file:package.jsonfoo.mjsfoo.cjs{ "name": "foo", "exports": { "import": "./foo.mjs", "default": "./foo.cjs" } }let num = Math.random(); export { num };
try { return require("./foo.mjs") } catch {} let num = Math.random(); exports.num = num;
Add a new
module-synccondition that is matched both byimportandrequire, in platforms whererequire()can load sync ESMThis would let you have different entrypoints for ESM with TLA (only loaded by
import), ESM without TLA (loaded both byimportandrequire, and CJS.{ "name": "foo", "exports": { "module-sync": "./foo.mjs", "default": "./foo.cjs" } }let num = Math.random(); export { num };
let num = Math.random(); exports.num = num;
Add a new
require-esmcondition that is matched byrequirein platforms whererequire()can load sync ESM{ "name": "foo", "exports": { "import": "./foo.mjs", "require-esm": "./foo.mjs", "default": "./foo.cjs" } }let num = Math.random(); export { num };
let num = Math.random(); exports.num = num;
Change
require()to also match the"import"conditionThis is a breaking change, but maybe it could be explored.
P.S. I wouldn't mind a situation like this neither, as ordered JSON is also something not many care about in the wild (AFAIK)
{ "name": "whatever", "type": "module", "exports": { ".": { // edit: oops, taken, I meant require-esm "require": "./esm/index.js", "types": "./types/index.d.ts", "import": "./esm/index.js", "default": "./cjs/index.js" }, "./package.json": "./package.json" } }
This adds a new field, it's "scoped" per each defined export, and extremely easy to reason about to me ... we can debate if
"cjs"instead of"require-esm"is a better choice, or literally any other convention to satisfy the goal, but I hope something happens that allows authors to decide themselves what's the best qay to require their own modules.In my case, when no hazards or side effects are possible (i.e. just utilities and who cares if duplicated), I could decide myself which path should be served and prefer maybe CJS if there's only a default export, translated directly as
module.exports = thingwithout all the ugly__esmModuleand oter decorations too many tools add these days to solve the same issue.This would probably be my best option yet because it's also easier for the eyes to understand, and easier for everyone to reason about, imho.
That
package.jsonis incompatible with every Node.js version between 12 and <last-version-without-the-flag-enabled-by-default>.EDIT Oh yes, with a new name it's like the
require-esmI proposed above.23 remaining items
- added a commit that references this issue
on Sep 25, 2024 - added a commit that references this issue
on Oct 1, 2024 - added a commit that references this issue
on Oct 4, 2024 - added a commit that references this issue
on Feb 11, 2025
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
module b
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.mjsWhere 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
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 dummyMath.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:
Back to this flag though, I would like to propose at least the following solutions:
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 presentrequirea module from CJS so that dual module authors can explicitly indicate in theirpackage.jsonthat the preferred way torequirethat 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.jsonof 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
defaultexports 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 finallyrequire(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
requireability 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.