nodejs / node-eps

Node.js Enhancement Proposals for discussion on future API additions/changes to Node core
443 stars 66 forks source link

adjusted proposal: ES module "esm": true package.json flag #60

Closed guybedford closed 6 years ago

guybedford commented 6 years ago

Update: This proposal has been updated to reflect the direction I believe to be the simplest and best for the ecosystem - an "esm": true flag in the package.json file.

The reason for this change from module is due to the interactions of module with other conditional main systems resulting in much unwanted complexity. For example browserModule was the natural next step here, which I don't think is a road that would be good for the ecosystem at all.

(added with comment at https://github.com/nodejs/node-eps/pull/60#issuecomment-343136052)

This spec provides a package.json "module" property and resolver implementation approach, to allow distinguishing ES modules in NodeJS. Unlike the In Defense of Dot JS proposal, it excludes supporting the "modules" and "modules.root" properties, while providing alternatives to the workflows these were designed for.

I've found in my own tooling work that having an active resolver spec would help a lot to ensure convergence towards where NodeJS will ultimately be heading. Webpack and Rollup both already implement this package.json "module" property, but the edge cases are not completely clear. If we have a formal active spec for "module", hopefully we can start to get everyone on board with a future-facing solution here.

Trott commented 6 years ago

/cc @bmeck @nodejs/ctc

Trott commented 6 years ago

/cc @jdalton @nodejs/collaborators

Trott commented 6 years ago

/cc @ljharb

mscdex commented 6 years ago

I thought we had pretty much decided against relying on/requiring package.json for ES modules?

Trott commented 6 years ago

I thought we had pretty much decided against relying on/requiring package.json for ES modules?

Not sure it's summed up in a tidy fashion anywhere, though. Best I've found is to go to https://github.com/nodejs/node-eps/pull/3 and use my browser's "find" feature to search for terms like package.json.

ljharb commented 6 years ago

https://github.com/nodejs/node-eps/pull/3/files#diff-0355b3cc08860f0ddbc8338ae885599aR141 summarizes "other proposals", but unfortunately doesn't explain the reasons they weren't suitable.

guybedford commented 6 years ago

To explain the need here, there is a very definite desire from the JS community to continue to be able to use the .js extension. The efforts being made by @jdalton in this direction are very much in response to this, and I think it is important for the Node community to listen to these voices. The goal here is not to try and impose a solution, but just to make sure that there is still an active proposal for an alternative solution to be discussed, whether or not the proposal is accepted.

In terms of this being a breaking change - note that the ES resolver is a new resolver and new behaviour (under this spec). Nothing about the existing CommonJS resolver is being changed under this proposal.

guybedford commented 6 years ago

And thanks all for the feedback so far :)

bmeck commented 6 years ago

This looks like it doesn't interfere with any current plans / tribulations for Node ("use module" from TC39, and .mjs from Node). I think we should frame this as an additive change for convenience of people who can't/won't use other file extensions rather than seeking a full set of support for all workflows.

We should review how existing tooling works compared to this PR and talk about the edge cases mentioned with a vantage point of if it causes conflicts with other plans. I am fairly neutral to this personally since I see it as an additive, so I won't be giving a +/- unless it regards compatibility. Even if this is not merged into Node's core I would like to see tools at least standardizing what they do. I feel this is as good a place to talk about such standardization as any since compatibility with Node is a priority for such tools.

I have some personal life things going on and this PR is not my main focus for a few weeks, but I don't see any glaring incompatibilities that cause conflicts. "use module" is a bit trickier to iron out edges of than this PR; since, as this PR has stated there are 2 resolution algorithms in play.

It does introduce/require package boundaries, but this was already hinted at in the ESM stuff as it would be required to prevent fall through. If nothing else, agreeing upon package boundaries would be a good thing.

For now, and action item would be to get a compatibility table of what "module" means across all the tools out there and post it here in the comments.

not-an-aardvark commented 6 years ago

I'm confused; why is this needed given .mjs? What's the point of "keeping the .js extension"?

To explain the need here, there is a very definite desire from the JS community to continue to be able to use the .js extension.

