From 680ae770abfef3bbb7af1a3aa3cd38c1c9098ea9 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Sun, 13 Oct 2019 19:27:39 -0400 Subject: [PATCH] module: conditional exports with flagged conditions PR-URL: https://github.com/nodejs/node/pull/29978 Reviewed-By: Jan Krems Reviewed-By: Myles Borins --- doc/api/cli.md | 11 + doc/api/esm.md | 184 +++++++++-- doc/api/modules.md | 13 +- doc/node.1 | 3 + lib/internal/errors.js | 2 +- lib/internal/modules/cjs/loader.js | 103 ++++-- src/env.h | 3 + src/module_wrap.cc | 307 +++++++++--------- src/node_options.cc | 4 + src/node_options.h | 1 + test/es-module/test-esm-exports.mjs | 31 +- .../pkgexports-sugar-fail/main.js | 1 + .../pkgexports-sugar-fail/not-exported.js | 3 + .../pkgexports-sugar-fail/package.json | 6 + .../node_modules/pkgexports-sugar/main.js | 1 + .../pkgexports-sugar/not-exported.js | 3 + .../pkgexports-sugar/package.json | 3 + .../node_modules/pkgexports-sugar2/main.js | 1 + .../pkgexports-sugar2/not-exported.js | 3 + .../pkgexports-sugar2/package.json | 6 + .../node_modules/pkgexports/package.json | 5 +- 21 files changed, 485 insertions(+), 209 deletions(-) create mode 100644 test/fixtures/node_modules/pkgexports-sugar-fail/main.js create mode 100644 test/fixtures/node_modules/pkgexports-sugar-fail/not-exported.js create mode 100644 test/fixtures/node_modules/pkgexports-sugar-fail/package.json create mode 100644 test/fixtures/node_modules/pkgexports-sugar/main.js create mode 100644 test/fixtures/node_modules/pkgexports-sugar/not-exported.js create mode 100644 test/fixtures/node_modules/pkgexports-sugar/package.json create mode 100644 test/fixtures/node_modules/pkgexports-sugar2/main.js create mode 100644 test/fixtures/node_modules/pkgexports-sugar2/not-exported.js create mode 100644 test/fixtures/node_modules/pkgexports-sugar2/package.json diff --git a/doc/api/cli.md b/doc/api/cli.md index 131e9a1f092d0e..a61ee84bf39dc3 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -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 + + +Enable experimental support for the `"require"` and `"node"` conditional +package export resolutions. +See [Conditional Exports][] for more information. + ### `--experimental-json-modules` ```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: + + +```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: + + +```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. + + +```js +{ + "exports": { + ".": "./main.js" + } +} +``` + +can be written: ```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: + +```js +{ + "exports": { + ".": { + "require": "./main.cjs", + "default": "./main.js" + } + } +} +``` + +can be written: ```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: + + +```js +{ + // Throws on resolution! + "exports": { + "./feature": "./lib/feature.js", + "require": "./main.cjs", + "default": "./main.js" + } +} +``` ## `import` Specifiers @@ -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"]`. +
Resolver algorithm specification @@ -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_. @@ -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 @@ -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_ @@ -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. @@ -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 @@ -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 diff --git a/doc/api/modules.md b/doc/api/modules.md index c7f6d8a0437d75..fafa8a88005f3a 100644 --- a/doc/api/modules.md +++ b/doc/api/modules.md @@ -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 ``` diff --git a/doc/node.1 b/doc/node.1 index eff90436d87254..2a78516504d2b3 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -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. . diff --git a/lib/internal/errors.js b/lib/internal/errors.js index ad071222696a13..c05a8d36c65277 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -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', diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 75300ae942b54f..225aa1787b0c3f 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -59,6 +59,8 @@ const preserveSymlinks = getOptionValue('--preserve-symlinks'); const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main'); const experimentalModules = getOptionValue('--experimental-modules'); const experimentalSelf = getOptionValue('--experimental-resolve-self'); +const experimentalConditionalExports = + getOptionValue('--experimental-conditional-exports'); const manifest = getOptionValue('--experimental-policy') ? require('internal/process/policy').manifest : null; @@ -67,6 +69,7 @@ const { compileFunction } = internalBinding('contextify'); const { ERR_INVALID_ARG_VALUE, ERR_INVALID_OPT_VALUE, + ERR_INVALID_PACKAGE_CONFIG, ERR_REQUIRE_ESM } = require('internal/errors').codes; const { validateString } = require('internal/validators'); @@ -441,7 +444,6 @@ function trySelf(paths, exts, isMain, trailingSlash, request) { if (expansion) { // Use exports const fromExports = applyExports(basePath, expansion); - if (!fromExports) return false; return resolveBasePath(fromExports, exts, isMain, trailingSlash, request); } else { // Use main field @@ -449,17 +451,51 @@ function trySelf(paths, exts, isMain, trailingSlash, request) { } } +function isConditionalDotExportSugar(exports, basePath) { + if (typeof exports === 'string') + return true; + if (Array.isArray(exports)) + return true; + if (typeof exports !== 'object') + return false; + let isConditional = false; + let firstCheck = true; + for (const key of Object.keys(exports)) { + const curIsConditional = key[0] !== '.'; + if (firstCheck) { + firstCheck = false; + isConditional = curIsConditional; + } else if (isConditional !== curIsConditional) { + throw new ERR_INVALID_PACKAGE_CONFIG(basePath, '"exports" cannot ' + + 'contain some keys starting with \'.\' and some not. The exports ' + + 'object must either be an object of package subpath keys or an ' + + 'object of main entry condition name keys only.'); + } + } + return isConditional; +} + function applyExports(basePath, expansion) { - const pkgExports = readPackageExports(basePath); const mappingKey = `.${expansion}`; - if (typeof pkgExports === 'object' && pkgExports !== null) { + let pkgExports = readPackageExports(basePath); + if (pkgExports === undefined || pkgExports === null || !experimentalModules) + return path.resolve(basePath, mappingKey); + + if (isConditionalDotExportSugar(pkgExports, basePath)) + pkgExports = { '.': pkgExports }; + + if (typeof pkgExports === 'object') { if (ObjectPrototype.hasOwnProperty(pkgExports, mappingKey)) { const mapping = pkgExports[mappingKey]; return resolveExportsTarget(pathToFileURL(basePath + '/'), mapping, '', basePath, mappingKey); } + // Fallback to CJS main lookup when no main export is defined + if (mappingKey === '.') + return basePath; + let dirMatch = ''; for (const candidateKey of Object.keys(pkgExports)) { if (candidateKey[candidateKey.length - 1] !== '/') continue; @@ -476,19 +512,15 @@ function applyExports(basePath, expansion) { subpath, basePath, mappingKey); } } - if (mappingKey === '.' && typeof pkgExports === 'string') { - return resolveExportsTarget(pathToFileURL(basePath + '/'), pkgExports, - '', basePath, mappingKey); - } - if (pkgExports != null) { - // eslint-disable-next-line no-restricted-syntax - const e = new Error(`Package exports for '${basePath}' do not define ` + - `a '${mappingKey}' subpath`); - e.code = 'MODULE_NOT_FOUND'; - throw e; - } + // Fallback to CJS main lookup when no main export is defined + if (mappingKey === '.') + return basePath; - return path.resolve(basePath, mappingKey); + // eslint-disable-next-line no-restricted-syntax + const e = new Error(`Package exports for '${basePath}' do not define ` + + `a '${mappingKey}' subpath`); + e.code = 'MODULE_NOT_FOUND'; + throw e; } // This only applies to requests of a specific form: @@ -532,7 +564,7 @@ function resolveExportsTarget(pkgPath, target, subpath, basePath, mappingKey) { } } else if (Array.isArray(target)) { for (const targetValue of target) { - if (typeof targetValue !== 'string') continue; + if (Array.isArray(targetValue)) continue; try { return resolveExportsTarget(pkgPath, targetValue, subpath, basePath, mappingKey); @@ -540,10 +572,43 @@ function resolveExportsTarget(pkgPath, target, subpath, basePath, mappingKey) { if (e.code !== 'MODULE_NOT_FOUND') throw e; } } + } else if (typeof target === 'object' && target !== null) { + if (experimentalConditionalExports && + ObjectPrototype.hasOwnProperty(target, 'require')) { + try { + return resolveExportsTarget(pkgPath, target.require, subpath, + basePath, mappingKey); + } catch (e) { + if (e.code !== 'MODULE_NOT_FOUND') throw e; + } + } + if (experimentalConditionalExports && + ObjectPrototype.hasOwnProperty(target, 'node')) { + try { + return resolveExportsTarget(pkgPath, target.node, subpath, + basePath, mappingKey); + } catch (e) { + if (e.code !== 'MODULE_NOT_FOUND') throw e; + } + } + if (ObjectPrototype.hasOwnProperty(target, 'default')) { + try { + return resolveExportsTarget(pkgPath, target.default, subpath, + basePath, mappingKey); + } catch (e) { + if (e.code !== 'MODULE_NOT_FOUND') throw e; + } + } + } + let e; + if (mappingKey !== '.') { + // eslint-disable-next-line no-restricted-syntax + e = new Error(`Package exports for '${basePath}' do not define a ` + + `valid '${mappingKey}' target${subpath ? ' for ' + subpath : ''}`); + } else { + // eslint-disable-next-line no-restricted-syntax + e = new Error(`No valid exports main found for '${basePath}'`); } - // eslint-disable-next-line no-restricted-syntax - const e = new Error(`Package exports for '${basePath}' do not define a ` + - `valid '${mappingKey}' target${subpath ? 'for ' + subpath : ''}`); e.code = 'MODULE_NOT_FOUND'; throw e; } diff --git a/src/env.h b/src/env.h index 66077560e1f552..e91aa5fd69ce13 100644 --- a/src/env.h +++ b/src/env.h @@ -201,6 +201,7 @@ constexpr size_t kFsStatsBufferLength = V(crypto_rsa_pss_string, "rsa-pss") \ V(cwd_string, "cwd") \ V(data_string, "data") \ + V(default_string, "default") \ V(dest_string, "dest") \ V(destroyed_string, "destroyed") \ V(detached_string, "detached") \ @@ -215,6 +216,7 @@ constexpr size_t kFsStatsBufferLength = V(dns_srv_string, "SRV") \ V(dns_txt_string, "TXT") \ V(done_string, "done") \ + V(dot_string, ".") \ V(duration_string, "duration") \ V(emit_warning_string, "emitWarning") \ V(empty_object_string, "{}") \ @@ -279,6 +281,7 @@ constexpr size_t kFsStatsBufferLength = V(netmask_string, "netmask") \ V(next_string, "next") \ V(nistcurve_string, "nistCurve") \ + V(node_string, "node") \ V(nsname_string, "nsname") \ V(ocsp_request_string, "OCSPRequest") \ V(oncertcb_string, "oncertcb") \ diff --git a/src/module_wrap.cc b/src/module_wrap.cc index 4c4a1ce863849e..5745cce9e099ab 100644 --- a/src/module_wrap.cc +++ b/src/module_wrap.cc @@ -835,10 +835,16 @@ void ThrowExportsInvalid(Environment* env, const std::string& target, const URL& pjson_url, const URL& base) { - const std::string msg = "Cannot resolve package exports target '" + target + - "' matched for '" + subpath + "' in " + pjson_url.ToFilePath() + - ", imported from " + base.ToFilePath(); - node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str()); + if (subpath.length()) { + const std::string msg = "Cannot resolve package exports target '" + target + + "' matched for '" + subpath + "' in " + pjson_url.ToFilePath() + + ", imported from " + base.ToFilePath(); + node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str()); + } else { + const std::string msg = "Cannot resolve package main '" + target + "' in" + + pjson_url.ToFilePath() + ", imported from " + base.ToFilePath(); + node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str()); + } } void ThrowExportsInvalid(Environment* env, @@ -857,13 +863,13 @@ void ThrowExportsInvalid(Environment* env, } } -Maybe ResolveExportsTarget(Environment* env, - const std::string& target, - const std::string& subpath, - const std::string& match, - const URL& pjson_url, - const URL& base, - bool throw_invalid = true) { +Maybe ResolveExportsTargetString(Environment* env, + const std::string& target, + const std::string& subpath, + const std::string& match, + const URL& pjson_url, + const URL& base, + bool throw_invalid = true) { if (target.substr(0, 2) != "./") { if (throw_invalid) { ThrowExportsInvalid(env, match, target, pjson_url, base); @@ -901,68 +907,142 @@ Maybe ResolveExportsTarget(Environment* env, return Just(subpath_resolved); } +Maybe ResolveExportsTarget(Environment* env, + const URL& pjson_url, + Local target, + const std::string& subpath, + const std::string& pkg_subpath, + const URL& base, + bool throw_invalid = true) { + Isolate* isolate = env->isolate(); + Local context = env->context(); + if (target->IsString()) { + Utf8Value target_utf8(isolate, target.As()); + std::string target_str(*target_utf8, target_utf8.length()); + Maybe resolved = ResolveExportsTargetString(env, target_str, subpath, + pkg_subpath, pjson_url, base, throw_invalid); + if (resolved.IsNothing()) { + return Nothing(); + } + return FinalizeResolution(env, resolved.FromJust(), base); + } else if (target->IsArray()) { + Local target_arr = target.As(); + const uint32_t length = target_arr->Length(); + if (length == 0) { + if (throw_invalid) { + ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base); + } + return Nothing(); + } + for (uint32_t i = 0; i < length; i++) { + auto target_item = target_arr->Get(context, i).ToLocalChecked(); + if (!target_item->IsArray()) { + Maybe resolved = ResolveExportsTarget(env, pjson_url, + target_item, subpath, pkg_subpath, base, false); + if (resolved.IsNothing()) continue; + return FinalizeResolution(env, resolved.FromJust(), base); + } + } + if (throw_invalid) { + auto invalid = target_arr->Get(context, length - 1).ToLocalChecked(); + Maybe resolved = ResolveExportsTarget(env, pjson_url, invalid, + subpath, pkg_subpath, base, true); + CHECK(resolved.IsNothing()); + } + return Nothing(); + } else if (target->IsObject()) { + Local target_obj = target.As(); + bool matched = false; + Local conditionalTarget; + if (env->options()->experimental_conditional_exports && + target_obj->HasOwnProperty(context, env->node_string()).FromJust()) { + matched = true; + conditionalTarget = + target_obj->Get(context, env->node_string()).ToLocalChecked(); + Maybe resolved = ResolveExportsTarget(env, pjson_url, + conditionalTarget, subpath, pkg_subpath, base, false); + if (!resolved.IsNothing()) { + return resolved; + } + } + if (target_obj->HasOwnProperty(context, env->default_string()).FromJust()) { + matched = true; + conditionalTarget = + target_obj->Get(context, env->default_string()).ToLocalChecked(); + Maybe resolved = ResolveExportsTarget(env, pjson_url, + conditionalTarget, subpath, pkg_subpath, base, false); + if (!resolved.IsNothing()) { + return resolved; + } + } + if (matched && throw_invalid) { + Maybe resolved = ResolveExportsTarget(env, pjson_url, + conditionalTarget, subpath, pkg_subpath, base, true); + CHECK(resolved.IsNothing()); + return Nothing(); + } + } + if (throw_invalid) { + ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base); + } + return Nothing(); +} + +Maybe IsConditionalExportsMainSugar(Environment* env, + Local exports, + const URL& pjson_url, + const URL& base) { + if (exports->IsString() || exports->IsArray()) return Just(true); + if (!exports->IsObject()) return Just(false); + Local context = env->context(); + Local exports_obj = exports.As(); + Local keys = + exports_obj->GetOwnPropertyNames(context).ToLocalChecked(); + bool isConditionalSugar = false; + for (uint32_t i = 0; i < keys->Length(); ++i) { + Local key = keys->Get(context, i).ToLocalChecked().As(); + Utf8Value key_utf8(env->isolate(), key); + bool curIsConditionalSugar = key_utf8.length() == 0 || key_utf8[0] != '.'; + if (i == 0) { + isConditionalSugar = curIsConditionalSugar; + } else if (isConditionalSugar != curIsConditionalSugar) { + const std::string msg = "Cannot resolve package exports in " + + pjson_url.ToFilePath() + ", imported from " + base.ToFilePath() + ". " + + "\"exports\" cannot contain some keys starting with '.' and some not." + + " The exports object must either be an object of package subpath keys" + + " or an object of main entry condition name keys only."; + node::THROW_ERR_INVALID_PACKAGE_CONFIG(env, msg.c_str()); + return Nothing(); + } + } + return Just(isConditionalSugar); +} + Maybe PackageMainResolve(Environment* env, const URL& pjson_url, const PackageConfig& pcfg, const URL& base) { if (pcfg.exists == Exists::Yes) { Isolate* isolate = env->isolate(); - Local context = env->context(); + if (!pcfg.exports.IsEmpty()) { Local exports = pcfg.exports.Get(isolate); - if (exports->IsString() || exports->IsObject() || exports->IsArray()) { - Local target; - if (!exports->IsObject()) { - target = exports; - } else { - Local exports_obj = exports.As(); - Local dot_string = String::NewFromUtf8(env->isolate(), ".", - v8::NewStringType::kNormal).ToLocalChecked(); - target = - exports_obj->Get(env->context(), dot_string).ToLocalChecked(); - } - if (target->IsString()) { - Utf8Value target_utf8(isolate, target.As()); - std::string target(*target_utf8, target_utf8.length()); - Maybe resolved = ResolveExportsTarget(env, target, "", ".", - pjson_url, base); - if (resolved.IsNothing()) { - ThrowExportsInvalid(env, ".", target, pjson_url, base); - return Nothing(); - } - return FinalizeResolution(env, resolved.FromJust(), base); - } else if (target->IsArray()) { - Local target_arr = target.As(); - const uint32_t length = target_arr->Length(); - if (length == 0) { - ThrowExportsInvalid(env, ".", target, pjson_url, base); - return Nothing(); - } - for (uint32_t i = 0; i < length; i++) { - auto target_item = target_arr->Get(context, i).ToLocalChecked(); - if (target_item->IsString()) { - Utf8Value target_utf8(isolate, target_item.As()); - std::string target_str(*target_utf8, target_utf8.length()); - Maybe resolved = ResolveExportsTarget(env, target_str, "", - ".", pjson_url, base, false); - if (resolved.IsNothing()) continue; - return FinalizeResolution(env, resolved.FromJust(), base); - } - } - auto invalid = target_arr->Get(context, length - 1).ToLocalChecked(); - if (!invalid->IsString()) { - ThrowExportsInvalid(env, ".", invalid, pjson_url, base); - return Nothing(); - } - Utf8Value invalid_utf8(isolate, invalid.As()); - std::string invalid_str(*invalid_utf8, invalid_utf8.length()); - Maybe resolved = ResolveExportsTarget(env, invalid_str, "", - ".", pjson_url, base); - CHECK(resolved.IsNothing()); - return Nothing(); - } else { - ThrowExportsInvalid(env, ".", target, pjson_url, base); - return Nothing(); + Maybe isConditionalExportsMainSugar = + IsConditionalExportsMainSugar(env, exports, pjson_url, base); + if (isConditionalExportsMainSugar.IsNothing()) + return Nothing(); + if (isConditionalExportsMainSugar.FromJust()) { + return ResolveExportsTarget(env, pjson_url, exports, "", "", base, + true); + } else if (exports->IsObject()) { + Local exports_obj = exports.As(); + if (exports_obj->HasOwnProperty(env->context(), env->dot_string()) + .FromJust()) { + Local target = + exports_obj->Get(env->context(), env->dot_string()) + .ToLocalChecked(); + return ResolveExportsTarget(env, pjson_url, target, "", "", base, + true); } } } @@ -1002,7 +1082,11 @@ Maybe PackageExportsResolve(Environment* env, Isolate* isolate = env->isolate(); Local context = env->context(); Local exports = pcfg.exports.Get(isolate); - if (!exports->IsObject()) { + Maybe isConditionalExportsMainSugar = + IsConditionalExportsMainSugar(env, exports, pjson_url, base); + if (isConditionalExportsMainSugar.IsNothing()) + return Nothing(); + if (!exports->IsObject() || isConditionalExportsMainSugar.FromJust()) { ThrowExportsNotFound(env, pkg_subpath, pjson_url, base); return Nothing(); } @@ -1012,49 +1096,12 @@ Maybe PackageExportsResolve(Environment* env, if (exports_obj->HasOwnProperty(context, subpath).FromJust()) { Local target = exports_obj->Get(context, subpath).ToLocalChecked(); - if (target->IsString()) { - Utf8Value target_utf8(isolate, target.As()); - std::string target_str(*target_utf8, target_utf8.length()); - Maybe resolved = ResolveExportsTarget(env, target_str, "", - pkg_subpath, pjson_url, base); - if (resolved.IsNothing()) { - ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base); - return Nothing(); - } - return FinalizeResolution(env, resolved.FromJust(), base); - } else if (target->IsArray()) { - Local target_arr = target.As(); - const uint32_t length = target_arr->Length(); - if (length == 0) { - ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base); - return Nothing(); - } - for (uint32_t i = 0; i < length; i++) { - auto target_item = target_arr->Get(context, i).ToLocalChecked(); - if (target_item->IsString()) { - Utf8Value target_utf8(isolate, target_item.As()); - std::string target(*target_utf8, target_utf8.length()); - Maybe resolved = ResolveExportsTarget(env, target, "", - pkg_subpath, pjson_url, base, false); - if (resolved.IsNothing()) continue; - return FinalizeResolution(env, resolved.FromJust(), base); - } - } - auto invalid = target_arr->Get(context, length - 1).ToLocalChecked(); - if (!invalid->IsString()) { - ThrowExportsInvalid(env, pkg_subpath, invalid, pjson_url, base); - return Nothing(); - } - Utf8Value invalid_utf8(isolate, invalid.As()); - std::string invalid_str(*invalid_utf8, invalid_utf8.length()); - Maybe resolved = ResolveExportsTarget(env, invalid_str, "", - pkg_subpath, pjson_url, base); - CHECK(resolved.IsNothing()); - return Nothing(); - } else { - ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base); + Maybe resolved = ResolveExportsTarget(env, pjson_url, target, "", + pkg_subpath, base); + if (resolved.IsNothing()) { return Nothing(); } + return FinalizeResolution(env, resolved.FromJust(), base); } Local best_match; @@ -1076,49 +1123,13 @@ Maybe PackageExportsResolve(Environment* env, if (best_match_str.length() > 0) { auto target = exports_obj->Get(context, best_match).ToLocalChecked(); std::string subpath = pkg_subpath.substr(best_match_str.length()); - if (target->IsString()) { - Utf8Value target_utf8(isolate, target.As()); - std::string target(*target_utf8, target_utf8.length()); - Maybe resolved = ResolveExportsTarget(env, target, subpath, - pkg_subpath, pjson_url, base); - if (resolved.IsNothing()) { - ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base); - return Nothing(); - } - return FinalizeResolution(env, URL(subpath, resolved.FromJust()), base); - } else if (target->IsArray()) { - Local target_arr = target.As(); - const uint32_t length = target_arr->Length(); - if (length == 0) { - ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base); - return Nothing(); - } - for (uint32_t i = 0; i < length; i++) { - auto target_item = target_arr->Get(context, i).ToLocalChecked(); - if (target_item->IsString()) { - Utf8Value target_utf8(isolate, target_item.As()); - std::string target_str(*target_utf8, target_utf8.length()); - Maybe resolved = ResolveExportsTarget(env, target_str, subpath, - pkg_subpath, pjson_url, base, false); - if (resolved.IsNothing()) continue; - return FinalizeResolution(env, resolved.FromJust(), base); - } - } - auto invalid = target_arr->Get(context, length - 1).ToLocalChecked(); - if (!invalid->IsString()) { - ThrowExportsInvalid(env, pkg_subpath, invalid, pjson_url, base); - return Nothing(); - } - Utf8Value invalid_utf8(isolate, invalid.As()); - std::string invalid_str(*invalid_utf8, invalid_utf8.length()); - Maybe resolved = ResolveExportsTarget(env, invalid_str, subpath, - pkg_subpath, pjson_url, base); - CHECK(resolved.IsNothing()); - return Nothing(); - } else { - ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base); + + Maybe resolved = ResolveExportsTarget(env, pjson_url, target, subpath, + pkg_subpath, base); + if (resolved.IsNothing()) { return Nothing(); } + return FinalizeResolution(env, resolved.FromJust(), base); } ThrowExportsNotFound(env, pkg_subpath, pjson_url, base); diff --git a/src/node_options.cc b/src/node_options.cc index 6bc0beac70a905..dfec179d80938e 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -340,6 +340,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "experimental ES Module support and caching modules", &EnvironmentOptions::experimental_modules, kAllowedInEnvironment); + AddOption("--experimental-conditional-exports", + "experimental support for conditional exports targets", + &EnvironmentOptions::experimental_conditional_exports, + kAllowedInEnvironment); AddOption("--experimental-resolve-self", "experimental support for require/import of the current package", &EnvironmentOptions::experimental_resolve_self, diff --git a/src/node_options.h b/src/node_options.h index 662a9bef7df3b2..19fde898a2a012 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -101,6 +101,7 @@ class EnvironmentOptions : public Options { public: bool abort_on_uncaught_exception = false; bool enable_source_maps = false; + bool experimental_conditional_exports = false; bool experimental_json_modules = false; bool experimental_modules = false; bool experimental_resolve_self = false; diff --git a/test/es-module/test-esm-exports.mjs b/test/es-module/test-esm-exports.mjs index d8c33994188138..2683b5df68e9fa 100644 --- a/test/es-module/test-esm-exports.mjs +++ b/test/es-module/test-esm-exports.mjs @@ -1,4 +1,4 @@ -// Flags: --experimental-modules --experimental-resolve-self +// Flags: --experimental-modules --experimental-resolve-self --experimental-conditional-exports import { mustCall } from '../common/index.mjs'; import { ok, deepStrictEqual, strictEqual } from 'assert'; @@ -23,7 +23,16 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js'; ['pkgexports/fallbackfile', { default: 'asdf' }], // Dot main ['pkgexports', { default: 'asdf' }], + // Conditional split for require + ['pkgexports/condition', isRequire ? { default: 'encoded path' } : + { default: 'asdf' }], + // String exports sugar + ['pkgexports-sugar', { default: 'main' }], + // Conditional object exports sugar + ['pkgexports-sugar2', isRequire ? { default: 'not-exported' } : + { default: 'main' }] ]); + for (const [validSpecifier, expected] of validSpecifiers) { if (validSpecifier === null) continue; @@ -39,6 +48,9 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js'; // The file exists but isn't exported. The exports is a number which counts // as a non-null value without any properties, just like `{}`. ['pkgexports-number/hidden.js', './hidden.js'], + // Sugar cases still encapsulate + ['pkgexports-sugar/not-exported.js', './not-exported.js'], + ['pkgexports-sugar2/not-exported.js', './not-exported.js'] ]); const invalidExports = new Map([ @@ -79,7 +91,7 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js'; assertStartsWith(err.message, (isRequire ? 'Package exports' : 'Cannot resolve')); assertIncludes(err.message, isRequire ? - `do not define a valid '${subpath}' subpath` : + `do not define a valid '${subpath}' target` : `matched for '${subpath}'`); })); } @@ -93,11 +105,22 @@ import fromInside from '../fixtures/node_modules/pkgexports/lib/hole.js'; 'Cannot find module'); })); - // THe use of %2F escapes in paths fails loading + // The use of %2F escapes in paths fails loading loadFixture('pkgexports/sub/..%2F..%2Fbar.js').catch(mustCall((err) => { strictEqual(err.code, isRequire ? 'ERR_INVALID_FILE_URL_PATH' : 'ERR_MODULE_NOT_FOUND'); })); + + // Sugar conditional exports main mixed failure case + loadFixture('pkgexports-sugar-fail').catch(mustCall((err) => { + strictEqual(err.code, 'ERR_INVALID_PACKAGE_CONFIG'); + assertStartsWith(err.message, (isRequire ? 'Invalid package' : + 'Cannot resolve')); + assertIncludes(err.message, '"exports" cannot contain some keys starting ' + + 'with \'.\' and some not. The exports object must either be an object of ' + + 'package subpath keys or an object of main entry condition name keys ' + + 'only.'); + })); }); const { requireFromInside, importFromInside } = fromInside; @@ -124,6 +147,6 @@ function assertStartsWith(actual, expected) { } function assertIncludes(actual, expected) { - ok(actual.toString().indexOf(expected), + ok(actual.toString().indexOf(expected) !== -1, `${JSON.stringify(actual)} includes ${JSON.stringify(expected)}`); } diff --git a/test/fixtures/node_modules/pkgexports-sugar-fail/main.js b/test/fixtures/node_modules/pkgexports-sugar-fail/main.js new file mode 100644 index 00000000000000..dfdd47b877319c --- /dev/null +++ b/test/fixtures/node_modules/pkgexports-sugar-fail/main.js @@ -0,0 +1 @@ +module.exports = 'main'; diff --git a/test/fixtures/node_modules/pkgexports-sugar-fail/not-exported.js b/test/fixtures/node_modules/pkgexports-sugar-fail/not-exported.js new file mode 100644 index 00000000000000..02e146dbe90985 --- /dev/null +++ b/test/fixtures/node_modules/pkgexports-sugar-fail/not-exported.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = 'not-exported'; diff --git a/test/fixtures/node_modules/pkgexports-sugar-fail/package.json b/test/fixtures/node_modules/pkgexports-sugar-fail/package.json new file mode 100644 index 00000000000000..0fb05a427a76e2 --- /dev/null +++ b/test/fixtures/node_modules/pkgexports-sugar-fail/package.json @@ -0,0 +1,6 @@ +{ + "exports": { + "default": "./main.js", + "./main": "./main.js" + } +} diff --git a/test/fixtures/node_modules/pkgexports-sugar/main.js b/test/fixtures/node_modules/pkgexports-sugar/main.js new file mode 100644 index 00000000000000..dfdd47b877319c --- /dev/null +++ b/test/fixtures/node_modules/pkgexports-sugar/main.js @@ -0,0 +1 @@ +module.exports = 'main'; diff --git a/test/fixtures/node_modules/pkgexports-sugar/not-exported.js b/test/fixtures/node_modules/pkgexports-sugar/not-exported.js new file mode 100644 index 00000000000000..02e146dbe90985 --- /dev/null +++ b/test/fixtures/node_modules/pkgexports-sugar/not-exported.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = 'not-exported'; diff --git a/test/fixtures/node_modules/pkgexports-sugar/package.json b/test/fixtures/node_modules/pkgexports-sugar/package.json new file mode 100644 index 00000000000000..5ebad0b4bda380 --- /dev/null +++ b/test/fixtures/node_modules/pkgexports-sugar/package.json @@ -0,0 +1,3 @@ +{ + "exports": "./main.js" +} diff --git a/test/fixtures/node_modules/pkgexports-sugar2/main.js b/test/fixtures/node_modules/pkgexports-sugar2/main.js new file mode 100644 index 00000000000000..dfdd47b877319c --- /dev/null +++ b/test/fixtures/node_modules/pkgexports-sugar2/main.js @@ -0,0 +1 @@ +module.exports = 'main'; diff --git a/test/fixtures/node_modules/pkgexports-sugar2/not-exported.js b/test/fixtures/node_modules/pkgexports-sugar2/not-exported.js new file mode 100644 index 00000000000000..02e146dbe90985 --- /dev/null +++ b/test/fixtures/node_modules/pkgexports-sugar2/not-exported.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = 'not-exported'; diff --git a/test/fixtures/node_modules/pkgexports-sugar2/package.json b/test/fixtures/node_modules/pkgexports-sugar2/package.json new file mode 100644 index 00000000000000..139b06665d85e0 --- /dev/null +++ b/test/fixtures/node_modules/pkgexports-sugar2/package.json @@ -0,0 +1,6 @@ +{ + "exports": { + "require": "./not-exported.js", + "default": "./main.js" + } +} diff --git a/test/fixtures/node_modules/pkgexports/package.json b/test/fixtures/node_modules/pkgexports/package.json index 38e2fc1a5c02f6..37c28cdc1a950f 100644 --- a/test/fixtures/node_modules/pkgexports/package.json +++ b/test/fixtures/node_modules/pkgexports/package.json @@ -1,7 +1,7 @@ { "name": "@pkgexports/name", + "main": "./asdf.js", "exports": { - ".": "./asdf.js", "./hole": "./lib/hole.js", "./space": "./sp%20ce.js", "./valid-cjs": "./asdf.js", @@ -18,6 +18,7 @@ "./fallbackfile": [[], null, {}, "builtin:x", "./asdf.js"], "./nofallback1": [], "./nofallback2": [null, {}, "builtin:x"], - "./nodemodules": "./node_modules/internalpkg/x.js" + "./nodemodules": "./node_modules/internalpkg/x.js", + "./condition": [{ "require": "./sp ce.js" }, "./asdf.js"] } }