module: pkg exports validations and fallbacks

PR-URL: https://github.com/nodejs/node/pull/28949
Reviewed-By: Bradley Farias <bradley.meck@gmail.com>
Reviewed-By: Jan Krems <jan.krems@gmail.com>
This commit is contained in:
Guy Bedford 2019-08-02 01:30:32 -04:00
parent 15b2d13310
commit 2103ae4835
10 changed files with 421 additions and 128 deletions

View File

@ -242,13 +242,13 @@ throw when an attempt is made to import them:
```js ```js
import submodule from 'es-module-package/private-module.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 > Note: this is not a strong encapsulation as any private modules can still be
> loaded by absolute paths. > loaded by absolute paths.
Folders can also be mapped with package exports as well: Folders can also be mapped with package exports:
<!-- eslint-skip --> <!-- eslint-skip -->
```js ```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 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 `"exports": {}` to indicate the package does not intend for submodules to be
exposed. 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:
<!-- eslint-skip -->
```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.
## <code>import</code> Specifiers ## <code>import</code> Specifiers
@ -660,7 +676,7 @@ CommonJS loader. Additional formats such as _"addon"_ can be extended in future
updates. updates.
In the following algorithms, all subroutine errors are propagated as errors 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. _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. Note: _specifier_ is now a bare specifier.
> 1. Set _resolvedURL_ the result of > 1. Set _resolvedURL_ the result of
> **PACKAGE_RESOLVE**(_specifier_, _parentURL_). > **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. If the file at _resolvedURL_ does not exist, then
> 1. Throw a _Module Not Found_ error. > 1. Throw a _Module Not Found_ error.
> 1. Set _resolvedURL_ to the real path of _resolvedURL_. > 1. Set _resolvedURL_ to the real path of _resolvedURL_.
@ -737,7 +756,7 @@ _isMain_ is **true** when resolving the Node.js application entry point.
> 1. If _pjson_ is **null**, then > 1. If _pjson_ is **null**, then
> 1. Throw a _Module Not Found_ error. > 1. Throw a _Module Not Found_ error.
> 1. If _pjson.main_ is a String, then > 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_. > _pjson.main_.
> 1. If the file at _resolvedMain_ exists, then > 1. If the file at _resolvedMain_ exists, then
> 1. Return _resolvedMain_. > 1. Return _resolvedMain_.
@ -746,8 +765,6 @@ _isMain_ is **true** when resolving the Node.js application entry point.
> 1. Let _legacyMainURL_ be the result applying the legacy > 1. Let _legacyMainURL_ be the result applying the legacy
> **LOAD_AS_DIRECTORY** CommonJS resolver to _packageURL_, throwing a > **LOAD_AS_DIRECTORY** CommonJS resolver to _packageURL_, throwing a
> _Module Not Found_ error for no resolution. > _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_. > 1. Return _legacyMainURL_.
**PACKAGE_EXPORTS_RESOLVE**(_packageURL_, _packagePath_, _exports_) **PACKAGE_EXPORTS_RESOLVE**(_packageURL_, _packagePath_, _exports_)
@ -755,19 +772,42 @@ _isMain_ is **true** when resolving the Node.js application entry point.
> 1. Set _packagePath_ to _"./"_ concatenated with _packagePath_. > 1. Set _packagePath_ to _"./"_ concatenated with _packagePath_.
> 1. If _packagePath_ is a key of _exports_, then > 1. If _packagePath_ is a key of _exports_, then
> 1. Let _target_ be the value of _exports[packagePath]_. > 1. Let _target_ be the value of _exports[packagePath]_.
> 1. If _target_ is not a String, continue the loop. > 1. Return **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_,
> 1. Return the URL resolution of the concatenation of _packageURL_ and > _""_).
> _target_.
> 1. Let _directoryKeys_ be the list of keys of _exports_ ending in > 1. Let _directoryKeys_ be the list of keys of _exports_ ending in
> _"/"_, sorted by length descending. > _"/"_, sorted by length descending.
> 1. For each key _directory_ in _directoryKeys_, do > 1. For each key _directory_ in _directoryKeys_, do
> 1. If _packagePath_ starts with _directory_, then > 1. If _packagePath_ starts with _directory_, then
> 1. Let _target_ be the value of _exports[directory]_. > 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 > 1. Let _subpath_ be the substring of _target_ starting at the index
> of the length of _directory_. > of the length of _directory_.
> 1. Return the URL resolution of the concatenation of _packageURL_, > 1. Return **PACKAGE_EXPORTS_TARGET_RESOLVE**(_packageURL_, _target_,
> _target_ and _subpath_. > _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. > 1. Throw a _Module Not Found_ error.
**ESM_FORMAT**(_url_, _isMain_) **ESM_FORMAT**(_url_, _isMain_)
@ -790,6 +830,7 @@ _isMain_ is **true** when resolving the Node.js application entry point.
**READ_PACKAGE_SCOPE**(_url_) **READ_PACKAGE_SCOPE**(_url_)
> 1. Let _scopeURL_ be _url_. > 1. Let _scopeURL_ be _url_.
> 1. While _scopeURL_ is not the file system root, > 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. Let _pjson_ be the result of **READ_PACKAGE_JSON**(_scopeURL_).
> 1. If _pjson_ is not **null**, then > 1. If _pjson_ is not **null**, then
> 1. Return _pjson_. > 1. Return _pjson_.

View File

@ -202,11 +202,12 @@ NODE_MODULES_PATHS(START)
5. return DIRS 5. return DIRS
``` ```
If `--experimental-exports` is enabled, If `--experimental-exports` is enabled, Node.js allows packages loaded via
node allows packages loaded via `LOAD_NODE_MODULES` to explicitly declare `LOAD_NODE_MODULES` to explicitly declare which file paths to expose and how
which filepaths to expose and how they should be interpreted. they should be interpreted. This expands on the control packages already had
This expands on the control packages already had using the `main` field. using the `main` field.
With this feature enabled, the `LOAD_NODE_MODULES` changes as follows:
With this feature enabled, the `LOAD_NODE_MODULES` changes are:
```txt ```txt
LOAD_NODE_MODULES(X, START) LOAD_NODE_MODULES(X, START)
@ -224,10 +225,10 @@ RESOLVE_BARE_SPECIFIER(DIR, X)
b. If "exports" is null or undefined, GOTO 3. b. If "exports" is null or undefined, GOTO 3.
c. Find the longest key in "exports" that the subpath starts with. c. Find the longest key in "exports" that the subpath starts with.
d. If no such key can be found, throw "not found". d. If no such key can be found, throw "not found".
e. If the key matches the subpath entirely, return DIR/name/${exports[key]}. e. let RESOLVED_URL =
f. If either the key or exports[key] do not end with a slash (`/`), PACKAGE_EXPORTS_TARGET_RESOLVE(pathToFileURL(DIR/name), exports[key],
throw "not found". subpath.slice(key.length)), as defined in the esm resolver.
g. Return DIR/name/${exports[key]}${subpath.slice(key.length)}. f. return fileURLToPath(RESOLVED_URL)
3. return DIR/X 3. return DIR/X
``` ```

View File

@ -24,6 +24,7 @@
const { const {
JSON, JSON,
Object, Object,
ObjectPrototype,
Reflect, Reflect,
SafeMap, SafeMap,
StringPrototype, StringPrototype,
@ -348,18 +349,18 @@ function resolveExports(nmPath, request, absoluteRequest) {
const basePath = path.resolve(nmPath, name); const basePath = path.resolve(nmPath, name);
const pkgExports = readExports(basePath); const pkgExports = readExports(basePath);
const mappingKey = `.${expansion}`;
if (pkgExports != null) { if (typeof pkgExports === 'object' && pkgExports !== null) {
const mappingKey = `.${expansion}`; if (ObjectPrototype.hasOwnProperty(pkgExports, mappingKey)) {
const mapping = pkgExports[mappingKey]; const mapping = pkgExports[mappingKey];
if (typeof mapping === 'string') { return resolveExportsTarget(pathToFileURL(basePath + '/'), mapping, '',
return fileURLToPath(new URL(mapping, `${pathToFileURL(basePath)}/`)); basePath, mappingKey);
} }
let dirMatch = ''; let dirMatch = '';
for (const [candidateKey, candidateValue] of Object.entries(pkgExports)) { for (const candidateKey of Object.keys(pkgExports)) {
if (candidateKey[candidateKey.length - 1] !== '/') continue; if (candidateKey[candidateKey.length - 1] !== '/') continue;
if (candidateValue[candidateValue.length - 1] !== '/') continue;
if (candidateKey.length > dirMatch.length && if (candidateKey.length > dirMatch.length &&
StringPrototype.startsWith(mappingKey, candidateKey)) { StringPrototype.startsWith(mappingKey, candidateKey)) {
dirMatch = candidateKey; dirMatch = candidateKey;
@ -367,15 +368,13 @@ function resolveExports(nmPath, request, absoluteRequest) {
} }
if (dirMatch !== '') { if (dirMatch !== '') {
const dirMapping = pkgExports[dirMatch]; const mapping = pkgExports[dirMatch];
const remainder = StringPrototype.slice(mappingKey, dirMatch.length); const subpath = StringPrototype.slice(mappingKey, dirMatch.length);
const expectedPrefix = return resolveExportsTarget(pathToFileURL(basePath + '/'), mapping,
new URL(dirMapping, `${pathToFileURL(basePath)}/`); subpath, basePath, mappingKey);
const resolved = new URL(remainder, expectedPrefix).href;
if (StringPrototype.startsWith(resolved, expectedPrefix.href)) {
return fileURLToPath(resolved);
}
} }
}
if (pkgExports != null) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const e = new Error(`Package exports for '${basePath}' do not define ` + const e = new Error(`Package exports for '${basePath}' do not define ` +
`a '${mappingKey}' subpath`); `a '${mappingKey}' subpath`);
@ -387,6 +386,43 @@ function resolveExports(nmPath, request, absoluteRequest) {
return path.resolve(nmPath, request); 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) { Module._findPath = function(request, paths, isMain) {
const absoluteRequest = path.isAbsolute(request); const absoluteRequest = path.isAbsolute(request);
if (absoluteRequest) { if (absoluteRequest) {

View File

@ -545,8 +545,8 @@ Maybe<const PackageConfig*> GetPackageConfig(Environment* env,
if (existing != env->package_json_cache.end()) { if (existing != env->package_json_cache.end()) {
const PackageConfig* pcfg = &existing->second; const PackageConfig* pcfg = &existing->second;
if (pcfg->is_valid == IsValid::No) { if (pcfg->is_valid == IsValid::No) {
std::string msg = "Invalid JSON in '" + path + std::string msg = "Invalid JSON in " + path +
"' imported from " + base.ToFilePath(); " imported from " + base.ToFilePath();
node::THROW_ERR_INVALID_PACKAGE_CONFIG(env, msg.c_str()); node::THROW_ERR_INVALID_PACKAGE_CONFIG(env, msg.c_str());
return Nothing<const PackageConfig*>(); return Nothing<const PackageConfig*>();
} }
@ -579,8 +579,8 @@ Maybe<const PackageConfig*> GetPackageConfig(Environment* env,
env->package_json_cache.emplace(path, env->package_json_cache.emplace(path,
PackageConfig { Exists::Yes, IsValid::No, HasMain::No, "", PackageConfig { Exists::Yes, IsValid::No, HasMain::No, "",
PackageType::None, Global<Value>() }); PackageType::None, Global<Value>() });
std::string msg = "Invalid JSON in '" + path + std::string msg = "Invalid JSON in " + path +
"' imported from " + base.ToFilePath(); " imported from " + base.ToFilePath();
node::THROW_ERR_INVALID_PACKAGE_CONFIG(env, msg.c_str()); node::THROW_ERR_INVALID_PACKAGE_CONFIG(env, msg.c_str());
return Nothing<const PackageConfig*>(); return Nothing<const PackageConfig*>();
} }
@ -633,6 +633,12 @@ Maybe<const PackageConfig*> GetPackageScopeConfig(Environment* env,
const URL& base) { const URL& base) {
URL pjson_url("./package.json", &resolved); URL pjson_url("./package.json", &resolved);
while (true) { 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<const PackageConfig*> pkg_cfg = Maybe<const PackageConfig*> pkg_cfg =
GetPackageConfig(env, pjson_url.ToFilePath(), base); GetPackageConfig(env, pjson_url.ToFilePath(), base);
if (pkg_cfg.IsNothing()) return pkg_cfg; if (pkg_cfg.IsNothing()) return pkg_cfg;
@ -643,14 +649,13 @@ Maybe<const PackageConfig*> GetPackageScopeConfig(Environment* env,
// Terminates at root where ../package.json equals ../../package.json // Terminates at root where ../package.json equals ../../package.json
// (can't just check "/package.json" for Windows support). // (can't just check "/package.json" for Windows support).
if (pjson_url.path() == last_pjson_url.path()) { 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<Value>() });
const PackageConfig* pcfg = &entry.first->second;
return Just(pcfg);
}
} }
auto entry = env->package_json_cache.emplace(pjson_url.ToFilePath(),
PackageConfig { Exists::No, IsValid::Yes, HasMain::No, "",
PackageType::None, Global<Value>() });
const PackageConfig* pcfg = &entry.first->second;
return Just(pcfg);
} }
/* /*
@ -750,16 +755,17 @@ Maybe<URL> FinalizeResolution(Environment* env,
if (!file.IsNothing()) { if (!file.IsNothing()) {
return file; return file;
} }
std::string msg = "Cannot find module '" + resolved.path() + std::string msg = "Cannot find module " + resolved.path() +
"' imported from " + base.ToFilePath(); " imported from " + base.ToFilePath();
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str()); node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
return Nothing<URL>(); return Nothing<URL>();
} }
const std::string& path = resolved.ToFilePath(); const std::string& path = resolved.ToFilePath();
if (CheckDescriptorAtPath(path) != FILE) { if (CheckDescriptorAtPath(path) != FILE) {
std::string msg = "Cannot find module '" + path + std::string msg = "Cannot find module " +
"' imported from " + base.ToFilePath(); (path.length() != 0 ? path : resolved.path()) +
" imported from " + base.ToFilePath();
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str()); node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
return Nothing<URL>(); return Nothing<URL>();
} }
@ -793,13 +799,94 @@ Maybe<URL> PackageMainResolve(Environment* env,
} }
} }
} }
std::string msg = "Cannot find main entry point for '" + std::string msg = "Cannot find main entry point for " +
URL(".", pjson_url).ToFilePath() + "' imported from " + URL(".", pjson_url).ToFilePath() + " imported from " +
base.ToFilePath(); base.ToFilePath();
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str()); node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
return Nothing<URL>(); return Nothing<URL>();
} }
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<Value> target,
const URL& pjson_url,
const URL& base) {
Local<String> 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<URL> 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<URL>();
}
if (subpath.length() > 0 && target.back() != '/') {
if (throw_invalid) {
ThrowExportsInvalid(env, match, target, pjson_url, base);
}
return Nothing<URL>();
}
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<URL>();
}
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<URL>();
}
return Just(subpath_resolved);
}
Maybe<URL> PackageExportsResolve(Environment* env, Maybe<URL> PackageExportsResolve(Environment* env,
const URL& pjson_url, const URL& pjson_url,
const std::string& pkg_subpath, const std::string& pkg_subpath,
@ -809,57 +896,126 @@ Maybe<URL> PackageExportsResolve(Environment* env,
Isolate* isolate = env->isolate(); Isolate* isolate = env->isolate();
Local<Context> context = env->context(); Local<Context> context = env->context();
Local<Value> exports = pcfg.exports.Get(isolate); Local<Value> exports = pcfg.exports.Get(isolate);
if (exports->IsObject()) { if (!exports->IsObject()) {
Local<Object> exports_obj = exports.As<Object>(); ThrowExportsNotFound(env, pkg_subpath, pjson_url, base);
Local<String> subpath = String::NewFromUtf8(isolate, return Nothing<URL>();
pkg_subpath.c_str(), v8::NewStringType::kNormal).ToLocalChecked(); }
Local<Object> exports_obj = exports.As<Object>();
Local<String> subpath = String::NewFromUtf8(isolate,
pkg_subpath.c_str(), v8::NewStringType::kNormal).ToLocalChecked();
auto target = exports_obj->Get(context, subpath).ToLocalChecked(); if (exports_obj->HasOwnProperty(context, subpath).FromJust()) {
Local<Value> target = exports_obj->Get(context, subpath).ToLocalChecked();
if (target->IsString()) {
Utf8Value target_utf8(isolate, target.As<v8::String>());
std::string target_str(*target_utf8, target_utf8.length());
Maybe<URL> resolved = ResolveExportsTarget(env, target_str, "",
pkg_subpath, pjson_url, base);
if (resolved.IsNothing()) {
ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base);
return Nothing<URL>();
}
return FinalizeResolution(env, resolved.FromJust(), base);
} else if (target->IsArray()) {
Local<Array> target_arr = target.As<Array>();
const uint32_t length = target_arr->Length();
if (length == 0) {
ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base);
return Nothing<URL>();
}
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<v8::String>());
std::string target(*target_utf8, target_utf8.length());
Maybe<URL> 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<URL>();
}
Utf8Value invalid_utf8(isolate, invalid.As<v8::String>());
std::string invalid_str(*invalid_utf8, invalid_utf8.length());
Maybe<URL> resolved = ResolveExportsTarget(env, invalid_str, "",
pkg_subpath, pjson_url, base);
CHECK(resolved.IsNothing());
return Nothing<URL>();
} else {
ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base);
return Nothing<URL>();
}
}
Local<String> best_match;
std::string best_match_str = "";
Local<Array> keys =
exports_obj->GetOwnPropertyNames(context).ToLocalChecked();
for (uint32_t i = 0; i < keys->Length(); ++i) {
Local<String> key = keys->Get(context, i).ToLocalChecked().As<String>();
Utf8Value key_utf8(isolate, key);
std::string key_str(*key_utf8, key_utf8.length());
if (key_str.back() != '/') continue;
if (pkg_subpath.substr(0, key_str.length()) == key_str &&
key_str.length() > best_match_str.length()) {
best_match = key;
best_match_str = key_str;
}
}
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()) { if (target->IsString()) {
Utf8Value target_utf8(isolate, target.As<v8::String>()); Utf8Value target_utf8(isolate, target.As<v8::String>());
std::string target(*target_utf8, target_utf8.length()); std::string target(*target_utf8, target_utf8.length());
if (target.substr(0, 2) == "./") { Maybe<URL> resolved = ResolveExportsTarget(env, target, subpath,
URL target_url(target, pjson_url); pkg_subpath, pjson_url, base);
return FinalizeResolution(env, target_url, base); if (resolved.IsNothing()) {
ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base);
return Nothing<URL>();
} }
} return FinalizeResolution(env, URL(subpath, resolved.FromJust()), base);
} else if (target->IsArray()) {
Local<String> best_match; Local<Array> target_arr = target.As<Array>();
std::string best_match_str = ""; const uint32_t length = target_arr->Length();
Local<Array> keys = if (length == 0) {
exports_obj->GetOwnPropertyNames(context).ToLocalChecked(); ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base);
for (uint32_t i = 0; i < keys->Length(); ++i) { return Nothing<URL>();
Local<String> key = keys->Get(context, i).ToLocalChecked().As<String>();
Utf8Value key_utf8(isolate, key);
std::string key_str(*key_utf8, key_utf8.length());
if (key_str.back() != '/') continue;
if (pkg_subpath.substr(0, key_str.length()) == key_str &&
key_str.length() > best_match_str.length()) {
best_match = key;
best_match_str = key_str;
} }
} for (uint32_t i = 0; i < length; i++) {
auto target_item = target_arr->Get(context, i).ToLocalChecked();
if (best_match_str.length() > 0) { if (target_item->IsString()) {
auto target = exports_obj->Get(context, best_match).ToLocalChecked(); Utf8Value target_utf8(isolate, target_item.As<v8::String>());
if (target->IsString()) { std::string target_str(*target_utf8, target_utf8.length());
Utf8Value target_utf8(isolate, target.As<v8::String>()); Maybe<URL> resolved = ResolveExportsTarget(env, target_str, subpath,
std::string target(*target_utf8, target_utf8.length()); pkg_subpath, pjson_url, base, false);
if (target.back() == '/' && target.substr(0, 2) == "./") { if (resolved.IsNothing()) continue;
std::string subpath = pkg_subpath.substr(best_match_str.length()); return FinalizeResolution(env, resolved.FromJust(), base);
URL url_prefix(target, pjson_url);
URL target_url(subpath, url_prefix);
if (target_url.path().find(url_prefix.path()) == 0) {
return FinalizeResolution(env, target_url, base);
}
} }
} }
auto invalid = target_arr->Get(context, length - 1).ToLocalChecked();
if (!invalid->IsString()) {
ThrowExportsInvalid(env, pkg_subpath, invalid, pjson_url, base);
return Nothing<URL>();
}
Utf8Value invalid_utf8(isolate, invalid.As<v8::String>());
std::string invalid_str(*invalid_utf8, invalid_utf8.length());
Maybe<URL> resolved = ResolveExportsTarget(env, invalid_str, subpath,
pkg_subpath, pjson_url, base);
CHECK(resolved.IsNothing());
return Nothing<URL>();
} else {
ThrowExportsInvalid(env, pkg_subpath, target, pjson_url, base);
return Nothing<URL>();
} }
} }
std::string msg = "Package exports for '" +
URL(".", pjson_url).ToFilePath() + "' do not define a '" + pkg_subpath + ThrowExportsNotFound(env, pkg_subpath, pjson_url, base);
"' subpath, imported from " + base.ToFilePath();
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
return Nothing<URL>(); return Nothing<URL>();
} }

View File

@ -17,6 +17,9 @@ import { requireFixture, importFixture } from '../fixtures/pkgexports.mjs';
['pkgexports/space', { default: 'encoded path' }], ['pkgexports/space', { default: 'encoded path' }],
// Verifying that normal packages still work with exports turned on. // Verifying that normal packages still work with exports turned on.
isRequire ? ['baz/index', { default: 'eye catcher' }] : [null], isRequire ? ['baz/index', { default: 'eye catcher' }] : [null],
// Fallbacks
['pkgexports/fallbackdir/asdf.js', { default: 'asdf' }],
['pkgexports/fallbackfile', { default: 'asdf' }],
]); ]);
for (const [validSpecifier, expected] of validSpecifiers) { for (const [validSpecifier, expected] of validSpecifiers) {
if (validSpecifier === null) continue; if (validSpecifier === null) continue;
@ -27,20 +30,56 @@ import { requireFixture, importFixture } from '../fixtures/pkgexports.mjs';
})); }));
} }
// There's no such export - so there's nothing to do. const undefinedExports = new Map([
loadFixture('pkgexports/missing').catch(mustCall((err) => { // There's no such export - so there's nothing to do.
strictEqual(err.code, (isRequire ? '' : 'ERR_') + 'MODULE_NOT_FOUND'); ['pkgexports/missing', './missing'],
assertStartsWith(err.message, 'Package exports'); // The file exists but isn't exported. The exports is a number which counts
assertIncludes(err.message, 'do not define a \'./missing\' subpath'); // as a non-null value without any properties, just like `{}`.
})); ['pkgexports-number/hidden.js', './hidden.js'],
]);
// The file exists but isn't exported. The exports is a number which counts const invalidExports = new Map([
// as a non-null value without any properties, just like `{}`. // Even though 'pkgexports/sub/asdf.js' works, alternate "path-like"
loadFixture('pkgexports-number/hidden.js').catch(mustCall((err) => { // variants do not to prevent confusion and accidental loopholes.
strictEqual(err.code, (isRequire ? '' : 'ERR_') + 'MODULE_NOT_FOUND'); ['pkgexports/sub/./../asdf.js', './sub/./../asdf.js'],
assertStartsWith(err.message, 'Package exports'); // This path steps back inside the package but goes through an exports
assertIncludes(err.message, 'do not define a \'./hidden.js\' subpath'); // target that escapes the package, so we still catch that as invalid
})); ['pkgexports/belowdir/pkgexports/asdf.js', './belowdir/pkgexports/asdf.js'],
// This target file steps below the package
['pkgexports/belowfile', './belowfile'],
// Directory mappings require a trailing / to work
['pkgexports/missingtrailer/x', './missingtrailer/x'],
// Invalid target handling
['pkgexports/null', './null'],
['pkgexports/invalid1', './invalid1'],
['pkgexports/invalid2', './invalid2'],
['pkgexports/invalid3', './invalid3'],
['pkgexports/invalid4', './invalid4'],
// Missing / invalid fallbacks
['pkgexports/nofallback1', './nofallback1'],
['pkgexports/nofallback2', './nofallback2'],
// Reaching into nested node_modules
['pkgexports/nodemodules', './nodemodules'],
]);
for (const [specifier, subpath] of undefinedExports) {
loadFixture(specifier).catch(mustCall((err) => {
strictEqual(err.code, (isRequire ? '' : 'ERR_') + 'MODULE_NOT_FOUND');
assertStartsWith(err.message, 'Package exports');
assertIncludes(err.message, `do not define a '${subpath}' subpath`);
}));
}
for (const [specifier, subpath] of invalidExports) {
loadFixture(specifier).catch(mustCall((err) => {
strictEqual(err.code, (isRequire ? '' : 'ERR_') + 'MODULE_NOT_FOUND');
assertStartsWith(err.message, (isRequire ? 'Package exports' :
'Cannot resolve'));
assertIncludes(err.message, isRequire ?
`do not define a valid '${subpath}' subpath` :
`matched for '${subpath}'`);
}));
}
// There's no main field so we won't find anything when importing the name. // There's no main field so we won't find anything when importing the name.
// The fact that "." is mapped is ignored, it's not a valid main config. // The fact that "." is mapped is ignored, it's not a valid main config.
@ -54,26 +93,19 @@ import { requireFixture, importFixture } from '../fixtures/pkgexports.mjs';
} }
})); }));
// Even though 'pkgexports/sub/asdf.js' works, alternate "path-like" variants
// do not to prevent confusion and accidental loopholes.
loadFixture('pkgexports/sub/./../asdf.js').catch(mustCall((err) => {
strictEqual(err.code, (isRequire ? '' : 'ERR_') + 'MODULE_NOT_FOUND');
assertStartsWith(err.message, 'Package exports');
assertIncludes(err.message,
'do not define a \'./sub/./../asdf.js\' subpath');
}));
// Covering out bases - not a file is still not a file after dir mapping. // Covering out bases - not a file is still not a file after dir mapping.
loadFixture('pkgexports/sub/not-a-file.js').catch(mustCall((err) => { loadFixture('pkgexports/sub/not-a-file.js').catch(mustCall((err) => {
if (isRequire) { strictEqual(err.code, (isRequire ? '' : 'ERR_') + 'MODULE_NOT_FOUND');
strictEqual(err.code, 'MODULE_NOT_FOUND'); // ESM returns a full file path
assertStartsWith(err.message, assertStartsWith(err.message, isRequire ?
'Cannot find module \'pkgexports/sub/not-a-file.js\''); 'Cannot find module \'pkgexports/sub/not-a-file.js\'' :
} else { 'Cannot find module');
strictEqual(err.code, 'ERR_MODULE_NOT_FOUND'); }));
// ESM currently returns a full file path
assertStartsWith(err.message, 'Cannot find module'); // 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');
})); }));
}); });

View File

@ -0,0 +1,10 @@
// Flags: --experimental-modules
import '../common/index.mjs';
import cjs from '../fixtures/baz.js';
import { message } from '../fixtures/es-modules/message.mjs';
import assert from 'assert';
// Assert we loaded esm dependency as ".js" in this mode
assert.strictEqual(message, 'A message');
// Assert we loaded CommonJS dependency
assert.strictEqual(cjs, 'perhaps I work');

View File

@ -1,3 +1,4 @@
import 'dep/dep.js';
const identifier = 'package-type-module'; const identifier = 'package-type-module';
console.log(identifier); console.log(identifier);
export default identifier; export default identifier;

View File

@ -0,0 +1,2 @@
// No package.json -> should still be CommonJS as it is in node_modules
module.exports = 42;

View File

@ -0,0 +1 @@
module.exports = 42;

View File

@ -3,6 +3,19 @@
".": "./asdf.js", ".": "./asdf.js",
"./space": "./sp%20ce.js", "./space": "./sp%20ce.js",
"./valid-cjs": "./asdf.js", "./valid-cjs": "./asdf.js",
"./sub/": "./" "./sub/": "./",
"./belowdir/": "../belowdir/",
"./belowfile": "../belowfile",
"./missingtrailer/": ".",
"./null": null,
"./invalid1": {},
"./invalid2": 1234,
"./invalid3": "",
"./invalid4": {},
"./fallbackdir/": [[], null, {}, "builtin:x/", "./fallbackfile", "./"],
"./fallbackfile": [[], null, {}, "builtin:x", "./asdf.js"],
"./nofallback1": [],
"./nofallback2": [null, {}, "builtin:x"],
"./nodemodules": "./node_modules/internalpkg/x.js"
} }
} }