I've noticed this as well, but is there a justification for this aside from "a lot of people seem to want it"? As someone who previously wanted to keep using .js, I was under the misconception that it was possible to determine whether a file is a module just from its contents. It's possible that some others would prefer .js only because they have that misconception too, and are opposed to adding any out-of-band directive. If that's the case, adding another out-of-band indicator as a replacement for .mjs to be used as an alternative to .mjs wouldn't satisfy those people either. That's why I want to make sure we're actually addressing substantive advantages and disadvantages of .mjs, not just going towards public opinion (even if we take that into account).

bmeck commented 6 years ago

@not-an-aardvark this does not attempt to replace .mjs but co-exist alongside it.

not-an-aardvark commented 6 years ago

I realize that, but I'm a bit averse to adding several different ways to indicate that a file is a module, because I think it would end up being very confusing. (I see how my previous comment was unclear, so I've edited it to clarify.)

MylesBorins commented 6 years ago

@guybedford could you provide a link to the specification reference to the module field?

One advantage to module is that it can work in tandem with main, theoretically making a path forward for module authors to publish repos that can support either loader.

At the same time, having too many ways of doing the same thing is definitely an anti-pattern we should be aware of, and something that can easily confuse / overwhelm new comers.

guybedford commented 6 years ago

As in the In Defense of JS proposal, the goal ultimately is to have module replace main eventually, so that the cognitive overhead does increase a little but then ultimately decreases. Future simplicity as the greater goal. See https://github.com/dherman/defense-of-dot-js/blob/master/proposal.md#modern-users for a great overview of this.

@MylesBorins thanks for the feedback. The module field handling is in the path lookup process at https://github.com/nodejs/node-eps/pull/60/files?diff=unified#diff-b193302c6b58f3c92b9098094d833b64R178, which is given a file system path, and does the file extension / main resolution for it.

I've removed this from the CTC meeting agenda item for today, as it seems like I may have got ahead of the correct process here. Going forward I'd really appreciate any guidance from CTC for how best to discuss this at a high level face to face, so we can start to have that more nuanced discussion over the different levels at which this kind of approach applies.

Thanks for all consideration so far.

mcollina commented 6 years ago

I think package.json is not the correct place for this information. Specifically, we must have fine-grained control over how a given file is parsed: looking for another file to know this is too complicated for me. Even if we can easily detect where is the nearest package.json, there are too many edge cases related when we require within a package.

Moreover, this proposal creates packages built with common.js and packages built using the new ES6 modules. This makes using both within the same application very hard or impossible.

I am in favor of the 'use module' pragma, or if that is not possible .mjs. I am -1 on this proposal.

hax commented 6 years ago

@mcollina As I understand, most projects will eventually use modules for all source files, so we will not need fine-grained control over how a given file is parsed.

ljharb commented 6 years ago

@hax that's the hope, surely - but that fine-grained control is utterly essential for even the possibility of getting there.

Fishrock123 commented 6 years ago

What does this solve that .mjs doesn't? (or also that "use module" doesn't?)

I am not very much in favor of adding multiple ways to do the same thing, that will only end in confusion.

guybedford commented 6 years ago

@Fishrock123 great point and it's important to be clear what we're actually solving certainly. And yes it's a human problem mostly. I know this has been discussed much, and the CTC is far more accomplished at dealing with these human problems, but here is my take on things.

Currently in NodeJS I can write a .js file using any extension I like and have it load correctly. Similarly in the browser. But if you look at packages written on npm, there's almost no examples of users writing JS files without the .js extension. The meaning of the dot .js extension today is mainly for our mime and editor configurations and for our own minds, with no deep requirement in the platforms themselves that we actually use it, yet we do, massively.

The thing here is that most users already write ES modules as .js files. We've given them that gratification, and they are happy. And they don't even know that they don't deserve this treat.

So to now expect those same developers to rewrite all these files to .mjs, is to at some level to take away from them what they already have. They didn't even know there was a problem, and now they have to rename every file in their codebase just to get it to run in their programming language runtime. While this is by no means the fault of Node, it is easy to place that frustration on Node.

Yes there will be many small changes from the handling of interop, named exports, core modules etc in the process of transitioning to actual ES modules from these codebases that will cause problems and frustrations, but we should make the greatest effort, so far as those efforts are not unnecessarily complex certainly, to provide what we can to make users' lives easier in this process - I hope we can agree that ultimately the goal of such a project as NodeJS is to enable developers, not to restrict them.

So in the name of allowing those with this emotional attachment to dot js not have to surrender their ".js" happiness, or having to instead begrudgingly write "use module" at the top of every ".js" file they might ever write again just to hang onto this extension, this is an alternative for that problem.

I don't think there would be an immediate backlash at all for Node to release modules with just ".mjs". But as the bulk of users actually start writing and migrating their primary codebases to ES modules in this way, there becomes a desire for alternatives if that frustration is too much. @jdalton has already made it clear that he is working on a loader for NodeJS to retain a ".js" extension approach and will be sharing this approach widely. It only seems productive to have a plan for handling such possible outcomes.

I'm not saying we should ship this approach today by any means, I'm just saying that having an approach and an active, relevant and current approach that is compatible with the way modules work in Node through the current PR implementation while being compatible with ".mjs" and "use module", is important to be able to say to those who question ".mjs" that there is an alternative, that it has been thought of, and that if they are interested there is a link to this proposal (or another), where they can engage, participate and help it become popular enough to gain adoption. If there is no interest, certainly lets drop it in future. But having users just see 5 proposals all abandoned would only lead to more frustration, so I'm just trying to provide some path here that people might get on board with, preferably without adding to that list!

jkrems commented 6 years ago

The thing here is that most users already write ES modules as .js files.

One very small (but important, I think) detail is that they write ES modules using what are basically CommonJS semantics, especially around loading. E.g. they assume that code gets executed without event loop ticks, they assume that all the wild path resolution rules used by node, including magical file extensions, "just work the same way", they make use of CommonJS-y identifiers like __filename, they assume that any CommonJS module can just synchronously load one of those "ES6 module" and start using it, ...

Taking that code and assuming it would also work in a true ES6 module environment is a rather big assumption. At least from some initial exploration in our own code base, it doesn't really work out. So splitting those ""ES modules"" that end with .js from actual ES modules that end with .mjs could almost be a feature.

guybedford commented 6 years ago

@jkrems I think the major pain point in most codebases will simply be the lack of named exports support for CommonJS. Circular references and bindings will break in certain cases. And then I don't think the event loop change will break much code as ES modules do execute synchronously together. __filename and globals will certainly bite though too.

Certainly .mjs could be used as a marker by those so inclined in the way you suggest, but I don't think that subtracts from the possibility of discontent over being forced into ".mjs".

Maybe everyone really will be completely fine moving to mjs, and I hope that could be the case... I'm just aiming to see that we can have an active "under consideration" or "draft" status proposal ready as an option. It could sit as a draft for years quite happily, but at least be there to absorb those democratic ".js"-seeking urges should they arise.

jkrems commented 6 years ago

No worries. Just wanted to add that detail to paint a more complete picture because your description of the healthy .js-ES-modules ecosystem seemed a bit too idyllic. ;)

P.S.: My own personal preference would be: Just drop any implicit compatibility between CommonJS and ES modules, period. Dynamic import is the only way to get ES modules from CommonJS, import.meta.require is the only way for the reverse. No magic fields, no file extensions, possible even separate path resolution rules. One can always dream... :D

Fishrock123 commented 6 years ago

I'm not really sure how "edit these filenames" along with "edit all this code" is particularly burdensome other than potential tooling issues?

Perhaps I missed the point, the post is long and I'm somewhat unwell.

Also maybe I'm missing something, but, if "use module" is a well working thing, I could forsee us just going that route.

ljharb commented 6 years ago

Also, it's quite common to use .jsx or some other nonstandard extension to mean "this JS requires Babel" - the airbnb config, for example, requires this. Using a different extension - even .js - is a pretty trivial thing to transpile/convert at build time to .mjs, so even with that extension, you aren't "forced" into anything.

not-an-aardvark commented 6 years ago

@Fishrock123

Also maybe I'm missing something, but, if "use module" is a well working thing, I could forsee us just going that route.

