-
Notifications
You must be signed in to change notification settings - Fork 29.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
module: __cjsModule on ESM CJS wrapper and in require ESM #53848
base: main
Are you sure you want to change the base?
Conversation
Review requested:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lgtm
I may not follow with the motivation. If this feature is going to be used in "transpile a CJS module (which was transpiled from ESM) back into ESM and run it on the web", can this be added to transpilers instead? |
It is also helpful to support CJS transpiled into ESM (ie modern bundled code) running in Node.js against an external that is unknown as to whether it is CJS or ESM, where being able to know if the external is a CJS wrapper can allow lowering the required require(esm) interop pattern. |
The reason why we introduced the __esModule was:
I think to implement a counterpart, we at least need 1 - there should at least be some integration in CJS -> ESM transpilers adopting this pattern to run things in browsers, which is what this primarily is useful for - it's very rare to see CJS code transpiled into ESM to be run in Node.js, as far as I know. Suppose transpilers disagree and some adopt a different flavor, or if this trick doesn't turn out to be actually working because no integration test has been written yet (this is all on paper for now), we'd be left with something that doesn't work and nobody uses. #52166 included an integration test using output from TypeScript, I think we need something similar in this PR too. |
@joyeecheung I think the requirement that this new pattern already be in use before we have created the pattern is an unrealistic requirement to make. I have given two clear use cases for this as a feature, with the direct use case in #53848 (comment) immediately being solved by landing this PR.
RollupJS is both a Node.js and browser bundler, so this is not quite true. When using RollupJS in Node.js applications to, say, speed up startup times, this is very much a real use case.
There's no trick or flavour here, simply the question - is the ES module namespace that I have imported an ESM wrapper around a CJS module? And I think that is a useful question to answer by supporting this flag to answer it. What more would you like to see tested with regards to this? |
Specifically the problem is needing to know when in Node.js if an arbitrary ESM namespace is an ESM CJS wrapper or not. With that answer, yes, new CJS to ESM interop patterns can emerge as I have discussed, since they very much need this information. But the check is useful in its own right, and does not depend on their future emergence either. |
Isn't this a pattern primarily meant for code run in the browser? Then it should be possible to create a bundler integration that demonstrate how this can be used in the browser first?
RollupJS supports CommonJS output too. As far as I know typically people bundle code into CommonJS format to speed up startup times (ESM loading is slower than CommonJS loading in the first place), or to use the bundle with SEA or snapshots since they do not support ESM. But choosing the ESM output for a Node.js environment is rare. If it's common enough it seems strange that this need for
I was referring to this use case:
In #52166 we added a test fixture with the output of the actual TypeScript compiler. Before the PR, loading default exports of real ESM in transpiled ESM -> CJS code leads to an error. After it works intuitively. I think for this PR we'd need CJS -> ESM code emitted by an existing transpiler that doesn't load an external CJS without |
I agree that it's useful in its own right, though currently for code only targeting Node.js this can also be worked around by checking whether the path exists in |
For the specific case in OP, it seems the problem all stems from the proposed interop layer, which AFAIK no transpiler has implemented yet. The layer seems redundant for ESM -> CJS code, however. If you don't do that layer, and simply transpile this: const depMod = require('dep');
const depNs = depMod.__esModule ? depMod : { default: depMod };
console.log(depNs.default); to just this (IIUC, this is already what Rollup currently produce with import depMod from 'dep';
const depNs = depMod.__esModule ? depMod : { default: depMod };
console.log(depNs.default); Things already just work automatically. If |
The entire ecosystem does not need to move to support Your scheme in #53848 (comment) does not work for a real ES module (not faux ESM) without a default export, since it will bail that |
If what you need is the ability fully comparable with |
The I think we're focusing a little too much on some hypothetical major adoption of this feature (I'm not really not sure what you mean by browser adoption), and a little too less on the problems it solves, and what reasons we might not want to land it. @joyeecheung if you are against this flag, perhaps you can more clearly state your underlying concerns? |
I’ve been building SvelteKit apps for a few years now, and SvelteKit uses Vite with Rollup to generate ESM production builds. Startup time isn’t a concern for long-lived servers, and these builds aren’t bundling the external dependencies; I still need my So no, I wouldn’t say that building for ESM output in a Node.js environment is rare. |
I've already stated this in #53848 (comment)
In general, adding more underscore underscore property should be frowned upon. |
So it isn't transpiling CJS to ESM either, which is what this PR is about? |
This PR is not claiming to set a convention that will work for all bundlers, rather it is providing a solution to the problem that when building CJS requires as externals into ESM imports, we have an interop problem in distinguishing CJS ESM wrappers from real ESM and that currently has no viable solution. And I would quite like to be able to solve this problem for our Node.js users.
This argument is circular - you are asking for adoption, but then also claiming you are concerned about adoption. The concern and request cannot be for the same thing? Would you be less concerned if we renamed the flag to There is also another side to this PR and that is that using this flag, it is possible to define the representation of an ESM module in require(esm). I would have thought you'd be interested in that given it can be used as another tool to solve require(esm) interop issues. |
Not saying that it isn't a problem, but it seems very well like an issue we can follow up later. This path needs code originally in CJS being transpiled to ESM and loading an external ESM to be hit. That doesn't seem to be an urgent issue - for one not that many people actually configure to transpile CJS to ESM for it to be run in Node.js, if they need to ship ESM they likely would just write in ESM in the first place and never hit this path (probably what @GeoffreyBooth does too), let alone attempting to loading real external ESM from said CJS code (considering how hacky CJS -> ESM can be in terms of handling dynamic exports and imports and all the wonky require extensions, this practice already has bigger fishes to fry anyway). This is unlike the problem that
I don't think I claimed to be concerned about adoption, rather I am concerned about the lack of adoption of a premature solution that's currently purely on paper, and we have no integration tests to verify that this solution actually works as intended.
I'd be more interested to see working prototypes, the current description of the solution is too vague and abstract, and we don't know for sure whether it actually works as intended. |
Actually I realized that this is a breaking change, because unlike import * as empty from './empty.cjs';
assert.deepStrictEqual({...empty}, { default: {} }); // it will throw after this change Unlike import { isCJSModule } from 'node:module'; // Or use process.getBuiltinModule();
import * as empty from './empty.cjs';
isCJSModule(empty); // true
|
8566dea
to
4688513
Compare
test/fixtures/cjsesm/index.mjs
Outdated
@@ -0,0 +1,3 @@ | |||
import dep from './dep.js'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should test three cases like what's done in the __esModule
PR:
- When there is only a default export
- When there are named exports
- When there are both named and default exports
And deps
should be put in node_modules
to mimic the targeted structure.
@@ -233,6 +232,23 @@ This property is experimental and can change in the future. It should only be us | |||
by tools converting ES modules into CommonJS modules, following existing ecosystem | |||
conventions. Code authored directly in CommonJS should avoid depending on it. | |||
|
|||
To create an ESM module that should provide an arbitrary value to CommonJS, the | |||
`__cjsModule: true` marker can be used instead: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still think we need a more informative name (I don't see any need for underscored names, this would be declared explicitly by library authors, if export default
is a thing, export const unwrapDefault
doesn't seem too off either), and this should also be applied to the setExports('default')
call for import cjs
as explained in #54085 (comment) - IIUC that addresses your "what if depMod is CJS -> ESM transpiled" use case, if depMod
defines this marker, the transpiled consumer no longer needs to unwrap the default again:
import * as depMod from 'deps';
// depMod is { default: transpiledNS.default, ...namedExportsFromCjsModuleLexer, __esModule: true, unwrapDefault: true }
const depNs = depMod.__esModule ? depMod : { default: depMod }; // Takes first branch
console.log(depNs.default); // It's the original CJS module.exports, which was exported as `export default` in the transpiled ESM i.e. transpiledNs.default.
Compared to adding the marker to the wrapper, this has the advantage of not having to break existing import cjs
users which may have been relying on a pristine namespace in the past 5 years and can be backported to v18-22.
Also, in the document we should warn users that this would make the named exports invisible to require(esm)
, so if they have any named exports, they should make sure to make it a property of the object that is being exported as default.
export default class Point {};
export function distance() { }
export const unwrapDefault = true;
// There is no way to get function distance from the module via `require()`
// unless the module defines `Point.distance = distance`.
// This is different from the behavior that ESM-transpiled-to-CJS libraries currently
// provide to their CJS consumers should they wish to ship real ESM.
const { distance } = require('point');
distance(); // throws
Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
4688513
to
9fcf217
Compare
@joyeecheung I've expanded the test cases to include various shapes include the string, object and also the faux esm cases of mixed, named and default exports, and separated the dependency into node_modules being consumed as CJS and ESM under this convention. For the name, I'm open to changing to a name that is not prefixed with I'd also like to revisit this being a major change. The benefit of supporting this at the same time as putting it on CJS ESM wrapper namespaces is that those namespaces are then properly supported under CJS require, so that closes the loop on the lifting and lowering sides of the pattern. Whereas if we only land one side individually we potentially break transitive usage. The argument here is that many objects support obtaining new fields as a non-breaking change, so surely this could be okay. If we don't treat this as a minor change, we would need to split it up into two PRs further - one for the CJS unwrapping in require(esm) that is non breaking, and another breaking change for adding this to the import wrapper. I'd really prefer not to have to do that if possible though. |
Codecov ReportAll modified and coverable lines are covered by tests ✅
Additional details and impacted files@@ Coverage Diff @@
## main #53848 +/- ##
==========================================
- Coverage 87.09% 87.09% -0.01%
==========================================
Files 647 647
Lines 181845 181856 +11
Branches 34913 34921 +8
==========================================
+ Hits 158373 158382 +9
+ Misses 16751 16747 -4
- Partials 6721 6727 +6
|
As a follow-up to #52166 which explicitly adds support for the
__esModule
wrapper marker for ESM modules imported into CJS, this implements a complementary__cjsModule
wrapper marker for the ESM wrappers created when importing CJS modules into ESM. In addition, whenrequire(esm)
is applied to any module exporting__cjsModule: true
(including these wrappers), it will handle unwrapping the default supporting custom CJS values and transitive reexports of wrappers exposing their underlying CommonJS modules.This came out of lengthy discussion with @joyeecheung both online and offline around that PR working through various ecosystem implications, and seems to me to be a good compromise to any potential interop problems #52166 may cause in future.
The use case here is that given an arbitrary namespace object imported into a JavaScript file, in dynamic linking workflows where no further information is available, it is useful to be able to infer many interop patterns against such arbitrary modules.
For example, consider an ESM module that is transpiled into a CommonJS module and then transpiled back to ESM running on the web, where a dependency could be in an unknown module format:
Faux ESM:
Transpiled from ESM to CJS (as tools do today, and then published to npm say):
Now if I run this module in Node.js,
dep
may now get an__esModule
marker added by Node.js if it doesn't have an__esModule
marker already and has adefault
export.So if I want to transpile this module back into ESM and run it on the web, with that same dynamic dependency linkage, I end up with something like:
So we now have a scheme to transpile arbitrary CJS to ESM for browsers against full dynamic externals by adding this new interop layer where an arbitrary
require('dep')
in a build to ESM gets turned into:That is, even if we don't know the value of
dep
, we have a way we can coerce it into the correct value and interop with it correctly, regardless of whether it came from CommonJS, ESM or faux ESM.But there is now a new interop problem with such a scheme - in the case where
depMod
is a CJS module transpiled into ESM, we may want the CJS transpiled into ESM to be represented by the Node.js CJS ESM wrapper which looks like{ default: module.exports, ...namedExportsFromCjsModuleLexer }
.But if we pass a module of the above shape in the above interop pattern as
depMod
, since it does not have an__esModule
and it does have adefault
, it will give a value ofdepRequire
being{ default: module.exports, ...namedExportsFromCjsModuleLexer, __esModule: true }
, when we would have expecteddepRequire
to have beenmodule.exports
!But, if there is a
__cjsModule
marker in the CJS ESM representation, then the interop can be updated to be:Therefore fully solving the dynamic externalization against all of the cases where
dep
might respectively be CJS transpiled to ESM and real ESM with the conditional faux ESM interop layer for the consuming faux ESM as CJS transpiled into ESM per #52166.I understand at this point that this all may seem contrived, but in real-world interop patterns, there is no concept of reaching some compositional complexity limit - users will apply tools to code, and expect it to work. Fractal interop is the name of the game. And where the past interop concerns were about ensuring ESM semantics transpiled to CJS, our future interop concerns are now the semantics of CJS transpiled to ESM.
__cjsModule
helps to bridge that much like__esModule
did when I originally implemented it in Traceur and Babel later adopted.By landing and porting this alongside #52166, it should give another tool to interop authors wherever they are in the toolchain to be able to have a way to deal with these kinds of onion interops. And while the exact interop issue is not something that Node.js itself has to deal with, I strongly believe that being friendly to tooling dealing with hard interop cases would be a net win for the ecosystem. Well that, and also I very much need this feature in my own tooling workflows.
Note that this would be a major change, and should go out at the same time as the unflagging for require(esm).
//cc @nodejs/loaders