Skip to content

Commit

Permalink
module: conditional exports with flagged conditions
Browse files Browse the repository at this point in the history
PR-URL: #29978
Reviewed-By: Jan Krems <jan.krems@gmail.com>
Reviewed-By: Myles Borins <myles.borins@gmail.com>
  • Loading branch information
guybedford authored and BethGriggs committed Feb 6, 2020
1 parent 1f46eea commit 680ae77
Show file tree
Hide file tree
Showing 21 changed files with 485 additions and 209 deletions.
11 changes: 11 additions & 0 deletions doc/api/cli.md
Expand Up @@ -161,6 +161,15 @@ the ability to import a directory that has an index file.

Please see [customizing esm specifier resolution][] for example usage.

### `--experimental-conditional-exports
<!-- YAML
added: REPLACEME
-->

Enable experimental support for the `"require"` and `"node"` conditional
package export resolutions.
See [Conditional Exports][] for more information.

### `--experimental-json-modules`
<!-- YAML
added: v12.9.0
Expand Down Expand Up @@ -1065,6 +1074,7 @@ Node.js options that are allowed are:
* `--enable-fips`
* `--enable-source-maps`
* `--es-module-specifier-resolution`
* `--experimental-conditional-exports`
* `--experimental-json-modules`
* `--experimental-loader`
* `--experimental-modules`
Expand Down Expand Up @@ -1370,3 +1380,4 @@ greater than `4` (its current default value). For more information, see the
[libuv threadpool documentation]: http://docs.libuv.org/en/latest/threadpool.html
[remote code execution]: https://www.owasp.org/index.php/Code_Injection
[context-aware]: addons.html#addons_context_aware_addons
[Conditional Exports]: esm.html#esm_conditional_exports
184 changes: 153 additions & 31 deletions doc/api/esm.md
Expand Up @@ -260,6 +260,9 @@ that would only be supported in ES module-supporting versions of Node.js (and
other runtimes). New packages could be published containing only ES module
sources, and would be compatible only with ES module-supporting runtimes.

To define separate package entry points for use by `require` and by `import`,
see [Conditional Exports][].

### Package Exports

By default, all subpaths from a package can be imported (`import 'pkg/x.js'`).
Expand Down Expand Up @@ -313,50 +316,154 @@ If a package has no exports, setting `"exports": false` can be used instead of
`"exports": {}` to indicate the package does not intend for submodules to be
exposed.

Exports can also be used to map the main entry point of a package:
Any invalid exports entries will be ignored. This includes exports not
starting with `"./"` or a missing trailing `"/"` for directory exports.

Array fallback support is provided for exports, similarly to import maps
in order to be forwards-compatible with possible fallback workflows in future:

<!-- eslint-skip -->
```js
// ./node_modules/es-module-package/package.json
{
"exports": {
".": "./main.js"
"./submodule": ["not:valid", "./submodule.js"]
}
}
```

where the "." indicates loading the package without any subpath. Exports will
always override any existing `"main"` value for both CommonJS and
ES module packages.
Since `"not:valid"` is not a supported target, `"./submodule.js"` is used
instead as the fallback, as if it were the only target.

Defining a `"."` export will define the main entry point for the package,
and will always take precedence over the `"main"` field in the `package.json`.

For packages with only a main entry point, an `"exports"` value of just
a string is also supported:
This allows defining a different entry point for Node.js versions that support
ECMAScript modules and versions that don't, for example:

<!-- eslint-skip -->
```js
{
"main": "./main-legacy.cjs",
"exports": {
".": "./main-modern.cjs"
}
}
```

#### Conditional Exports

Conditional exports provide a way to map to different paths depending on
certain conditions. They are supported for both CommonJS and ES module imports.

For example, a package that wants to provide different ES module exports for
Node.js and the browser can be written:

<!-- eslint-skip -->
```js
// ./node_modules/pkg/package.json
{
"type": "module",
"main": "./index.js",
"exports": {
"./feature": {
"browser": "./feature-browser.js",
"default": "./feature-default.js"
}
}
}
```

When resolving the `"."` export, if no matching target is found, the `"main"`
will be used as the final fallback.

The conditions supported in Node.js are matched in the following order:

1. `"require"` - matched when the package is loaded via `require()`.
_This is currently only supported behind the
`--experimental-conditional-exports` flag._
2. `"node"` - matched for any Node.js environment. Can be a CommonJS or ES
module file. _This is currently only supported behind the
`--experimental-conditional-exports` flag._
3. `"default"` - the generic fallback that will always match if no other
more specific condition is matched first. Can be a CommonJS or ES module
file.

Using the `"require"` condition it is possible to define a package that will
have a different exported value for CommonJS and ES modules, which can be a
hazard in that it can result in having two separate instances of the same
package in use in an application, which can cause a number of bugs.