I'm not a fan of this solution because I think one of the biggest advantages of modules is having strict mode enabled by default. Making users add a directive would mostly defeat this advantage, regardless of whether it's "use module" or it's a package.json opt-in.


@guybedford

So to now expect those same developers to rewrite all these files to .mjs, is to at some level to take away from them what they already have.

I don't understand this point. CJS modules would still be completely supported with .mjs implemented, and we're not saying people have to switch. (In fact, the .mjs proposal would make it easier to migrate gradually or not migrate at all, since ES modules would be completely interoperable with CJS modules.) Implementing .mjs would only affect developers that are explicitly interested in doing a migration anyway.

To me, this is like adding support for async functions, which are sometimes a replacement for Promise#then. Promise#then is still completely supported, and you don't have to use async functions at all. But if you do choose to use them, there is naturally a migration cost (you have to update your codebase to replace uses of Promise#then). Similarly, if you choose to use ES modules, there is naturally a migration cost (changing your syntax to use import declarations, and changing the file extension).

Given that migration is completely optional, I think changing the file extension is a very small migration cost compared to other migration costs that will be necessary regardless (such as updating the code to use import syntax).

edit: fixed missing word

guybedford commented 6 years ago

I'm not really sure how "edit these filenames" along with "edit all this code" is particularly burdensome other than potential tooling issues?

@Fishrock123 in the utility calculus of burdens, my argument boils down to that both approaches might create a psychological burden - the file extension for those attached to .js and "use module" I think we can agree is more work to add than just writing a single package.json property, especially if considering that user would need to write it in every js file they ever write again. These are small costs, small things, but it's about ensuring the best developer experience, and ensuring those not happy have alternatives.

So to now expect those same developers to rewrite all these files to .mjs, is to at some level to take away from them what they already have.

I don't understand this point.

@not-an-aardvark the transition I'm talking about here is specifically users who have already written js files as ES modules, with a compile step to publish CommonJS to npm (or production), who then want to transition to publishing those ES modules directly.

not-an-aardvark commented 6 years ago

the transition I'm talking about here is specifically users who have already written js files as ES modules, with a compile step to publish CommonJS to npm, who then want to transition to publishing those ES modules directly.

Is there a feasible path for these users that doesn't involve migration anyway? If these projects used import {foo} from 'bar' as an equivalent of let foo = require('bar').foo (using the Babel semantics), then they will have to migrate because that doesn't work with actual ES modules.

In my opinion, this is the expected result of using unsupported features with a build step before they're finalized, and people using a compile step have opted into this. I don't think it's realistic for users to expect to not have to do any migration at all after using a Babel transform that was only speculating about the upcoming semantics.

guybedford commented 6 years ago

@not-an-aardvark certainly those upgrade changes will be a cost regardless. But that doesn't mean we can't make a best effort to improve what we can, and reduce those frustrations as much as possible.

not-an-aardvark commented 6 years ago

But that doesn't mean we can't make a best effort to improve what we can, and reduce those frustrations as much as possible.

Agreed. I'm just not really convinced that an alternative to .mjs is needed, because (a) the migration cost seems very small (renaming a file is simple), and (b) any migration cost that comes from renaming the file would be overshadowed by other necessary migration costs anyway.

guybedford commented 6 years ago

Agreed. I'm just not really convinced that an alternative to .mjs is needed, because (a) the migration cost seems very small (renaming a file is simple), and (b) any migration cost that comes from renaming the file would be overshadowed by other necessary migration costs anyway.

It's not a clear-cut statement by any means because it's all about how users respond, which is very hard to tell until mjs is released. What I'm proposing is to have this ready as a viable proposal to be moved forward in response to if it turns out that mjs is not enough for users.

mcollina commented 6 years ago

I am -1 as landing this even as draft. It is definitely the wrong call for me.

babel-modules are marketed as ES6 modules, while in fact they are not because of the completely different runtime behavior. Changing or support this is not our responsibility. Anything that is using .js with babel-modules is not going to work as-is with ES6 modules, because of the difference in runtime behavior. These users will need either some refactoring or to keep using babel. The current specification requires support from the engines, which limit our possibilities. The two approaches is a) get involved with TC39 and define a specification that is what the community expect, or b) convince the runtime engines to implement the behavior that users are expecting. I think we are too late for a) and b) is not viable.

