Skip to content

Commit

Permalink
module: pkg exports validations and fallbacks
Browse files Browse the repository at this point in the history
PR-URL: #28949
Reviewed-By: Bradley Farias <bradley.meck@gmail.com>
Reviewed-By: Jan Krems <jan.krems@gmail.com>
  • Loading branch information
guybedford authored and targos committed Aug 19, 2019
1 parent b2936cf commit cb16229
Show file tree
Hide file tree
Showing 10 changed files with 418 additions and 125 deletions.
69 changes: 55 additions & 14 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<!-- eslint-skip -->
```js
Expand All @@ -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:

<!-- 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

Expand Down Expand Up @@ -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.
Expand All @@ -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_.
Expand Down Expand Up @@ -737,7 +756,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_.
Expand All @@ -746,28 +765,49 @@ _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_)
> 1. If _exports_ is an Object, 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. 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_)
Expand All @@ -790,6 +830,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_.
Expand Down
19 changes: 10 additions & 9 deletions doc/api/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
```

Expand Down
66 changes: 51 additions & 15 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
const {
JSON,
Object,
ObjectPrototype,
Reflect,
SafeMap,
StringPrototype,
Expand Down Expand Up @@ -372,34 +373,32 @@ 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;
}
}

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`);
Expand All @@ -411,6 +410,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) {
Expand Down
Loading

0 comments on commit cb16229

Please sign in to comment.