Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

module: implement the "module-sync" exports condition #54648

Merged
merged 1 commit into from
Sep 25, 2024

Conversation

joyeecheung
Copy link
Member

@joyeecheung joyeecheung commented Aug 29, 2024

This patch implements a "module-sync" exports condition for packages to supply a sycnrhonous ES module to the Node.js module loader, no matter it's being required or imported. This is similar to the "module" condition that bundlers have been using to support require(esm) in Node.js, and allows dual-package authors to opt into ESM-first only on newer versions of Node.js that supports require(esm) to avoid the dual-package hazard.

{
  "type": "module",
  "exports": {
    "node": {
      // On new version of Node.js, both require() and import get
      // the ESM version
      "module-sync": "./index.js",
      // On older version of Node.js, where "module-sync" and require(esm) are
      // not supported, use the CJS version to avoid dual-package hazard. 
      // When package authors think it's time to drop support for older versions of
      // Node.js, they can remove the exports conditions and just use "main": "index.js".
      "default": "./dist/index.cjs"
    },
    // On any other environment, use the ESM version.
    "default": "./index.js"
  }
}

Or if the package is only meant to be run on Node.js and wants to fallback to CJS on older versions that don't have require(esm)

{
  "type": "module",
  "exports": {
    // On new version of Node.js, both require() and import get the ESM version
    "module-sync": "./index.js",
    // On older version of Node.js, where "module-sync" and require(esm) are
    // not supported, use the CJS version to avoid dual-package hazard. 
    // When package authors think it's time to drop support for older versions of
    // Node.js, they can remove the exports conditions and just use "main": "index.js".
    "default": "./dist/index.cjs"
  }
}

We end up implementing a condition with a different name instead of reusing "module", because existing code in the ecosystem using the "module" condition sometimes also expect the module resolution for these ESM files to work in CJS style, which is supported by bundlers, but the native Node.js loader has intentionally made ESM resolution different from CJS resolution (e.g. forbidding import './noext' or import './directory'), so it would be semver-major to implement a "module" condition without implementing the forbidden ESM resolution rules. For now, this just implments a new condition as semver-minor so it can be backported to older LTS.

The tests added in this patch match the output of using the modulecondition from webpack and esbuild bundles, will a small difference in rollup outputs. See https://github.com/joyeecheung/test-module-condition

Fixes: #52173
Refs: https://github.com/joyeecheung/test-module-condition
Refs: #52697
Refs: https://webpack.js.org/guides/package-exports/#target-environment-independent-packages

@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/loaders

@nodejs-github-bot nodejs-github-bot added esm Issues and PRs related to the ECMAScript Modules implementation. module Issues and PRs related to the module subsystem. needs-ci PRs that need a full CI run. labels Aug 29, 2024
@joyeecheung
Copy link
Member Author

cc @guybedford

Copy link

codecov bot commented Aug 30, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 88.24%. Comparing base (e35299a) to head (e96699e).
Report is 63 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #54648      +/-   ##
==========================================
+ Coverage   88.03%   88.24%   +0.21%     
==========================================
  Files         652      652              
  Lines      183797   183886      +89     
  Branches    35863    35853      -10     
==========================================
+ Hits       161804   162279     +475     
+ Misses      15242    14896     -346     
+ Partials     6751     6711      -40     
Files with missing lines Coverage Δ
lib/internal/modules/esm/get_format.js 89.28% <100.00%> (ø)
lib/internal/modules/esm/utils.js 98.89% <100.00%> (+<0.01%) ⬆️
lib/internal/modules/helpers.js 99.03% <100.00%> (+0.98%) ⬆️

... and 98 files with indirect coverage changes

Copy link
Member

@legendecas legendecas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we document the subtle difference between "module" and "import" at https://nodejs.org/api/packages.html#conditional-exports?

@joyeecheung
Copy link
Member Author

joyeecheung commented Aug 30, 2024

