Skip to content
This repository was archived by the owner on Sep 2, 2023. It is now read-only.
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

Closed

Description

@sheerun

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):

  1. const lodash = require('lodash') in old node (for projects without upgraded node)
  2. const lodash = require('lodash') in new node (for projects without upgraded code)
  3. 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

{
  "main": "./old-node.cjs",
  "exports": {
    ".": {
      "require": "./old-node.cjs",
      "default": "./new-node.js"
    }
  },
  "type": "module"
}

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:

  1. 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.
  2. 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):

{
  "main": "lib/index.js",
  "exports": "src/index.js"
}

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:

{
  "main": "lib/index.js",
  "exports": "src/"
}

Activity

  1. ljharb commented on Nov 16, 2019

    @ljharb
    SponsorMember

    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.

  2. sheerun commented on Nov 16, 2019

    @sheerun
    Author

    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.

  3. guybedford commented on Nov 16, 2019

    @guybedford
    Contributor

    @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:

    {
      "main": "./cjs-main.js",
      "exports": {
        "require": "./cjs-main.js",
        "default": "./esm-main.mjs"
      }
    }

    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.

  4. sheerun commented on Nov 16, 2019

    @sheerun
    Author

    @guybedford Why can't I write this as follows:

    {
      "main": "./cjs-main.js",
      "exports": {
        "default": "./esm-main.mjs"
      }
    }
    

    Which can be sugared to follows?

    {
      "main": "./cjs-main.js",
      "exports": "./esm-main.mjs"
    }
    
  5. GeoffreyBooth commented on Nov 16, 2019

    @GeoffreyBooth
    Member

    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:

    /package.json:      "type": "module"
    /dist/package.json: "type": "commonjs"
    

    And then all .js files in or under /dist would be treated as CommonJS, while all .js files elsewhere would be treated as ES modules.

    The .cjs and .mjs extensions are only necessary if you want to intermix CommonJS and ES module files within the same folder.

  6. GeoffreyBooth commented on Nov 16, 2019

    @GeoffreyBooth
    Member

    Why can't I write this as follows:

    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".

  7. sheerun commented on Nov 16, 2019

    @sheerun
    Author

    @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

  8. GeoffreyBooth commented on Nov 16, 2019

    @GeoffreyBooth
    Member

    @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.

    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

  9. sheerun commented on Nov 16, 2019

    @sheerun
    Author

    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?

  10. ljharb commented on Nov 16, 2019

    @ljharb
    SponsorMember

    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)

  11. sheerun commented on Nov 16, 2019

    @sheerun
    Author

    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.

  12. guybedford commented on Nov 16, 2019

    @guybedford
    Contributor

    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.

  13. sheerun commented on Nov 16, 2019

    @sheerun
    Author

    So package.json:

    {
      "main": "index.js",
      "exports": {
        "require": "index.js",
        "default": "src/index.js"
      }
    }

    and src/package.json:

    {
      "type": "module"
    }

    ?

  14. guybedford commented on Nov 16, 2019

    @guybedford
    Contributor

    Yes exactly.

  15. sheerun commented on Nov 16, 2019

    @sheerun
    Author

    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
    • import lodash from 'lodash'
    • import first from 'lodash/first'
  16. 8 remaining items

  17. devsnek commented on Nov 16, 2019

    @devsnek
    Member

    because the former is much more likely to be usable unmodified in browser environments and we’re trying to encourage cross-compatible code.

    but you're also encouraging using package.json mappings, which browsers definitely don't support. I don't understand this dichotomy.

  18. GeoffreyBooth commented on Nov 16, 2019

    @GeoffreyBooth
    Member

    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).

    There’s also something called import maps coming to browsers (in Chrome already) that provides a browser equivalent to "exports"’ path mappings.

  19. MylesBorins commented on Nov 16, 2019

    @MylesBorins
    Contributor
  20. devsnek commented on Nov 16, 2019

    @devsnek
    Member

    @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?

  21. sheerun commented on Nov 16, 2019

    @sheerun
    Author

    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.

  22. MylesBorins commented on Nov 16, 2019

    @MylesBorins
    Contributor
  23. MylesBorins commented on Nov 16, 2019

    @MylesBorins
    Contributor
  24. evanplaice commented on Nov 16, 2019

    @evanplaice

    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?

  25. MylesBorins commented on Nov 16, 2019

    @MylesBorins
    Contributor
  26. GeoffreyBooth commented on Nov 17, 2019

    @GeoffreyBooth
    Member

    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.

  27. sheerun commented on Nov 17, 2019

    @sheerun
    Author

    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:

    1. Need to change extensions of files in repository to either .mjs or .cjs
    2. Need to resolve node imports to their exact extensions in code
    3. Manually adding exports entries to package.json
    4. Keeping track which dependencies are es6 and which are cjs imports
    5. 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.

  28. sheerun commented on Nov 17, 2019

    @sheerun
    Author

    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.

    So let's say there's following package.json:

    {
      "main": "index.js",
      "exports": {
        "require": "./",
        "default": "./src/"
      }
    }
    
    • require('lodash') in old node loads lodash/index.js with old rules
    • require('lodash/first') in old node loads lodash/first.js with old rules
    • require('lodash') in new node loads lodash/index.js because of require in exports
    • require('lodash/first') in new node loads lodash/first.js for the same reason
    • import lodash from 'lodash' loads lodash/src/index.js
    • import first from 'lodash/first' loads lodash/src/first.js

    This way there's both no node resolution algorithm and it's possible to not use .js extension.

  29. sheerun commented on Nov 22, 2019

    @sheerun
    Author

    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.

  30. ljharb commented on Nov 22, 2019

    @ljharb
    SponsorMember

    @sheerun the modules implementation is experimental; there remains the possibility that things like the default specifier resolution could change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions