You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Sep 2, 2023. It is now read-only.
Repository navigation
This repository was archived by the owner on Sep 2, 2023. It is now read-only.
Simplifying package.json of backward-compatible packages (type: "module") #432
As asked by @GeoffreyBooth in #323 I'm opening new issue for this discussion
The "type": "module" solution for publishing MJS modules had an issue. Specifically was not possible to publish package that cold be simultaneously consumed in following ways (without publishing separate package like lodash-es):
const lodash = require('lodash') in old node (for projects without upgraded node)
const lodash = require('lodash') in new node (for projects without upgraded code)
import lodash from 'lodash' in new node (for projects with upgraded node and code)
According to interesting comment by @jkrems the current experimental solution for this is to run node with --experimental-conditional-exports and publish lodash with following package.json
Needless to say this seems quite complicated package.json what should probably be the default for all migrated packages.
Additionally it requires lodash to rename all its current CJS-compatible files to .cjs extension, while the current standard practice is to put all modern code to one directory (e.g. src), and all compiled code to other (e.g. lib). Additionally "type": "module"
I'm opening this issue to suggest that this issue needs to be solved before these features go stable, discuss this issue, and suggest one solution myself. Ideal solution would:
The solution I suggest is to:
Leave main to mean CJS compatible entrypoint without ability to change its meaning with "type": "module". This ensures 1. and 2. can be always handled properly.
When consumer uses import instead of require, make node use already implemented "conditional-exports" logic and look at exports field of package.json + ignore main field
Without "type": "module" there is no need for conditional conditional-exports because they are currently used to override back what it changes. Even worse, using conditional-expressions in any form for CJS modules makes these changes not backward compatible, because old nodes would not know how to interpret this.
The final package.json would be as follows (thanks to already implemented exports sugar):
which looks like much better alternative. Additionally if lodash would like to support importing subpaths like import first from 'lodash/first', the package.json is equally simple:
I'm not asking to do anything (or remove) support for exports field in package.json as designed currently, but rather to not make "type": "module" stable and instead deprecate it.
Note that CommonJS supports "exports" so the whole point of these schemes exactly is to be explicit about the fact that there is a bifurcation happening here.
Additionally it requires lodash to rename all its current CJS-compatible files to .cjs extension, while the current standard practice is to put all modern code to one directory (e.g. src), and all compiled code to other (e.g. lib). Additionally "type": "module"
This isn’t correct. A package can have multiple package.json files in different folders; each one starts a new package scope. So for your example, you could have:
Because "exports" is not exclusive to ES modules. If your package gets used via require, what’s defined in "exports" will be used. "exports" overrides "main".
@GeoffreyBooth But I don't use require in exports, so it should fallback to main for this kind of import. At least it would be a good default behavior to avoid duplication in package.json
@GeoffreyBooth But I don't use require in exports, so it should fallback to main for this kind of import
That’s not how "exports" is designed. "exports" applies equivalently to CommonJS and ES modules. If a version of Node that supports "exports" is being used, what’s in "exports" will take precedence over "main" for both CommonJS and ES modules. The only time anything defined within "exports" differs between the two modes is via --experimental-conditional-exports.
And before you ask why can’t this not be the case, why does "exports" need to apply to CommonJS at all, the answer is because the group decided to preserve CommonJS as an equal to ES modules within Node. So whatever features get added to ES modules get supported in CommonJS as well, whenever possible.
Could you update this guide to tell how to create Dual Package without using .cjs or .mjs extensions and still supporting code that uses require('lodash') and require('lodash/first') on older node (and node that supports exports)? Even better publish some example "upgraded" package as an example?
You can’t have a dual package without two extensions, or, without using multiple package.jsons.
I hope that unflagged modules will allow to publish single package.json and just tell that some subfolder (e.g. src) contains es6 modules, without using .cjs or .mjs extensions anywhere.
There's nothing wrong with the approach of putting a package.json with { "type": "module"} in a subdirectory to do the above.
An important property of the system though is that it is possible to determine the module format of a file in this way without having to rely on assuming how the consumer got to the file. This is an important property for a module registry as the registry is a file-pathed namespace.
Could you just show how it should look like if both packages need to support subpath imports? i.e. what should be package.json and dist/package.json if all of following are possible:
const lodash = require('lodash') // in old node
const first = require('lodash/first') // in old node
const lodash = require('lodash') // in new node
const first = require('lodash/first') // in new node
Then it means import first from 'lodash/first.js' is better for these group members?
Not necessarily, because that is in consumer code, outside of the package itself. Think of it this way: to use a package in a browser context, you need to pull in an entry point somehow, e.g.:
The URL you use in a browser is separate from anything defined in package.json.
But then within that index.js or first.js, it’s more compatible to have relative references that browsers can resolve. So for example within index.js, there might be:
export*asfirstfrom'./first.js';
This will work in browsers, whereas export * as first from './first' will not (unless you have special mappings set up by your webserver).
There’s also something called import maps coming to browsers (in Chrome already) that provides a browser equivalent to "exports"’ path mappings.
Import maps are already shipping behind a flag in chrome and will be
unflagged in the not too distant future.
Anything defined in an export map will be able to be used to statically
generate an import map 🎉
On Sat, Nov 16, 2019, 1:24 PM Geoffrey Booth ***@***.***> wrote:
Then it means import first from 'lodash/first.js' is better for these
group members?
Not necessarily, because that is in consumer code, outside of the package
itself. Think of it this way: to use a package in a browser context, you
need to pull in an entry point somehow, e.g.:
import _ from 'https://somewhere.com/lodash/es/index.js';
// or
import first from 'https://somewhere.com/lodash/es/first.js';
The URL you use in a browser is separate from anything defined in
package.json.
But then *within* that index.js or first.js, it’s more compatible to have
relative references that browsers can resolve. So for example within
index.js, there might be:
export * as first from './first.js';
This will work in browsers, whereas export * as first from './first' will
not (unless you have special mappings set up by your webserver).
—
You are receiving this because you are subscribed to this thread.
Reply to this email directly, view it on GitHub
<#432?email_source=notifications&email_token=AADZYV725AUDILG4XGILBADQUBQHPA5CNFSM4JOE2K62YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEEH24II#issuecomment-554675745>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AADZYV5PQLFB545ZZVRAPMTQUBQHPANCNFSM4JOE2K6Q>
.
@MylesBorins I'm just trying to figure out what our goals here are. Do we want modules to be usable unmodified in browsers or are we okay with tooling being needed?
I fail to see where is issue making --es-module-specifier-resolution=node a default given that import maps solve this issue in browsers and they can be statically generated.
Using explicit extensions in consumer code seems like antipattern because such code won't survive or allow refactors in packages themselves (for example: changing .js extension to .mjs or vice versa, or moving utils.js to utils/index.js if it becomes complex enough).
My experience tells me that using node modules in browser environment without tooling can never be performant and is usable only in development, I don't believe this can be solved with modules.
Can we move this to another thread this is off topic.
But tldr there is a huge difference between tooling that can generate meta
data at install time vs transpilation
On Sat, Nov 16, 2019, 2:09 PM Gus Caplan ***@***.***> wrote:
@MylesBorins <https://github.com/MylesBorins> I'm just trying to figure
out what our goals here are. Do we want modules to be usable unmodified in
browsers or are we okay with tooling being needed?
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#432?email_source=notifications&email_token=AADZYV2DXE2YEQGOFHQUPLTQUBVSHA5CNFSM4JOE2K62YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEEH3VFA#issuecomment-554678932>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AADZYV6QEISHNHAYBG6LP2DQUBVSHANCNFSM4JOE2K6Q>
.
Import maps cannot generate file extensions they can allow you to convert a
specifier into a URL.
If you wanted to write code in a repo with 0 file extensions for any import
you would need an entry for every file, which is fairly verbose.
If we turned on the resolution algorithm by default we would lose the
static nature of the current implementation. We would have no way to
determine the full path without multiple pings to the filesystem.
This is actually a non trivial performance issue with nodes cjs algorithm
On Sat, Nov 16, 2019, 2:11 PM Myles Borins ***@***.***> wrote:
Can we move this to another thread this is off topic.
But tldr there is a huge difference between tooling that can generate meta
data at install time vs transpilation
On Sat, Nov 16, 2019, 2:09 PM Gus Caplan ***@***.***> wrote:
> @MylesBorins <https://github.com/MylesBorins> I'm just trying to figure
> out what our goals here are. Do we want modules to be usable unmodified in
> browsers or are we okay with tooling being needed?
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <#432?email_source=notifications&email_token=AADZYV2DXE2YEQGOFHQUPLTQUBVSHA5CNFSM4JOE2K62YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEEH3VFA#issuecomment-554678932>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AADZYV6QEISHNHAYBG6LP2DQUBVSHANCNFSM4JOE2K6Q>
> .
>
On Sat, Nov 16, 2019, 3:39 PM Evan Plaice ***@***.***> wrote:
Isn't the root issue with Node's resolution algoritm? That -- what is
cheap and easy ona local FS -- becomes a perf deal breaker in browsers?
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#432?email_source=notifications&email_token=AADZYV5XOEONMY3Y7ZLJ3FLQUCADZA5CNFSM4JOE2K62YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEEH5EHA#issuecomment-554684956>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AADZYV2XGP7VKEHUA7GLBITQUCADZANCNFSM4JOE2K6Q>
.
I fail to see where is issue making --es-module-specifier-resolution=node a default given that import maps solve this issue in browsers and they can be statically generated.
Import maps are a more-or-less direct equivalent to "exports", in that they allow a mapping of a public API. They’re a poor tool for bringing automatic extension resolution to the Web. As the import maps README puts it:
Although this example shows how it is possible to allow extension-less imports with import maps, it's not necessarily desirable. Doing so bloats the import map, and makes the package's interface less simple—both for humans and for tooling.
My experience tells me that using node modules in browser environment without tooling can never be performant and is usable only in development, I don't believe this can be solved with modules.
This shouldn’t be the case for too much longer, if it isn’t already a thing of the past. Technologies like HTTP/2 are designed to make the need to bundle JavaScript no longer necessary from a network performance perspective; the idea behind encouraging public packages to be browser-compatible by default is to lessen (or eliminate) the need for bundling for compatibility reasons as well.
Using explicit extensions in consumer code seems like antipattern because such code won't survive or allow refactors in packages themselves (for example: changing .js extension to .mjs or vice versa, or moving utils.js to utils/index.js if it becomes complex enough).
That is the benefit of filenames without extensions, yes. The question is whether that benefit is more important than pushing public package authors toward a future where universal JavaScript is the default, rather than something achieved only through tooling. That’s a subjective call that different people will feel differently about.
And it’s not quite a zero-sum game. A tool like a Babel plugin could certainly rewrite specifiers such as './file' into './file.js' or './file/index.js' as part of a build process. So then an author would still get the refactorability of extensionless specifiers, while Node and the browser community would get the benefits of explicit extensions in published source files. Once native ESM becomes more popular in Node after it ships, I expect to see lots of tools to help with things like this.
This has been a good discussion and a good review of the implementation as it is currently designed, but aside from answering any other questions you may have, is there any specific feedback you’d like to see addressed? Obviously we can’t make everyone happy with every decision we’ve made, but I hope we’ve at least explained them such that you understand why things came out the way they did. I also hope that those design decisions can be ones that you at least can work with, even if they’re not quite what you might have chosen had it been entirely up to you.
Technologies like HTTP/2 are designed to make the need to bundle JavaScript no longer necessary from a network performance perspective
Performant web apps require much more than simple response multiplexing. They involve tree shaking, minifying, ordering resources in proper order, deduplicating imports, adding polyfills for legacy or mobile browsers, pre-rendering, small image inlining, among others. Processing and bundling on production needs to happen even if it happens on-the-fly.
Although this example shows how it is possible to allow extension-less imports with import maps, it's not necessarily desirable. Doing so bloats the import map, and makes the package's interface less simple—both for humans and for tooling.
If processing needs to happen anyway for production application for the reasons above, either statically or in cached-on-the-fly way, all import paths can be inlined, so even if in code there is import fp from "lodash/fp" the browser can receive import fp from "lodash/fp.js". Import maps are good for avoiding this extra processing, but only in development. I can't see any way performant production app would just import unmodified .js, .mjs, or .cjs files.
A tool like a Babel plugin could certainly rewrite specifiers such as './file' into './file.js' or './file/index.js' as part of a build process. So then an author would still get the refactorability of extensionless specifiers, while Node and the browser community would get the benefits of explicit extensions in published source files.
With this approach ultimately the only reason to build project for node would be to apply node resolution logic which is very weird. And yes, Babel can rewrite ./file to ./file.js for browser so slow import maps are not necessary. But for development building is not needed thanks to them.
This has been a good discussion and a good review of the implementation as it is currently designed, but aside from answering any other questions you may have, is there any specific feedback you’d like to see addressed?
I'd just like to express my concern that unflagging new modules without making --es-module-specifier-resolution=node a default will slowdown or prevent upgrade of existing node packages and applications for following reasons:
Need to change extensions of files in repository to either .mjs or .cjs
Need to resolve node imports to their exact extensions in code
Manually adding exports entries to package.json
Keeping track which dependencies are es6 and which are cjs imports
Doing everything above gradually because upgrading whole codebase at once is very hard
If performance is concern paths can be rewritten either when bundling (on-the-fly or not) for browser, installing packages with npm/yarn or publishing packages to registry.
I can suggest one more alternative that seems good for both sides: --es-module-specifier-resolution=js which is would be simplified resolution algorithm that just adds .js extension if no extension is present in import, and given imported name is not in import maps.
It seems that most of this discussion is rendered irrelevant because { "type": "module" }, import, export has been made stable and --es-module-specifier-resolution=node has not been made default. I'll open another issue that focuses just on assuming default extension when importing.
Activity
ljharb commentedon Nov 16, 2019
Packages don’t just have one entry point; they have infinite - as such, a map of exports is always needed.
Using only the directory doesn’t allow for selective exposing/hiding of files within the same directory.
sheerun commentedon Nov 16, 2019
I'm not asking to do anything (or remove) support for
exportsfield in package.json as designed currently, but rather to not make"type": "module"stable and instead deprecate it.guybedford commentedon Nov 16, 2019
@sheerun you do not need to use type module if you don't want to.
An alternative configuration to do the same thing would be the following:
Note that CommonJS supports
"exports"so the whole point of these schemes exactly is to be explicit about the fact that there is a bifurcation happening here.sheerun commentedon Nov 16, 2019
@guybedford Why can't I write this as follows:
Which can be sugared to follows?
GeoffreyBooth commentedon Nov 16, 2019
This isn’t correct. A package can have multiple
package.jsonfiles in different folders; each one starts a new package scope. So for your example, you could have:And then all
.jsfiles in or under/distwould be treated as CommonJS, while all.jsfiles elsewhere would be treated as ES modules.The
.cjsand.mjsextensions are only necessary if you want to intermix CommonJS and ES module files within the same folder.GeoffreyBooth commentedon Nov 16, 2019
Because
"exports"is not exclusive to ES modules. If your package gets used viarequire, what’s defined in"exports"will be used."exports"overrides"main".sheerun commentedon Nov 16, 2019
@GeoffreyBooth But I don't use
requireinexports, so it should fallback tomainfor this kind of import. At least it would be a good default behavior to avoid duplication in package.jsonGeoffreyBooth commentedon Nov 16, 2019
That’s not how
"exports"is designed."exports"applies equivalently to CommonJS and ES modules. If a version of Node that supports"exports"is being used, what’s in"exports"will take precedence over"main"for both CommonJS and ES modules. The only time anything defined within"exports"differs between the two modes is via--experimental-conditional-exports.And before you ask why can’t this not be the case, why does
"exports"need to apply to CommonJS at all, the answer is because the group decided to preserve CommonJS as an equal to ES modules within Node. So whatever features get added to ES modules get supported in CommonJS as well, whenever possible.The next version of Node includes a new section in the docs with recommended approaches for publishing what I call dual packages, packages meant to be used by both CommonJS and ES module consumers: https://github.com/nodejs/node/blob/master/doc/api/esm.md#dual-commonjses-module-packages
sheerun commentedon Nov 16, 2019
Could you update this guide to tell how to create Dual Package without using
.cjsor.mjsextensions and still supporting code that usesrequire('lodash')andrequire('lodash/first')on older node (and node that supportsexports)? Even better publish some example "upgraded" package as an example?ljharb commentedon Nov 16, 2019
You can’t have a dual package without two extensions, or, without using multiple package.jsons.
(Also as soon as node is released with unflagged modules, there will be many examples to point to)
sheerun commentedon Nov 16, 2019
I hope that unflagged modules will allow to publish single package.json and just tell that some subfolder (e.g.
src) contains es6 modules, without using .cjs or .mjs extensions anywhere.guybedford commentedon Nov 16, 2019
There's nothing wrong with the approach of putting a
package.jsonwith{ "type": "module"}in a subdirectory to do the above.An important property of the system though is that it is possible to determine the module format of a file in this way without having to rely on assuming how the consumer got to the file. This is an important property for a module registry as the registry is a file-pathed namespace.
sheerun commentedon Nov 16, 2019
So
package.json:and
src/package.json:?
guybedford commentedon Nov 16, 2019
Yes exactly.
sheerun commentedon Nov 16, 2019
Could you just show how it should look like if both packages need to support subpath imports? i.e. what should be
package.jsonanddist/package.jsonif all of following are possible:8 remaining items
devsnek commentedon Nov 16, 2019
but you're also encouraging using package.json mappings, which browsers definitely don't support. I don't understand this dichotomy.
GeoffreyBooth commentedon Nov 16, 2019
Not necessarily, because that is in consumer code, outside of the package itself. Think of it this way: to use a package in a browser context, you need to pull in an entry point somehow, e.g.:
The URL you use in a browser is separate from anything defined in
package.json.But then within that
index.jsorfirst.js, it’s more compatible to have relative references that browsers can resolve. So for example withinindex.js, there might be:This will work in browsers, whereas
export * as first from './first'will not (unless you have special mappings set up by your webserver).There’s also something called import maps coming to browsers (in Chrome already) that provides a browser equivalent to
"exports"’ path mappings.MylesBorins commentedon Nov 16, 2019
devsnek commentedon Nov 16, 2019
@MylesBorins I'm just trying to figure out what our goals here are. Do we want modules to be usable unmodified in browsers or are we okay with tooling being needed?
sheerun commentedon Nov 16, 2019
I fail to see where is issue making
--es-module-specifier-resolution=nodea default given that import maps solve this issue in browsers and they can be statically generated.Using explicit extensions in consumer code seems like antipattern because such code won't survive or allow refactors in packages themselves (for example: changing .js extension to .mjs or vice versa, or moving utils.js to utils/index.js if it becomes complex enough).
My experience tells me that using node modules in browser environment without tooling can never be performant and is usable only in development, I don't believe this can be solved with modules.
MylesBorins commentedon Nov 16, 2019
MylesBorins commentedon Nov 16, 2019
evanplaice commentedon Nov 16, 2019
Isn't the root issue with Node's resolution algoritm? That -- what is cheap and easy ona local FS -- becomes a perf deal breaker in browsers?
MylesBorins commentedon Nov 16, 2019
GeoffreyBooth commentedon Nov 17, 2019
Import maps are a more-or-less direct equivalent to
"exports", in that they allow a mapping of a public API. They’re a poor tool for bringing automatic extension resolution to the Web. As the import maps README puts it:This shouldn’t be the case for too much longer, if it isn’t already a thing of the past. Technologies like HTTP/2 are designed to make the need to bundle JavaScript no longer necessary from a network performance perspective; the idea behind encouraging public packages to be browser-compatible by default is to lessen (or eliminate) the need for bundling for compatibility reasons as well.
That is the benefit of filenames without extensions, yes. The question is whether that benefit is more important than pushing public package authors toward a future where universal JavaScript is the default, rather than something achieved only through tooling. That’s a subjective call that different people will feel differently about.
And it’s not quite a zero-sum game. A tool like a Babel plugin could certainly rewrite specifiers such as
'./file'into'./file.js'or'./file/index.js'as part of a build process. So then an author would still get the refactorability of extensionless specifiers, while Node and the browser community would get the benefits of explicit extensions in published source files. Once native ESM becomes more popular in Node after it ships, I expect to see lots of tools to help with things like this.This has been a good discussion and a good review of the implementation as it is currently designed, but aside from answering any other questions you may have, is there any specific feedback you’d like to see addressed? Obviously we can’t make everyone happy with every decision we’ve made, but I hope we’ve at least explained them such that you understand why things came out the way they did. I also hope that those design decisions can be ones that you at least can work with, even if they’re not quite what you might have chosen had it been entirely up to you.
sheerun commentedon Nov 17, 2019
Performant web apps require much more than simple response multiplexing. They involve tree shaking, minifying, ordering resources in proper order, deduplicating imports, adding polyfills for legacy or mobile browsers, pre-rendering, small image inlining, among others. Processing and bundling on production needs to happen even if it happens on-the-fly.
If processing needs to happen anyway for production application for the reasons above, either statically or in cached-on-the-fly way, all import paths can be inlined, so even if in code there is
import fp from "lodash/fp"the browser can receiveimport fp from "lodash/fp.js". Import maps are good for avoiding this extra processing, but only in development. I can't see any way performant production app would just import unmodified .js, .mjs, or .cjs files.With this approach ultimately the only reason to build project for node would be to apply node resolution logic which is very weird. And yes, Babel can rewrite
./fileto./file.jsfor browser so slow import maps are not necessary. But for development building is not needed thanks to them.I'd just like to express my concern that unflagging new modules without making
--es-module-specifier-resolution=nodea default will slowdown or prevent upgrade of existing node packages and applications for following reasons:If performance is concern paths can be rewritten either when bundling (on-the-fly or not) for browser, installing packages with npm/yarn or publishing packages to registry.
sheerun commentedon Nov 17, 2019
I can suggest one more alternative that seems good for both sides:
--es-module-specifier-resolution=jswhich is would be simplified resolution algorithm that just adds.jsextension if no extension is present inimport, and given imported name is not in import maps.So let's say there's following
package.json:require('lodash')in old node loadslodash/index.jswith old rulesrequire('lodash/first')in old node loadslodash/first.jswith old rulesrequire('lodash')in new node loadslodash/index.jsbecause ofrequireinexportsrequire('lodash/first')in new node loadslodash/first.jsfor the same reasonimport lodash from 'lodash'loadslodash/src/index.jsimport first from 'lodash/first'loadslodash/src/first.jsThis way there's both no node resolution algorithm and it's possible to not use
.jsextension.sheerun commentedon Nov 22, 2019
It seems that most of this discussion is rendered irrelevant because
{ "type": "module" },import,exporthas been made stable and--es-module-specifier-resolution=nodehas not been made default. I'll open another issue that focuses just on assuming default extension when importing.ljharb commentedon Nov 22, 2019
@sheerun the modules implementation is experimental; there remains the possibility that things like the default specifier resolution could change.