Other conditions such as `"browser"`, `"electron"`, `"deno"`, `"react-native"`,
etc. could be defined in other runtimes or tools.

#### Exports Sugar

If the `"."` export is the only export, the `"exports"` field provides sugar
for this case being the direct `"exports"` field value.

If the `"."` export has a fallback array or string value, then the `"exports"`
field can be set to this value directly.

<!-- eslint-skip -->
```js
{
"exports": {
".": "./main.js"
}
}
```

can be written:

<!-- eslint-skip -->
```js
// ./node_modules/es-module-package/package.json
{
"exports": "./main.js"
}
```

Any invalid exports entries will be ignored. This includes exports not
starting with `"./"` or a missing trailing `"/"` for directory exports.
When using conditional exports, the rule is that all keys in the object mapping
must not start with a `"."` otherwise they would be indistinguishable from
exports subpaths.

Array fallback support is provided for exports, similarly to import maps
in order to be forward-compatible with fallback workflows in future:
<!-- eslint-skip -->
```js
{
"exports": {
".": {
"require": "./main.cjs",
"default": "./main.js"
}
}
}
```

can be written:

<!-- eslint-skip -->
```js
{
"exports": {
"./submodule": ["not:valid", "./submodule.js"]
"require": "./main.cjs",
"default": "./main.js"
}
}
```

Since `"not:valid"` is not a supported target, `"./submodule.js"` is used
instead as the fallback, as if it were the only target.
If writing any exports value that mixes up these two forms, an error will be
thrown:

<!-- eslint-skip -->
```js
{
// Throws on resolution!
"exports": {
"./feature": "./lib/feature.js",
"require": "./main.cjs",
"default": "./main.js"
}
}
```

## `import` Specifiers

