Repository navigation
Proposal for dual ESM/CommonJS packages #273
Description
Activity
- addedmodules-agendaTo be discussed in a meetingTo be discussed in a meeting
on Feb 25, 2019 this still feels like esm is second-class. allowing extension searching gets rid of the entire dual mode issue.
Reacted by Wesley WighamI would prefer not to proceed with "exports" until we have extension lookup, at which point the easy way to get dual-mode packages will be
foo.jsandfoo.mjs, where "main" isfooReacted by Wesley Wigham, Adrian, Toru Nagashima and Charles SamborskiThere’s nothing about the proposal above the prevents extension searching from happening, either opt-in or by default. This proposal need not be tied to that, and I think it’s better if it isn’t. Keep in mind that
package.jsonisn’t only for Node’s use; build tools and other platforms will need to be able to read it, and it’s a burden on them if we force them to implement Node’s extension searching algorithm in order to determine the ESM entry point. Even if we allow automatic extension searching within the Node runtime itself, for compatibility with other tools and environments it would be better if it doesn’t extend topackage.json.Reacted by Jayden Seric, Andrea Giammarchi, Mark Stacey and Sasha Chudesnov@GeoffreyBooth whether
exportsexists or not, they have to do searching for themainfield. why not just keep it simple.Reacted by Adrian and Toru Nagashima@devsnek Tools need only do extension lookup on
mainto support CommonJS, which will become increasingly uncommon as it's a Node-only thing.way to get dual-mode packages will be
foo.jsandfoo.mjs, where "main" isfooThis seems to imply that you'd have to have both in the same directory which is really uncommon. This doesn't fit well with either compilation-to-CJS or "esm interface in root, source in lib". It would mean more empty junk files in package roots unless I'm missing something about this suggestion.
Reacted by Mark Stacey@GeoffreyBooth You did great work anayzing packages with a "module" key. I'm curious if we've done any similar analysis of previous usage of the "exports" key. Does it conflict with any existing ecosystem usage? (Apologies if this belongs in another thread.)
@zenparsing I looked it up, and it's been a while so I don't remember the usage number offhand but basically we can claim
exports. It's either completely unused or used by only a handful of public npm packages.@GeoffreyBooth I believe that the last time
package.exportscame up the feature was discussed as something that might be useful for common.js as well. It seems like this proposal is making the assumption that the only things to be exposed bypackage.exportswould be ESM. It seems a bit fragile if we would eventually want to support ESM, alternatively it also seems like it may fail as we introduce more goals e.g. wasm / json / etcReacted by Jordan Harband@jkrems both ways leave a patterns of doing things out. assuming i'm not unique on this planet, this proposal is more junk for some people's package.json, but less junk for people who have a src and lib directory. personally i tend to prefer solutions for people that don't use build tooling since build tooling can automagically fill configurations in and whatnot. i think this is definitely something worth discussing more in call.
@MylesBorins This proposes that the shorthand string value be the ESM entry point, but the verbose object form could define CommonJS values as well. That’s why
exportson its own doesn’t imply"type": "module".67 remaining items
I would like to explicitly object to dual mode packages. I genuinely believe that the removal of dual mode due to lack of automatic extension searching is a feature not a bug. Have a single specifier mean two different things depending on what graph you load it into is a massive hazard. I will spend some time this week going through some scenarios to explain some situations that we might be creating with dual mode and ways in which it creates extremely nasty and hard to debug errors.
edit:
to clarify I meant dual mode packages sharing a single specifier @GeoffreyBooth does a good job of explaining what I meant in #273 (comment)
Reacted by Jordan Harband, Wesley Wigham, Toru Nagashima, Ant Stanley, Adam Stankiewicz, Alexander O'Mara and Ingvar StepanyanHave a single specifier mean two different things depending on what graph you load it into is a massive hazard.
Disagree with lack of a kind of dual mode, but agree with this sentiment, which is why I think the cjs and esm resolver need to be unified (and then cross-format calls made ok via a syncification of the resolution process as described in the other proposal).
Even if you disagree with
require(esm)for whatever reason, this sentiment should be a good driver for unifying the cjs and esm resolvers (related: we probably need to reserve .wasm in cjs same as .mjs in preparation for wasm modules).Reacted by Jordan Harband(We may want to reserve all unknown extensions in
.cjs, in preparation for whatever module types we might want to add in the future)I think what is becoming more visible is the disconnect that exists between simulated interoperability and actual interoperability hidden behind some form of conventionally opinionated façade.
Even if you disagree with require(esm) for whatever reason
So maybe we can recognize that historically this has been actually doing a
require(transpileToCJS(esm))and that is probably where various concerns about failing to meet expectations for all forms of conventionally opinionated façades in a one-size de facto implementation are challenging (at least at this moment).Have a single specifier mean two different things depending on what graph you load
So if we said a specifier can mean two things depending how the means by which it was specified — as in
require(‹x⑴›) == import(‹x⑵›)wherex⑴ ≃ x⑵then imho we can say that the module record for both can be a single record; assuming of course a conforming module map keysx⑵and use two-way mapping facilitator to adapt calls torequire(…).Yeah, that sounds like two specifiers, but they are actually one specifier meaning one this, with a separate form that adapts them to the CJS layer.
I would like to explicitly object to dual mode packages. … Have a single specifier mean two different things depending on what graph you load it into
Specifically, I think @MylesBorins is objecting to the latter—a single specifier (like
'pkg') resolving to different files based on whether it’s referenced viaimportin ESM orrequirein CommonJS. I’m assuming he doesn’t object to the limited “dual packages” support we have in--experimental-modulesnow, where"main"can point to a single entry point in either ESM or CommonJS and deep paths like'pkg/module.mjs'can point to one or more other entry points also in either ESM or CommonJS. That’s the recommended best practice I wrote about above in #273 (comment), that I suggested we add to the docs. I think if we’re not likely to move forward on any other version of dual packages, that’s all the more reason we should write this up and put it in the docs to start setting expectations that this is all that’s likely to ship.And @weswigham, this still applies even if
requireof ESM happens, because people will still want to publish packages that arerequired into CommonJS in older versions of Node. If and whenrequireof ESM lands, this new section of the docs can be updated appropriately.In relation to #323 (comment) I just wanted to say that while the deep import option (
import 'pkg/module.mjs', documented here) does work, it's far from ideal especially from a tooling perspective.It's not so bad if everyone who uses your package only uses it for ESM, but if you are also making a dual package which depends on another dual package you might run into issues.
Suppose you write the following, where you import something from one of those dual packages:
import {something} from 'pkg';
Now suppose you want to transpile it so other people can use both your MJS and CJS modules. Here you run into an issue, because you need to output two different files like the following:
const {something} = require('pkg');
import {something} from 'pkg/module.mjs';
Essentially, the path needs to be different based on the module system you are compiling for. This is also the case for relative imports to files in your own package (since automatic extension resolution was disabled), but that's a relatively easy problem for tooling to solve. This is more complicated, and requires knowledge of the layout of 3rd-party packages (how should it know?), and the expectation that layout will not change.
if you are also making a dual package which depends on another dual package you might run into issues.
Can you give an example? I'm not seeing this in the example you gave above. What's different about dual depending on dual?
@GeoffreyBooth You somehow need to have your ESM modules import that 3rd-party module as
'pkg/module.mjs';while your CJS modules import it as'pkg';.Currently that would mean editing every file that references it after transpiling.
Ideally all one would need to do is transpile it and be done, but how can the transpiler know what to change to do that for you?
Technically the CommonJS and ESM versions of a package are really two separate packages, that just happen to be published in the same folder tree. They don't necessarily behave identically, so it's not safe to assume that they're interchangeable.
If you're outputting a dual package, then all of your package's dependencies need to be the CommonJS ones, at least for the CommonJS version of your dual package. But your ESM version could also use all CommonJS dependencies; there's no reason it needs to be ESM all the way down. Switch to ESM dependencies when you stop publishing a CommonJS version of your package.
then all of your package's dependencies need to be the CommonJS ones
Couldn't they also be dual packages? I've had no problem doing that.
Also, for tree-shaking purposes, it's ideal to have it be ESM as far down as possible.
Couldn't they also be dual packages? I've had no problem doing that.
They'd need to be the CommonJS exports of dual packages, unless you want the CommonJS side of your dual package to only be usable in Node 12+ (where ESM is supported).
Okay, trying to recap @AlexanderOMara's concern (let me know if I'm off):
- I maintain package
minethat depends ondeep-dep. deep-depis using the recommended flow of exposingdeep-dep/cjsanddeep-dep/esm.- I want to support both CJS and ESM in
mine. - To do that, I want to write ESM code and compile it to CJS.
- In my ESM code, I would write something like this:
// file:///mine/lib/mine.mjs import 'deep-dep/esm';
Problem: No compiler is smart enough right now to rewrite that to a working CJS file (if that's even reliably possible). Without manually fixing the compiled code, I will not be able to publish a package that supports both webpack's ESM-only tree-shaking and being required in node.
P.S.: I think @GeoffreyBooth's read of the situation is correct and right now the solution is "if you want to use ESM dependencies anywhere, you have to drop CJS support or write additional code manually".
- I maintain package
I think this deserves its own issue. Apologies if I sounded dismissive, I was only trying to explain how to do this in current
--experimental-modules, not to imply that that shouldn’t change.Off the top of my head I would think that dual packages should continue publishing their ESM entry point in
"module", and CommonJS entry point in"main", and that should provide build tools with all the information they’d need to output the two variations for each version of your package.Closing in favor of nodejs/node#29978.
@guybedford, @jkrems and I discussed the package dual-ESM/CommonJS case and we have a small proposal, based on the current ecmascript-modules implementation:
The
package.json"main"field reverts to its prior CommonJS-only use.A new field
"exports"is created that takes a string like"./src/index.js". This is the ES module entry point."exports"is toimportwhat"main"is torequire.Notes:
"exports"may in the future take an object, preserving design space for the package exports proposal.If
"exports"points to a.jsfile and"type": "module"is not set, an error is thrown similar to the “type mismatch” errors (like using--type=commonjswith an.mjsfile). The error would also instruct the user to add"type": "module"topackage.json. The"exports"field does not imply"type": "module".And that’s it! This should cover the case while preserving design space for future proposals, and for Node potentially switching to ESM by default someday.