There is a huge section in packages.md that teaches some questionable practices (see #52174) that will soon be outdated when we unflag --experimental-require-module. I am not sure if we should just update that doc now, or leave a TODO (I am inclined to just leave a TODO and don't let this get held back by a huge doc bikeshedding - IMO this documentation doesn't belong in the API docs at all, it should probably be placed in some example repo like the n-api examples that are easier to update and less abstract when it describes multi-file structures)

@joyeecheung joyeecheung added the request-ci Add this label to start a Jenkins CI on a PR. label Sep 1, 2024
@github-actions github-actions bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Sep 1, 2024
@nodejs-github-bot
Copy link
Collaborator

@nodejs-github-bot
Copy link
Collaborator

@nodejs-github-bot
Copy link
Collaborator

@joyeecheung joyeecheung added the request-ci Add this label to start a Jenkins CI on a PR. label Sep 4, 2024
@github-actions github-actions bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Sep 4, 2024
@nodejs-github-bot
Copy link
Collaborator

@nodejs-github-bot
Copy link
Collaborator

@joyeecheung
Copy link
Member Author

I did a bit more testing with other bundlers, and put the results in https://github.com/joyeecheung/test-module-condition

In summary it allows running unbundled code that produce the same output as bundled produced by webpack and esbuild. Rollup has a difference in that it doesn't respect insertion-order priorities, but I guess that's probably a problem that can apply to any other exports conditions.

@nicolo-ribaudo
Copy link
Contributor

It would be great if the docs mentioned that module is not a catch-all for ESM, but that you should only use it when your ESM does not use top-level await.

@phryneas
Copy link

phryneas commented Sep 5, 2024

This could be problematic where packages ship build explicitly for bundlers, but not for node.

Just as an example: there is a good bunch of packages that use module because it does not get picked up by node, in elaborate schemes to get around a dual package hazard.
Pinging @Andarist on this, as he is shipping a lot of those packages.

You can find some explanation on this in https://github.com/graphql/graphq where we are exploring to ship CommonJS for require, and stubs re-exporting that CommonJS for imports.
module is used explicitly targeting bundlers with a completely different build.

I do not think that this exact bundling schema would be impacted negatively (as this PR would also get around the dual package hazard), but I imagine there might be more reasons to have something "not for node" in module.

Copy link
Member

@targos targos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point @phryneas !

I confirm that this PR breaks require('xstate') under --experimental-require-module.

targos
targos previously requested changes Sep 5, 2024
Copy link
Member

@targos targos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meant to block.

@joyeecheung
Copy link
Member Author

I confirm that this PR breaks require('xstate') under --experimental-require-module.

How did you confirm that this breaks require('xstate') though? I tried it out locally, and it's actually working as intended - require('xstate') === await import('xstate') is even returning true (because we don't need to add the __esModule hack for xstate since it has no default exports, but even if it does, all the name exports are still going to be reference equal)

According to graphql/graphql-js#4062, take the base path for example:

    ".": {
      "module": "./dist/xstate.esm.js",  // Original ESM version
      "import": "./dist/xstate.cjs.mjs",  // Re-exports of the transpiled CJS
      "default": "./dist/xstate.cjs.js"  // Transpiled ESM version
    },

Before this PR, import 'xstate' picks up the re-exports of the transpiled CJS while require('xstate') picks up the transpiled CJS. After this PR, both of them gets the original ESM version under --experimental-require-module (although this reminds me to fill the gap, we should also make it flagged for import). This is actually the desirable situation, and basically the point of choosing module. If we end up inventing a new condition, say require-module, then the package has to do this if they want to ask newer versions of Node.js to use ESM for require()

    ".": {
      "require-module": "./dist/xstate.esm.js",  // Original ESM version
      "module": "./dist/xstate.esm.js",  // Original ESM version
      "import": "./dist/xstate.cjs.mjs",  // Re-exports of the transpiled CJS
      "default": "./dist/xstate.cjs.js"  // Transpiled ESM version
    },

This further bloating is what we are trying to avoid by implementing module, as bundlers already have intended it to be an optimization for require(esm) on Node.js targets.

@joyeecheung
Copy link
Member Author

joyeecheung commented Sep 5, 2024

I imagine there might be more reasons to have something "not for node" in module.

Do you have an example? Considering bundlers have been using this as an optimization for bundling code that does require(esm) on Node.js, it seems risky for a module to deviate from the intended use case already. The cost of using a new condition name for this is that we will risk further bloating the package.json as described in #54648 (comment), instead of keeping existing package.json work out of the box if they are following a guideline such as https://webpack.js.org/guides/package-exports/#target-environment-independent-packages

@targos
Copy link
Member

targos commented Sep 5, 2024

I'm no longer with my computer but my reproduction ended up in a throw (while v22 with the flag doesn't throw).
Just install xstate, write a commonjs file that requires it, and run the file with the node built from this PR.
Without the flag it passes, with the flag it throws.

@joyeecheung joyeecheung force-pushed the module-condition branch 2 times, most recently from b8a4492 to bb65ea2 Compare September 23, 2024 15:24
@joyeecheung joyeecheung added the request-ci Add this label to start a Jenkins CI on a PR. label Sep 23, 2024
This patch implements a "module-sync" exports condition
for packages to supply a sycnrhonous ES module to the
Node.js module loader, no matter it's being required
or imported. This is similar to the "module" condition
that bundlers have been using to support `require(esm)`
in Node.js, and allows dual-package authors to opt into
ESM-first only newer versions of Node.js that supports
require(esm) while avoiding the dual-package hazard.

```json
{
  "type": "module",
  "exports": {
    "node": {
      // On new version of Node.js, both require() and import get
      // the ESM version
      "module-sync": "./index.js",
      // On older version of Node.js, where "module" and
      // require(esm) are not supported, use the transpiled CJS version
      // to avoid dual-package hazard. Library authors can decide
      // to drop support for older versions of Node.js when they think
      // it's time.
      "default": "./dist/index.cjs"
    },
    // On any other environment, use the ESM version.
    "default": "./index.js"
  }
}
```