Expand Down Expand Up @@ -805,6 +912,9 @@ of these top-level routines unless stated otherwise.
_isMain_ is **true** when resolving the Node.js application entry point.
_defaultEnv_ is the conditional environment name priority array,
`["node", "default"]`.
<details>
<summary>Resolver algorithm specification</summary>
Expand Down Expand Up @@ -904,14 +1014,16 @@ _isMain_ is **true** when resolving the Node.js application entry point.
> 1. If _pjson_ is **null**, then
> 1. Throw a _Module Not Found_ error.
> 1. If _pjson.exports_ is not **null** or **undefined**, then
> 1. If _pjson.exports_ is a String or Array, then
> 1. If _exports_ is an Object with both a key starting with _"."_ and a key
> not starting with _"."_, throw a "Invalid Package Configuration" error.
> 1. If _pjson.exports_ is a String or Array, or an Object containing no
> keys starting with _"."_, then
> 1. Return **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_,
> _pjson.exports_, _""_).
> 1. If _pjson.exports_ is an Object containing a _"."_ property, then
> 1. Let _mainExport_ be the _"."_ property in _pjson.exports_.
> 1. Return **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_,
> _pjson.exports_, "")_.
> 1. If _pjson.exports is an Object, then
> 1. If _pjson.exports_ contains a _"."_ property, then
> 1. Let _mainExport_ be the _"."_ property in _pjson.exports_.
> 1. Return **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_,
> _mainExport_, "")_.
> _mainExport_, _""_).
> 1. If _pjson.main_ is a String, then
> 1. Let _resolvedMain_ be the URL resolution of _packageURL_, "/", and
> _pjson.main_.
Expand All @@ -925,13 +1037,14 @@ _isMain_ is **true** when resolving the Node.js application entry point.
> 1. Return _legacyMainURL_.
**PACKAGE_EXPORTS_RESOLVE**(_packageURL_, _packagePath_, _exports_)
> 1. If _exports_ is an Object, then
> 1. If _exports_ is an Object with both a key starting with _"."_ and a key not
> starting with _"."_, throw an "Invalid Package Configuration" error.
> 1. If _exports_ is an Object and all keys of _exports_ start with _"."_, then
> 1. Set _packagePath_ to _"./"_ concatenated with _packagePath_.
> 1. If _packagePath_ is a key of _exports_, then
> 1. Let _target_ be the value of _exports\[packagePath\]_.
> 1. Return **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_,
> _""_).
> _""_, _defaultEnv_).
> 1. Let _directoryKeys_ be the list of keys of _exports_ ending in
> _"/"_, sorted by length descending.
> 1. For each key _directory_ in _directoryKeys_, do
Expand All @@ -940,10 +1053,10 @@ _isMain_ is **true** when resolving the Node.js application entry point.
> 1. Let _subpath_ be the substring of _target_ starting at the index
> of the length of _directory_.
> 1. Return **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_,
> _subpath_).
> _subpath_, _defaultEnv_).
> 1. Throw a _Module Not Found_ error.
**PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_, _subpath_)
**PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_, _subpath_, _env_)
> 1. If _target_ is a String, then
> 1. If _target_ does not start with _"./"_, throw a _Module Not Found_
Expand All @@ -959,12 +1072,20 @@ _isMain_ is **true** when resolving the Node.js application entry point.
> _subpath_ and _resolvedTarget_.
> 1. If _resolved_ is contained in _resolvedTarget_, then
> 1. Return _resolved_.
> 1. Otherwise, if _target_ is a non-null Object, then
> 1. If _target_ has an object key matching one of the names in _env_, then
> 1. Let _targetValue_ be the corresponding value of the first object key
> of _target_ in _env_.
> 1. Let _resolved_ be the result of **PACKAGE_EXPORTS_TARGET_RESOLVE**
> (_packageURL_, _targetValue_, _subpath_, _env_).
> 1. Assert: _resolved_ is a String.
> 1. Return _resolved_.
> 1. Otherwise, if _target_ is an Array, then
> 1. For each item _targetValue_ in _target_, do
> 1. If _targetValue_ is not a String, continue the loop.
> 1. If _targetValue_ is an Array, continue the loop.
> 1. Let _resolved_ be the result of
> **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _targetValue_,
> _subpath_), continuing the loop on abrupt completion.
> _subpath_, _env_), continuing the loop on abrupt completion.
> 1. Assert: _resolved_ is a String.
> 1. Return _resolved_.
> 1. Throw a _Module Not Found_ error.
Expand Down Expand Up @@ -1032,6 +1153,7 @@ success!
```
[CommonJS]: modules.html
[Conditional Exports]: #esm_conditional_exports
[ECMAScript-modules implementation]: https://github.com/nodejs/modules/blob/master/doc/plan-for-new-modules-implementation.md
[ES Module Integration Proposal for Web Assembly]: https://github.com/webassembly/esm-integration
[Node.js EP for ES Modules]: https://github.com/nodejs/node-eps/blob/master/002-es-modules.md
Expand All @@ -1044,7 +1166,7 @@ success!
[`import`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
[`module.createRequire()`]: modules.html#modules_module_createrequire_filename
[`module.syncBuiltinESMExports()`]: modules.html#modules_module_syncbuiltinesmexports
[dynamic instantiate hook]: #esm_dynamic_instantiate_hook
[package exports]: #esm_package_exports
[dynamic instantiate hook]: #esm_dynamic_instantiate_hook
[special scheme]: https://url.spec.whatwg.org/#special-scheme
[the official standard format]: https://tc39.github.io/ecma262/#sec-modules
13 changes: 9 additions & 4 deletions doc/api/modules.md
Expand Up @@ -232,12 +232,17 @@ RESOLVE_BARE_SPECIFIER(DIR, X)
2. If X matches this pattern and DIR/name/package.json is a file:
a. Parse DIR/name/package.json, and look for "exports" field.
b. If "exports" is null or undefined, GOTO 3.
c. Find the longest key in "exports" that the subpath starts with.
d. If no such key can be found, throw "not found".
e. let RESOLVED_URL =
c. If "exports" is an object with some keys starting with "." and some keys
not starting with ".", throw "invalid config".
c. If "exports" is a string, or object with no keys starting with ".", treat
it as having that value as its "." object property.
d. If subpath is "." and "exports" does not have a "." entry, GOTO 3.
e. Find the longest key in "exports" that the subpath starts with.
f. If no such key can be found, throw "not found".
g. let RESOLVED_URL =
PACKAGE_EXPORTS_TARGET_RESOLVE(pathToFileURL(DIR/name), exports[key],
subpath.slice(key.length)), as defined in the esm resolver.
f. return fileURLToPath(RESOLVED_URL)
h. return fileURLToPath(RESOLVED_URL)
3. return DIR/X
```

Expand Down
3 changes: 3 additions & 0 deletions doc/node.1
Expand Up @@ -108,6 +108,9 @@ Requires Node.js to be built with
.It Fl -es-module-specifier-resolution
Select extension resolution algorithm for ES Modules; either 'explicit' (default) or 'node'
.
.It Fl -experimental-conditional-exports
Enable experimental support for "require" and "node" conditional export targets.
.
.It Fl -experimental-json-modules
Enable experimental JSON interop support for the ES Module loader.
.
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/errors.js
Expand Up @@ -976,7 +976,7 @@ E('ERR_INVALID_OPT_VALUE', (name, value) =>
E('ERR_INVALID_OPT_VALUE_ENCODING',
'The value "%s" is invalid for option "encoding"', TypeError);
E('ERR_INVALID_PACKAGE_CONFIG',
'Invalid package config in \'%s\' imported from %s', Error);
'Invalid package config for \'%s\', %s', Error);
E('ERR_INVALID_PERFORMANCE_MARK',
'The "%s" performance mark has not been set', Error);
E('ERR_INVALID_PROTOCOL',
Expand Down

0 comments on commit 680ae77

Please sign in to comment.