diff --git a/doc/api/esm.md b/doc/api/esm.md
index 07a3a71e90f0fd..ac1f154ff3e540 100644
--- a/doc/api/esm.md
+++ b/doc/api/esm.md
@@ -242,13 +242,13 @@ throw when an attempt is made to import them:
```js
import submodule from 'es-module-package/private-module.js';
-// Throws - Package exports error
+// Throws - Module not found
```
> Note: this is not a strong encapsulation as any private modules can still be
> loaded by absolute paths.
-Folders can also be mapped with package exports as well:
+Folders can also be mapped with package exports:
```js
@@ -268,8 +268,24 @@ import feature from 'es-module-package/features/x.js';
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.
-This is just a convention that works because `false`, just like `{}`, has no
-iterable own properties.
+
+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 forward-compatible with fallback workflows in future:
+
+
+```js
+{
+ "exports": {
+ "./submodule": ["not:valid", "./submodule.js"]
+ }
+}
+```
+
+Since `"not:valid"` is not a supported target, `"./submodule.js"` is used
+instead as the fallback, as if it were the only target.
## import Specifiers
@@ -660,7 +676,7 @@ CommonJS loader. Additional formats such as _"addon"_ can be extended in future
updates.
In the following algorithms, all subroutine errors are propagated as errors
-of these top-level routines.
+of these top-level routines unless stated otherwise.
_isMain_ is **true** when resolving the Node.js application entry point.
@@ -681,6 +697,9 @@ _isMain_ is **true** when resolving the Node.js application entry point.
> 1. Note: _specifier_ is now a bare specifier.
> 1. Set _resolvedURL_ the result of
> **PACKAGE_RESOLVE**(_specifier_, _parentURL_).
+> 1. If _resolvedURL_ contains any percent encodings of _"/"_ or _"\\"_ (_"%2f"_
+> and _"%5C"_ respectively), then
+> 1. Throw an _Invalid Specifier_ error.
> 1. If the file at _resolvedURL_ does not exist, then
> 1. Throw a _Module Not Found_ error.
> 1. Set _resolvedURL_ to the real path of _resolvedURL_.
@@ -735,7 +754,7 @@ _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.main_ is a String, then
-> 1. Let _resolvedMain_ be the concatenation of _packageURL_, "/", and
+> 1. Let _resolvedMain_ be the URL resolution of _packageURL_, "/", and
> _pjson.main_.
> 1. If the file at _resolvedMain_ exists, then
> 1. Return _resolvedMain_.
@@ -744,8 +763,6 @@ _isMain_ is **true** when resolving the Node.js application entry point.
> 1. Let _legacyMainURL_ be the result applying the legacy
> **LOAD_AS_DIRECTORY** CommonJS resolver to _packageURL_, throwing a
> _Module Not Found_ error for no resolution.
-> 1. If _legacyMainURL_ does not end in _".js"_ then,
-> 1. Throw an _Unsupported File Extension_ error.
> 1. Return _legacyMainURL_.
**PACKAGE_EXPORTS_RESOLVE**(_packageURL_, _packagePath_, _exports_)
@@ -753,19 +770,42 @@ _isMain_ is **true** when resolving the Node.js application entry point.
> 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. If _target_ is not a String, continue the loop.
-> 1. Return the URL resolution of the concatenation of _packageURL_ and
-> _target_.
+> 1. Return **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_,
+> _""_).
> 1. Let _directoryKeys_ be the list of keys of _exports_ ending in
> _"/"_, sorted by length descending.
> 1. For each key _directory_ in _directoryKeys_, do
> 1. If _packagePath_ starts with _directory_, then
> 1. Let _target_ be the value of _exports[directory]_.
-> 1. If _target_ is not a String, continue the loop.
> 1. Let _subpath_ be the substring of _target_ starting at the index
> of the length of _directory_.
-> 1. Return the URL resolution of the concatenation of _packageURL_,
-> _target_ and _subpath_.
+> 1. Return **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_,
+> _subpath_).
+> 1. Throw a _Module Not Found_ error.
+
+**PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_, _subpath_)
+> 1. If _target_ is a String, then
+> 1. If _target_ does not start with _"./"_, throw a _Module Not Found_
+> error.
+> 1. If _subpath_ has non-zero length and _target_ does not end with _"/"_,
+> throw a _Module Not Found_ error.
+> 1. If _target_ or _subpath_ contain any _"node_modules"_ segments including
+> _"node_modules"_ percent-encoding, throw a _Module Not Found_ error.
+> 1. Let _resolvedTarget_ be the URL resolution of the concatenation of
+> _packageURL_ and _target_.
+> 1. If _resolvedTarget_ is contained in _packageURL_, then
+> 1. Let _resolved_ be the URL resolution of the concatenation of
+> _subpath_ and _resolvedTarget_.
+> 1. If _resolved_ is contained in _resolvedTarget_, then
+> 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. Let _resolved_ be the result of
+> **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _targetValue_,
+> _subpath_), continuing the loop on abrupt completion.
+> 1. Assert: _resolved_ is a String.
+> 1. Return _resolved_.
> 1. Throw a _Module Not Found_ error.
**ESM_FORMAT**(_url_, _isMain_)
@@ -788,6 +828,7 @@ _isMain_ is **true** when resolving the Node.js application entry point.
**READ_PACKAGE_SCOPE**(_url_)
> 1. Let _scopeURL_ be _url_.
> 1. While _scopeURL_ is not the file system root,
+> 1. If _scopeURL_ ends in a _"node_modules"_ path segment, return **null**.
> 1. Let _pjson_ be the result of **READ_PACKAGE_JSON**(_scopeURL_).
> 1. If _pjson_ is not **null**, then
> 1. Return _pjson_.
diff --git a/doc/api/modules.md b/doc/api/modules.md
index bf8209965e9122..7197ef6ae2fdaa 100644
--- a/doc/api/modules.md
+++ b/doc/api/modules.md
@@ -202,11 +202,12 @@ NODE_MODULES_PATHS(START)
5. return DIRS
```
-If `--experimental-exports` is enabled,
-node allows packages loaded via `LOAD_NODE_MODULES` to explicitly declare
-which filepaths to expose and how they should be interpreted.
-This expands on the control packages already had using the `main` field.
-With this feature enabled, the `LOAD_NODE_MODULES` changes as follows:
+If `--experimental-exports` is enabled, Node.js allows packages loaded via
+`LOAD_NODE_MODULES` to explicitly declare which file paths to expose and how
+they should be interpreted. This expands on the control packages already had
+using the `main` field.
+
+With this feature enabled, the `LOAD_NODE_MODULES` changes are:
```txt
LOAD_NODE_MODULES(X, START)
@@ -224,10 +225,10 @@ RESOLVE_BARE_SPECIFIER(DIR, X)
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. If the key matches the subpath entirely, return DIR/name/${exports[key]}.
- f. If either the key or exports[key] do not end with a slash (`/`),
- throw "not found".
- g. Return DIR/name/${exports[key]}${subpath.slice(key.length)}.
+ e. 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)
3. return DIR/X
```
diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js
index 95b56e08520a52..0ea8a46855e68a 100644
--- a/lib/internal/modules/cjs/loader.js
+++ b/lib/internal/modules/cjs/loader.js
@@ -24,6 +24,7 @@
const {
JSON,
Object,
+ ObjectPrototype,
Reflect,
SafeMap,
StringPrototype,
@@ -348,18 +349,18 @@ function resolveExports(nmPath, request, absoluteRequest) {
const basePath = path.resolve(nmPath, name);
const pkgExports = readExports(basePath);
+ const mappingKey = `.${expansion}`;
- if (pkgExports != null) {
- const mappingKey = `.${expansion}`;
- const mapping = pkgExports[mappingKey];
- if (typeof mapping === 'string') {
- return fileURLToPath(new URL(mapping, `${pathToFileURL(basePath)}/`));
+ if (typeof pkgExports === 'object' && pkgExports !== null) {
+ if (ObjectPrototype.hasOwnProperty(pkgExports, mappingKey)) {
+ const mapping = pkgExports[mappingKey];
+ return resolveExportsTarget(pathToFileURL(basePath + '/'), mapping, '',
+ basePath, mappingKey);
}
let dirMatch = '';
- for (const [candidateKey, candidateValue] of Object.entries(pkgExports)) {
+ for (const candidateKey of Object.keys(pkgExports)) {
if (candidateKey[candidateKey.length - 1] !== '/') continue;
- if (candidateValue[candidateValue.length - 1] !== '/') continue;
if (candidateKey.length > dirMatch.length &&
StringPrototype.startsWith(mappingKey, candidateKey)) {
dirMatch = candidateKey;
@@ -367,15 +368,13 @@ function resolveExports(nmPath, request, absoluteRequest) {
}
if (dirMatch !== '') {
- const dirMapping = pkgExports[dirMatch];
- const remainder = StringPrototype.slice(mappingKey, dirMatch.length);
- const expectedPrefix =
- new URL(dirMapping, `${pathToFileURL(basePath)}/`);
- const resolved = new URL(remainder, expectedPrefix).href;
- if (StringPrototype.startsWith(resolved, expectedPrefix.href)) {
- return fileURLToPath(resolved);
- }
+ const mapping = pkgExports[dirMatch];
+ const subpath = StringPrototype.slice(mappingKey, dirMatch.length);
+ return resolveExportsTarget(pathToFileURL(basePath + '/'), mapping,
+ subpath, 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`);
@@ -387,6 +386,43 @@ function resolveExports(nmPath, request, absoluteRequest) {
return path.resolve(nmPath, request);
}
+function resolveExportsTarget(pkgPath, target, subpath, basePath, mappingKey) {
+ if (typeof target === 'string') {
+ if (target.startsWith('./') &&
+ (subpath.length === 0 || target.endsWith('/'))) {
+ const resolvedTarget = new URL(target, pkgPath);
+ const pkgPathPath = pkgPath.pathname;
+ const resolvedTargetPath = resolvedTarget.pathname;
+ if (StringPrototype.startsWith(resolvedTargetPath, pkgPathPath) &&
+ StringPrototype.indexOf(resolvedTargetPath, '/node_modules/',
+ pkgPathPath.length - 1) === -1) {
+ const resolved = new URL(subpath, resolvedTarget);
+ const resolvedPath = resolved.pathname;
+ if (StringPrototype.startsWith(resolvedPath, resolvedTargetPath) &&
+ StringPrototype.indexOf(resolvedPath, '/node_modules/',
+ pkgPathPath.length - 1) === -1) {
+ return fileURLToPath(resolved);
+ }
+ }
+ }
+ } else if (Array.isArray(target)) {
+ for (const targetValue of target) {
+ if (typeof targetValue !== 'string') continue;
+ try {
+ return resolveExportsTarget(pkgPath, targetValue, subpath, basePath,
+ mappingKey);
+ } catch (e) {
+ if (e.code !== 'MODULE_NOT_FOUND') throw e;
+ }
+ }
+ }
+ // 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;
+}
+
Module._findPath = function(request, paths, isMain) {
const absoluteRequest = path.isAbsolute(request);
if (absoluteRequest) {
diff --git a/src/module_wrap.cc b/src/module_wrap.cc
index 5b33ef261cf69c..83cc9863a62553 100644
--- a/src/module_wrap.cc
+++ b/src/module_wrap.cc
@@ -545,8 +545,8 @@ Maybe GetPackageConfig(Environment* env,
if (existing != env->package_json_cache.end()) {
const PackageConfig* pcfg = &existing->second;
if (pcfg->is_valid == IsValid::No) {
- std::string msg = "Invalid JSON in '" + path +
- "' imported from " + base.ToFilePath();
+ std::string msg = "Invalid JSON in " + path +
+ " imported from " + base.ToFilePath();
node::THROW_ERR_INVALID_PACKAGE_CONFIG(env, msg.c_str());
return Nothing();
}
@@ -579,8 +579,8 @@ Maybe GetPackageConfig(Environment* env,
env->package_json_cache.emplace(path,
PackageConfig { Exists::Yes, IsValid::No, HasMain::No, "",
PackageType::None, Global() });
- std::string msg = "Invalid JSON in '" + path +
- "' imported from " + base.ToFilePath();
+ std::string msg = "Invalid JSON in " + path +
+ " imported from " + base.ToFilePath();
node::THROW_ERR_INVALID_PACKAGE_CONFIG(env, msg.c_str());
return Nothing();
}
@@ -633,6 +633,12 @@ Maybe GetPackageScopeConfig(Environment* env,
const URL& base) {
URL pjson_url("./package.json", &resolved);
while (true) {
+ std::string pjson_url_path = pjson_url.path();
+ if (pjson_url_path.length() > 25 &&
+ pjson_url_path.substr(pjson_url_path.length() - 25, 25) ==
+ "node_modules/package.json") {
+ break;
+ }
Maybe pkg_cfg =
GetPackageConfig(env, pjson_url.ToFilePath(), base);
if (pkg_cfg.IsNothing()) return pkg_cfg;
@@ -643,14 +649,13 @@ Maybe GetPackageScopeConfig(Environment* env,
// Terminates at root where ../package.json equals ../../package.json
// (can't just check "/package.json" for Windows support).
- if (pjson_url.path() == last_pjson_url.path()) {
- auto entry = env->package_json_cache.emplace(pjson_url.ToFilePath(),
- PackageConfig { Exists::No, IsValid::Yes, HasMain::No, "",
- PackageType::None, Global() });
- const PackageConfig* pcfg = &entry.first->second;
- return Just(pcfg);
- }
+ if (pjson_url.path() == last_pjson_url.path()) break;
}
+ auto entry = env->package_json_cache.emplace(pjson_url.ToFilePath(),
+ PackageConfig { Exists::No, IsValid::Yes, HasMain::No, "",
+ PackageType::None, Global() });
+ const PackageConfig* pcfg = &entry.first->second;
+ return Just(pcfg);
}
/*
@@ -750,16 +755,17 @@ Maybe FinalizeResolution(Environment* env,
if (!file.IsNothing()) {
return file;
}
- std::string msg = "Cannot find module '" + resolved.path() +
- "' imported from " + base.ToFilePath();
+ std::string msg = "Cannot find module " + resolved.path() +
+ " imported from " + base.ToFilePath();
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
return Nothing();
}
const std::string& path = resolved.ToFilePath();
if (CheckDescriptorAtPath(path) != FILE) {
- std::string msg = "Cannot find module '" + path +
- "' imported from " + base.ToFilePath();
+ std::string msg = "Cannot find module " +
+ (path.length() != 0 ? path : resolved.path()) +
+ " imported from " + base.ToFilePath();
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
return Nothing();
}
@@ -793,13 +799,94 @@ Maybe PackageMainResolve(Environment* env,
}
}
}
- std::string msg = "Cannot find main entry point for '" +
- URL(".", pjson_url).ToFilePath() + "' imported from " +
+ std::string msg = "Cannot find main entry point for " +
+ URL(".", pjson_url).ToFilePath() + " imported from " +
base.ToFilePath();
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
return Nothing();
}
+void ThrowExportsNotFound(Environment* env,
+ const std::string& subpath,
+ const URL& pjson_url,
+ const URL& base) {
+ const std::string msg = "Package exports for " +
+ pjson_url.ToFilePath() + " do not define a '" + subpath +
+ "' subpath, imported from " + base.ToFilePath();
+ node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
+}
+
+void ThrowExportsInvalid(Environment* env,
+ const std::string& subpath,
+ 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());
+}
+
+void ThrowExportsInvalid(Environment* env,
+ const std::string& subpath,
+ Local target,
+ const URL& pjson_url,
+ const URL& base) {
+ Local target_string;
+ if (target->ToString(env->context()).ToLocal(&target_string)) {
+ Utf8Value target_utf8(env->isolate(), target_string);
+ std::string target_str(*target_utf8, target_utf8.length());
+ if (target->IsArray()) {
+ target_str = '[' + target_str + ']';
+ }
+ ThrowExportsInvalid(env, subpath, target_str, pjson_url, base);
+ }
+}
+
+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) {
+ if (target.substr(0, 2) != "./") {
+ if (throw_invalid) {
+ ThrowExportsInvalid(env, match, target, pjson_url, base);
+ }
+ return Nothing();
+ }
+ if (subpath.length() > 0 && target.back() != '/') {
+ if (throw_invalid) {
+ ThrowExportsInvalid(env, match, target, pjson_url, base);
+ }
+ return Nothing();
+ }
+ URL resolved(target, pjson_url);
+ std::string resolved_path = resolved.path();
+ std::string pkg_path = URL(".", pjson_url).path();
+ if (resolved_path.find(pkg_path) != 0 ||
+ resolved_path.find("/node_modules/", pkg_path.length() - 1) !=
+ std::string::npos) {
+ if (throw_invalid) {
+ ThrowExportsInvalid(env, match, target, pjson_url, base);
+ }
+ return Nothing();
+ }
+ if (subpath.length() == 0) return Just(resolved);
+ URL subpath_resolved(subpath, resolved);
+ std::string subpath_resolved_path = subpath_resolved.path();
+ if (subpath_resolved_path.find(resolved_path) != 0 ||
+ subpath_resolved_path.find("/node_modules/", pkg_path.length() - 1)
+ != std::string::npos) {
+ if (throw_invalid) {
+ ThrowExportsInvalid(env, match, target + subpath, pjson_url, base);
+ }
+ return Nothing();
+ }
+ return Just(subpath_resolved);
+}
+
Maybe PackageExportsResolve(Environment* env,
const URL& pjson_url,
const std::string& pkg_subpath,
@@ -809,57 +896,126 @@ Maybe PackageExportsResolve(Environment* env,
Isolate* isolate = env->isolate();
Local context = env->context();
Local exports = pcfg.exports.Get(isolate);
- if (exports->IsObject()) {
- Local