guybedford commented 6 years ago

@mcollina as discussed in previous comments, yes there is a breaking transition here, but that doesn't mean we can't make it as good as possible.

b) convince the runtime engines to implement the behavior that users are expecting. I think we are too late for a) and b) is not viable.

Care to elaborate on why b) is not viable? Just because it's not possible to provide all the exact expectations users have right now on es module semantics, doesn't mean we can't do our best to meet those expectations so far as Node can provide that without needless complexity or performance loss.

mcollina commented 6 years ago

The problem is that the runtime behavior is different, and because it is a language feature, not something we add at runtime, such behavior is partially implemented in the VMs. In the case of Node.js, this means ChakraCore and V8.

In babel-modules, the loading module is synchronous, while in ES6 modules it is asynchronous. Moreover, in babel modules, the full code is run on import. In ES6 modules, the exports are detected, and the code is run in a future phase. Now, this mandates changing the language.

guybedford commented 6 years ago

I don't think that the async v sync effects of execution will cause any breaking changes in most code. ES modules still execute synchronously in order of import, they just wait on the event loop when running that first import, which is something that affects the dynamic import behaviour.

Named exports being static is well understood as well in Babel ecosystem, although yes there may be cases where mutations bite, but these are the edge cases users will have to accept. As mentioned in https://github.com/nodejs/node-eps/pull/60#issuecomment-317107969 the main problem I think will be the lack of named exports on CommonJS modules imported from ES modules.

I agree there is nothing that can be done about these breaks, but I'm not sure how this relates to whether or not it should be possible to define ".js" files with a package.json modules property to be parsed as ES modules in Node.

Rich-Harris commented 6 years ago