We end up implementing a condition with a different name
instead of reusing "module", because existing code in the
ecosystem using the "module" condition sometimes also expect
the module resolution for these ESM files to work in CJS
style, which is supported by bundlers, but the native
Node.js loader has intentionally made ESM resolution
different from CJS resolution (e.g. forbidding `import
'./noext'` or `import './directory'`), so it would be
semver-major to implement a `"module"` condition
without implementing the forbidden ESM resolution rules.
For now, this just implments a new condition as semver-minor
so it can be backported to older LTS.

Refs: https://webpack.js.org/guides/package-exports/#target-environment-independent-packages
@github-actions github-actions bot added request-ci-failed An error occurred while starting CI via request-ci label, and manual interventon is needed. and removed request-ci Add this label to start a Jenkins CI on a PR. labels Sep 23, 2024
Copy link
Contributor

Failed to start CI
   ⚠  Something was pushed to the Pull Request branch since the last approving review.
   ✘  Refusing to run CI on potentially unsafe PR
https://github.com/nodejs/node/actions/runs/10997539161

@joyeecheung joyeecheung added request-ci Add this label to start a Jenkins CI on a PR. and removed request-ci-failed An error occurred while starting CI via request-ci label, and manual interventon is needed. labels Sep 23, 2024
@github-actions github-actions bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Sep 23, 2024
@nodejs-github-bot
Copy link
Collaborator

@joyeecheung joyeecheung added the review wanted PRs that need reviews. label Sep 24, 2024
@joyeecheung
Copy link
Member Author

@guybedford @legendecas @JakobJingleheimer @targos Can you take a look again? Thanks

@joyeecheung joyeecheung added notable-change PRs with changes that should be highlighted in changelogs. commit-queue Add this label to land a pull request using GitHub Actions. and removed review wanted PRs that need reviews. labels Sep 25, 2024
Copy link
Contributor

The notable-change PRs with changes that should be highlighted in changelogs. label has been added by @joyeecheung.

Please suggest a text for the release notes if you'd like to include a more detailed summary, then proceed to update the PR description with the text or a link to the notable change suggested text comment. Otherwise, the commit will be placed in the Other Notable Changes section.

@nodejs-github-bot nodejs-github-bot removed the commit-queue Add this label to land a pull request using GitHub Actions. label Sep 25, 2024
@nodejs-github-bot nodejs-github-bot merged commit 0b9249e into nodejs:main Sep 25, 2024
61 checks passed
@nodejs-github-bot
Copy link
Collaborator

Landed in 0b9249e

joyeecheung added a commit to joyeecheung/node that referenced this pull request Oct 1, 2024
This patch implements a "module-sync" exports condition
for packages to supply a sycnrhonous ES module to the
Node.js module loader, no matter it's being required
or imported. This is similar to the "module" condition
that bundlers have been using to support `require(esm)`
in Node.js, and allows dual-package authors to opt into
ESM-first only newer versions of Node.js that supports
require(esm) while avoiding the dual-package hazard.

```json
{
  "type": "module",
  "exports": {
    "node": {
      // On new version of Node.js, both require() and import get
      // the ESM version
      "module-sync": "./index.js",
      // On older version of Node.js, where "module" and
      // require(esm) are not supported, use the transpiled CJS version
      // to avoid dual-package hazard. Library authors can decide
      // to drop support for older versions of Node.js when they think
      // it's time.
      "default": "./dist/index.cjs"
    },
    // On any other environment, use the ESM version.
    "default": "./index.js"
  }
}
```

We end up implementing a condition with a different name
instead of reusing "module", because existing code in the
ecosystem using the "module" condition sometimes also expect
the module resolution for these ESM files to work in CJS
style, which is supported by bundlers, but the native
Node.js loader has intentionally made ESM resolution
different from CJS resolution (e.g. forbidding `import
'./noext'` or `import './directory'`), so it would be
semver-major to implement a `"module"` condition
without implementing the forbidden ESM resolution rules.
For now, this just implments a new condition as semver-minor
so it can be backported to older LTS.

Refs: https://webpack.js.org/guides/package-exports/#target-environment-independent-packages
PR-URL: nodejs#54648
Fixes: nodejs#52173
Refs: https://github.com/joyeecheung/test-module-condition
Refs: nodejs#52697
Reviewed-By: Jacob Smith <jacob@frende.me>
Reviewed-By: Jan Krems <jan.krems@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
esm Issues and PRs related to the ECMAScript Modules implementation. module Issues and PRs related to the module subsystem. needs-ci PRs that need a full CI run. notable-change PRs with changes that should be highlighted in changelogs. semver-minor PRs that contain new features and should be released in the next minor version.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

module: increased surface for hazards with require(esm) experimental flag
10 participants