Rollup author here, just wanted to weigh in with a tooling perspective. First off, the premise of this discussion — there definitely is value in preserving the .js extension, which is evident from the fact that a lot of people apparently feel quite strongly about it. If people want to keep .js, then there is value in doing so even if you think their reasons are silly. And I don't think the reasons are silly — we didn't start writing .ajs files to use arrow functions or .bjs files to use block scoping, and as far as most people are concerned modules are just another ES2015 feature. (The fact that there's an important distinction between parse goals is a technical matter that most people shrug at.) People are aware that the impetus for .mjs is because of edge cases that stem from Node's existing behaviour, so there's a tail-wagging-the-dog appearance that is likely to cause resentment. Whether or not these feelings are rational is largely irrelevant.

So I think this discussion should focus on the proposal itself, and not on whether it's better or worse than .mjs which is surely a separate issue.

Separately, I think that the very existence of "module" elsewhere in the ecosystem should be a dealbreaker for its use in node core - in other words, the only way a new package.json property should be accepted is if ZERO packages are published with it.

I have to disagree with this — tools that support "module" such as Rollup or Webpack do so precisely because it was a plausible route forward for publishing ESM and CJS simultaneously. The way it's being used in the wild does not conflict with the proposal here. That argument is rather like saying 'we can't ship this new browser API because people are already using a polyfill for it'.

I'm not a fan of this solution because I think one of the biggest advantages of modules is having strict mode enabled by default. Making users add a directive would mostly defeat this advantage, regardless of whether it's "use module" or it's a package.json opt-in.

Strongly agree with @not-an-aardvark.

Is there a feasible path for these users that doesn't involve migration anyway? If these projects used import {foo} from 'bar' as an equivalent of let foo = require('bar').foo (using the Babel semantics)

Just to clarify, these aren't the Babel semantics. The require itself obviously behaves differently from import, but the actual behaviour is this...

// input
import {foo} from 'bar';
console.log(foo);

// output
let bar = require('bar');
console.log(bar.foo);

...which preserves the correct behaviour around live bindings and cycles. My expectation is that the differing semantics around loading and evaluation will affect a very small minority of projects. Meanwhile, Rollup users shouldn't even have to worry about that.

P.S.: My own personal preference would be: Just drop any implicit compatibility between CommonJS and ES modules, period. Dynamic import is the only way to get ES modules from CommonJS, import.meta.require is the only way for the reverse. No magic fields, no file extensions, possible even separate path resolution rules. One can always dream... :D

🙌 A thousand times this. It seems to me that all these problems stem from the assumption that import './common.js should work. I haven't yet heard a convincing explanation why that's necessary — certainly why it's preferable to .mjs.

In short, I like this proposal — certainly more than "use module", and I'd love to see this seriously considered as an alternative to .mjs.

mcollina commented 6 years ago

The problem with this proposals is that, before loading any javascript file, Node.js would have to resolve the nearest package.json, and parse that. Currently we are doing it only for things in node_modules, and only to know which one is the main. I think it would be very hard to implement this proposal in a way that does not cause slowdowns in loading applications composed of 100s of files.

This proposal is going to effect load times for all javascript files, not just ES6 module users. Specifically this is problematic for me. This is needed to support import for any common.js modules (I do agree that a world without that compatibility would be simpler, but it is a needed feature).

'use module' and mjs are both superior in this regard, as they allow us to know what to do with the file without checking another file. Moreover, 'use module' could tell the engine what to do when it is parsing it.

I do not discard as 'silly' any part of the discussion related to the .js extensions.

(I will leave to somebody else more qualified than me to explain how much ES6 modules differs from babel modules, see this).

That being sad, I am happy to evaluate a PR that implements this behavior and check if it introduces problem either in performance or total memory consumption (because of caching). If there are no regressions, I have little objections. However, I am still -1 to land this proposal, because I do not think it is feasible.

tniessen commented 6 years ago

In fact, @jasnell has already answered many questions in his posts (e.g. here) about the ES6 module specification and how it could be implemented within node. (He also explained why babel does not implement ES6 modules conforming the the specification.)

I think using "use module" or .mjs is not too bad; it is far from optimal, but I don't think anyone considers this draft to be optimal either. So far, the best option seems to be to decide whether it is ES6 or CJS based on the result of the parser:

One proposal is to ensure that a JavaScript file can be unambiguously parsed as either an ESM or something else. In other words, when I parse a bit of JavaScript, the fact that it is an ESM or not should be obvious by the result of the parse operation. This approach is called “unambiguous grammar”. Unfortunately, it’s a bit trickier to accomplish that it may appear. (source by @jasnell)

Any news regarding this James?

tniessen commented 6 years ago

That being sad, I am happy to evaluate a PR that implements this behavior and check if it introduces problem either in performance or total memory consumption (because of caching). If there are no regressions, I have little objections.

Even if it does not cause regressions, we should be really careful to implement it, and definitely not rush it.

Rich-Harris commented 6 years ago

This is needed to support import for any common.js modules (I do agree that a world without that compatibility would be simpler, but it is a needed feature).

Can you elaborate on why (and why import.meta.require is insufficient)?

Definitely not disputing that Babel semantics differ from true ES module semantics — just suggesting that they differ in a way that won't affect the vast majority of Babel users.

jkrems commented 6 years ago

Can you elaborate on why (and why import.meta.require is insufficient)?

I think it was my mistake to mention (make up?) import.meta.require. I was just dreaming up a perfect world where all TC39 decisions are immediately reflected in V8 in node. In reality, the big downside of relying on import.meta.require is that it requires a feature in V8 (import.meta) that - afaik - didn't even make it to the "there's a ticket for it on bugs.chromium.org". So depending on it being shipped and pulled into node would push the timeline back considerably.

ljharb commented 6 years ago

Importing CJS and requiring ESM is critical - consumers shouldn't have to know the module format you chose, and changing module formats shouldn't have to be a breaking change.

If it is, then that's a barrier to runtime ESM adoption (Babel doesn't count, since it's prior to runtime), and every barrier increases the risk that ESM will be DOA, and simply never get a critical mass of adoption. That would be a serious loss for the ecosystem.

Separately, "users want it" is always important to consider, but does not automatically correlate to "it has value". There are certainly users who would be more inconvenienced by using .mjs, but there are zero users (afaik) for whom it will be impossible to use ESM - whereas with most of the proposals that stick with .js, there are nonzero users whose use cases will be impossible. You're correct, however, that the merits of .mjs over anything else aren't necessarily important to this discussion - but if we all agree not to relitigate .mjs, then it does become a discussion of the incremental value of this proposal, weighed against the damage caused by having multiple ways to determine parsing goal.

unional commented 6 years ago

If it is, then that's a barrier to runtime ESM adoption (Babel doesn't count, since it's prior to runtime), and every barrier increases the risk that ESM will be DOA

I don't get this argument. To the user of Babel and TypeScript, it's already "landed" and people are already using it. Yes, I understand there are some semantic differences and some adjustments are needed, but to me, it is just a step in the process (CJS -> Babel/TS "pseudo" ESM -> ESM).

People who have authored their library in the "Babel/TS pseudo ESM" way is not likely to go back. The ship has sailed. There is already a clear pathway to transition from CJS to Babel/TS, now we just need to find a way to transition from there to ESM, no?

By the way, we all know that browsers are already getting ready for ESM, right? https://jakearchibald.com/2017/es-modules-in-browsers/

ljharb commented 6 years ago

Authoring format isn't what's important here wrt the benefit I'm hoping for - I want to see ESM published everywhere, reached gradually with a seamless transition path from CJS and others, so that one day it's the only module format that needs to exist. Authoring format might as well be a custom macro.

unional commented 6 years ago

I'm with you on wanting only one module format to exist in the future.

I feel like there are some contradictions here. On one hand, we say authoring format doesn't matter (i.e. a build tool is needed, always), on the other hand only one module format (ESM) needs to exist.

Also, the separation between .js and .mjs actually promoting two module formats.

There are two consumers of the ESM module, the machine, and human. The tool, such and Babel and TS, would like to keep the source as close to the resulting code as possible. That means the way to author the code should change and adapt. And it is in the benefit of the author to use the same semantic anyway.

For this proposal, what I can see is:

  1. Get transpiler to do that work in transpiling the code to CJS or ESM.
  2. Modules are published in both CJS (main) and ESM (module)
  3. NodeJS can support ESM in .js
  4. Switch over.

Of course, this is a very rough view and there are lots of challenges associated with it. But if we can fix postpublish, can we fix this in a similar manner?

One benefit of letting the transpiler to do the work instead of NodeJS is it doesn't need to do it on the fly in runtime.

My naive two cents.

Rich-Harris commented 6 years ago

Importing CJS and requiring ESM is critical - consumers shouldn't have to know the module format you chose, and changing module formats shouldn't have to be a breaking change.

I don't think that having some basic awareness of what you're putting in your application or library is all that onerous 😀 We've been there before with e.g. CJS and AMD, and we all muddled through.

If it is, then that's a barrier to runtime ESM adoption (Babel doesn't count, since it's prior to runtime), and every barrier increases the risk that ESM will be DOA, and simply never get a critical mass of adoption. That would be a serious loss for the ecosystem.

Two counter-arguments:

not-an-aardvark commented 6 years ago

I don't think that having some basic awareness of what you're putting in your application or library is all that onerous 😀 We've been there before with e.g. CJS and AMD, and we all muddled through.

If I have to do something slightly awkward to use your CommonJS package in my ES module package, I'm more motivated to raise an issue, open a PR, or switch to an alternative package that does offer ESM. In that way, a small amount of friction is very likely to accelerate adoption.

Wouldn't this create a big compatibility issue? If CJS modules are imported differently from ES modules, then popular libraries won't be able to switch to ES modules without breaking all their users. If, instead, a module's type is an implementation detail that can be changed without affecting consumers, then it will be much more feasible for libraries to upgrade. I think the point is more that applications shouldn't have to care what type of modules their dependencies are using, because then the module type would become a part of the public API and would be difficult to change.

Something that really doesn't get discussed nearly enough: if file foo.js can import a CommonJS file called bar.js, then foo.js will only work in Node. I cannot import it in a browser, but I can't determine that from looking at the file. By allowing the possibility that an ES module might eventually import some CommonJS, we are setting browsers up to fail — that is surely a far greater threat to the ecosystem.

Isn't this a problem regardless of whether the syntax is the same? For example, if foo.js imports an ES module called bar.js using import statements, and bar.js imports an ES module called baz.js using import.meta.require, then foo.js still won't be importable in a browser, and it won't be possible to determine that by looking at foo.js. I think the only solution to that issue would be to disallow using CJS modules from ES modules entirely (which I don't think anyone is arguing for).

unional commented 6 years ago

If, instead, a module's type is an implementation detail that can be changed without affecting consumers, then it will be much more feasible for libraries to upgrade.

To be honest, as a user, I would rather know about the change and decide what to do with it. Changing the module format is not trivial, especially in larger libraries. As a user, I would rather have it as a major version release instead of a minor or even patch release. So that I have more confidence that my consuming code is not going to be affected by any accidental bugs introduced during the conversion process.

not-an-aardvark commented 6 years ago

As a user, I would rather have it as a major version change instead of a minor or even patch release. So that I have more confidence that my consuming code is not going to be affected by any accidental bugs introduced during the conversion process.

I think it would be better to catch accidental bugs by using tests, not by making every user of the package manually upgrade out of fear of implementation errors.

Rich-Harris commented 6 years ago

Wouldn't this create a big compatibility issue? If CJS modules are imported differently from ES modules, then popular libraries won't be able to switch to ES modules without breaking all their users.

That's exactly what Guy's proposal is about. If a package has both "main" and "module", it can be consumed as either ESM or CJS, the implication being that "main" would eventually be phased out.

I think the only solution to that issue would be to disallow using CJS modules from ES modules entirely (which I don't think anyone is arguing for).

I've argued for it 😉 More JavaScript runs in browsers than in Node. By making it possible to import CJS in ESM, we're choosing to favour Node at the expense of browsers. I strongly believe that that's a mistake. (The transitional period, while we're still heavily reliant on CJS, can be taken care of with transpilers and bundlers — exactly what we're doing now.) Again, this would accelerate adoption of ESM, not inhibit it.

But since that seems to an unpopular opinion around these parts, something Node-specific like import.meta.require seems far preferable to using import for both ESM and CJS given the different semantics, even if you do eventually run into the same problem. Hell, browsers could even implement import.meta.require:

import.meta.require = () => {
  throw new Error('This module is not compatible with browsers. Go yell at the maintainers');
};
not-an-aardvark commented 6 years ago

That's exactly what Guy's proposal is about. If a package has both "main" and "module", it can be consumed as either ESM or CJS, the implication being that "main" would eventually be phased out.

I see, thanks for clarifying. I still have significant usability concerns along the lines of https://github.com/nodejs/node-eps/pull/60#issuecomment-317206062, but I agree that there wouldn't need to be a large one-time compatibility break.

something Node-specific like import.meta.require seems far preferable to using import for both ESM and CJS given the different semantics, even if you do eventually run into the same problem.

Could you clarify why? I understand how it would be useful to determine whether a file is browser-compatible by only looking at the file itself, and without traversing the dependencies. But since the dependencies themselves might use import.meta.require, it seems like traversing the dependencies would be necessary either way.


It seems like "adoption" is being used to describe two different goals in this conversation, and I think it's important to distinguish them:

  1. Allowing future packages and applications to be written in ES modules, as easily as possible
  2. Encouraging existing CJS packages to convert to ES modules, as easily as possible

Requiring a different syntax to import different types of modules would hurt goal 1, because needing to distinguish whether dependencies use CJS increases cognitive load for future users. I'll grant that it might help goal 2, because users could be more inclined to make PRs to dependencies (although it still seems inconvenient for a package to have two separate entry points).

I think goal 1 is more important than goal 2. In theory, it would be great to have all existing packages switch to ESM and be browser-compatible, but I don't think that's realistic; many existing packages are unmaintained, and it seems like the entire dependency tree would have to be converted in order for a package to be usable in browsers. On the other hand, I think it's very important for future packages to adopt ESM, because that would provide benefits to users such as strict mode by default, without needing to wait for the rest of the ecosystem to migrate.