summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/api/errors.md7
-rw-r--r--doc/api/modules.md33
-rw-r--r--lib/internal/errors.js2
-rw-r--r--lib/internal/modules/cjs/loader.js105
-rw-r--r--src/module_wrap.cc2
-rw-r--r--src/node_errors.h1
-rw-r--r--src/node_file.cc4
-rw-r--r--src/node_options.cc1
-rw-r--r--test/es-module/test-esm-exports.mjs5
-rw-r--r--test/fixtures/node_modules/pkgexports/package.json2
-rw-r--r--test/fixtures/node_modules/pkgexports/sp ce.js3
-rw-r--r--test/fixtures/pkgexports.mjs1
-rw-r--r--test/parallel/test-module-package-exports.js47
13 files changed, 200 insertions, 13 deletions
diff --git a/doc/api/errors.md b/doc/api/errors.md
index f638f0ba90..902f5f5c8e 100644
--- a/doc/api/errors.md
+++ b/doc/api/errors.md
@@ -1585,6 +1585,13 @@ compiled with ICU support.
A given value is out of the accepted range.
+<a id="ERR_PATH_NOT_EXPORTED"></a>
+### ERR_PATH_NOT_EXPORTED
+
+> Stability: 1 - Experimental
+
+An attempt was made to load a protected path from a package using `exports`.
+
<a id="ERR_REQUIRE_ESM"></a>
### ERR_REQUIRE_ESM
diff --git a/doc/api/modules.md b/doc/api/modules.md
index 73771f49af..abac7eaf91 100644
--- a/doc/api/modules.md
+++ b/doc/api/modules.md
@@ -202,6 +202,39 @@ 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:
+
+```txt
+LOAD_NODE_MODULES(X, START)
+1. let DIRS = NODE_MODULES_PATHS(START)
+2. for each DIR in DIRS:
+ a. let FILE_PATH = RESOLVE_BARE_SPECIFIER(DIR, X)
+ a. LOAD_AS_FILE(FILE_PATH)
+ b. LOAD_AS_DIRECTORY(FILE_PATH)
+
+RESOLVE_BARE_SPECIFIER(DIR, X)
+1. Try to interpret X as a combination of name and subpath where the name
+ may have a @scope/ prefix and the subpath begins with a slash (`/`).
+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 exported".
+ 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 exported".
+ g. Return DIR/name/${exports[key]}${subpath.slice(key.length)}.
+3. return DIR/X
+```
+
+`"exports"` is only honored when loading a package "name" as defined above. Any
+`"exports"` values within nested directories and packages must be declared by
+the `package.json` responsible for the "name".
+
## Caching
<!--type=misc-->
diff --git a/lib/internal/errors.js b/lib/internal/errors.js
index c36f34eff4..64e5a2773b 100644
--- a/lib/internal/errors.js
+++ b/lib/internal/errors.js
@@ -1098,6 +1098,8 @@ E('ERR_OUT_OF_RANGE',
msg += ` It must be ${range}. Received ${received}`;
return msg;
}, RangeError);
+E('ERR_PATH_NOT_EXPORTED',
+ 'Package exports for \'%s\' do not define a \'%s\' subpath', Error);
E('ERR_REQUIRE_ESM', 'Must use import to load ES Module: %s', Error);
E('ERR_SCRIPT_EXECUTION_INTERRUPTED',
'Script execution was interrupted by `SIGINT`', Error);
diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js
index 199405b0e2..c769ce535a 100644
--- a/lib/internal/modules/cjs/loader.js
+++ b/lib/internal/modules/cjs/loader.js
@@ -21,7 +21,13 @@
'use strict';
-const { JSON, Object, Reflect } = primordials;
+const {
+ JSON,
+ Object,
+ Reflect,
+ SafeMap,
+ StringPrototype,
+} = primordials;
const { NativeModule } = require('internal/bootstrap/loaders');
const { pathToFileURL, fileURLToPath, URL } = require('internal/url');
@@ -53,10 +59,12 @@ const { compileFunction } = internalBinding('contextify');
const {
ERR_INVALID_ARG_VALUE,
ERR_INVALID_OPT_VALUE,
+ ERR_PATH_NOT_EXPORTED,
ERR_REQUIRE_ESM
} = require('internal/errors').codes;
const { validateString } = require('internal/validators');
const pendingDeprecation = getOptionValue('--pending-deprecation');
+const experimentalExports = getOptionValue('--experimental-exports');
module.exports = { wrapSafe, Module };
@@ -182,12 +190,10 @@ Module._debug = deprecate(debug, 'Module._debug is deprecated.', 'DEP0077');
// Check if the directory is a package.json dir.
const packageMainCache = Object.create(null);
+// Explicit exports from package.json files
+const packageExportsCache = new SafeMap();
-function readPackage(requestPath) {
- const entry = packageMainCache[requestPath];
- if (entry)
- return entry;
-
+function readPackageRaw(requestPath) {
const jsonPath = path.resolve(requestPath, 'package.json');
const json = internalModuleReadJSON(path.toNamespacedPath(jsonPath));
@@ -201,7 +207,12 @@ function readPackage(requestPath) {
}
try {
- return packageMainCache[requestPath] = JSON.parse(json).main;
+ const parsed = JSON.parse(json);
+ packageMainCache[requestPath] = parsed.main;
+ if (experimentalExports) {
+ packageExportsCache.set(requestPath, parsed.exports);
+ }
+ return parsed;
} catch (e) {
e.path = jsonPath;
e.message = 'Error parsing ' + jsonPath + ': ' + e.message;
@@ -209,6 +220,31 @@ function readPackage(requestPath) {
}
}
+function readPackage(requestPath) {
+ const entry = packageMainCache[requestPath];
+ if (entry)
+ return entry;
+
+ const pkg = readPackageRaw(requestPath);
+ if (pkg === false) return false;
+
+ return pkg.main;
+}
+
+function readExports(requestPath) {
+ if (packageExportsCache.has(requestPath)) {
+ return packageExportsCache.get(requestPath);
+ }
+
+ const pkg = readPackageRaw(requestPath);
+ if (!pkg) {
+ packageExportsCache.set(requestPath, null);
+ return null;
+ }
+
+ return pkg.exports;
+}
+
function tryPackage(requestPath, exts, isMain, originalPath) {
const pkg = readPackage(requestPath);
@@ -297,8 +333,59 @@ function findLongestRegisteredExtension(filename) {
return '.js';
}
+// This only applies to requests of a specific form:
+// 1. name/.*
+// 2. @scope/name/.*
+const EXPORTS_PATTERN = /^((?:@[^./@\\][^/@\\]*\/)?[^@./\\][^/\\]*)(\/.*)$/;
+function resolveExports(nmPath, request, absoluteRequest) {
+ // The implementation's behavior is meant to mirror resolution in ESM.
+ if (experimentalExports && !absoluteRequest) {
+ const [, name, expansion] =
+ StringPrototype.match(request, EXPORTS_PATTERN) || [];
+ if (!name) {
+ return path.resolve(nmPath, request);
+ }
+
+ const basePath = path.resolve(nmPath, name);
+ const pkgExports = readExports(basePath);
+
+ if (pkgExports != null) {
+ const mappingKey = `.${expansion}`;
+ const mapping = pkgExports[mappingKey];
+ if (typeof mapping === 'string') {
+ return fileURLToPath(new URL(mapping, `${pathToFileURL(basePath)}/`));
+ }
+
+ let dirMatch = '';
+ for (const [candidateKey, candidateValue] of Object.entries(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);
+ }
+ }
+ throw new ERR_PATH_NOT_EXPORTED(basePath, mappingKey);
+ }
+ }
+
+ return path.resolve(nmPath, request);
+}
+
Module._findPath = function(request, paths, isMain) {
- if (path.isAbsolute(request)) {
+ const absoluteRequest = path.isAbsolute(request);
+ if (absoluteRequest) {
paths = [''];
} else if (!paths || paths.length === 0) {
return false;
@@ -322,7 +409,7 @@ Module._findPath = function(request, paths, isMain) {
// Don't search further if path doesn't exist
const curPath = paths[i];
if (curPath && stat(curPath) < 1) continue;
- var basePath = path.resolve(curPath, request);
+ var basePath = resolveExports(curPath, request, absoluteRequest);
var filename;
var rc = stat(basePath);
diff --git a/src/module_wrap.cc b/src/module_wrap.cc
index c13ab3b5ed..503ca8a858 100644
--- a/src/module_wrap.cc
+++ b/src/module_wrap.cc
@@ -856,7 +856,7 @@ Maybe<URL> PackageExportsResolve(Environment* env,
std::string msg = "Package exports for '" +
URL(".", pjson_url).ToFilePath() + "' do not define a '" + pkg_subpath +
"' subpath, imported from " + base.ToFilePath();
- node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
+ node::THROW_ERR_PATH_NOT_EXPORTED(env, msg.c_str());
return Nothing<URL>();
}
diff --git a/src/node_errors.h b/src/node_errors.h
index 939f93a489..c2587d73e6 100644
--- a/src/node_errors.h
+++ b/src/node_errors.h
@@ -53,6 +53,7 @@ void PrintErrorString(const char* format, ...);
V(ERR_MISSING_PLATFORM_FOR_WORKER, Error) \
V(ERR_MODULE_NOT_FOUND, Error) \
V(ERR_OUT_OF_RANGE, RangeError) \
+ V(ERR_PATH_NOT_EXPORTED, Error) \
V(ERR_SCRIPT_EXECUTION_INTERRUPTED, Error) \
V(ERR_SCRIPT_EXECUTION_TIMEOUT, Error) \
V(ERR_STRING_TOO_LONG, Error) \
diff --git a/src/node_file.cc b/src/node_file.cc
index 564c63bad7..e11aa90546 100644
--- a/src/node_file.cc
+++ b/src/node_file.cc
@@ -872,7 +872,9 @@ static void InternalModuleReadJSON(const FunctionCallbackInfo<Value>& args) {
}
const size_t size = offset - start;
- if (size == 0 || size == SearchString(&chars[start], size, "\"main\"")) {
+ if (size == 0 || (
+ size == SearchString(&chars[start], size, "\"main\"") &&
+ size == SearchString(&chars[start], size, "\"exports\""))) {
return;
} else {
Local<String> chars_string =
diff --git a/src/node_options.cc b/src/node_options.cc
index 9da1ed5fb8..eaa3e7d049 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -319,6 +319,7 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
"experimental ES Module support and caching modules",
&EnvironmentOptions::experimental_modules,
kAllowedInEnvironment);
+ Implies("--experimental-modules", "--experimental-exports");
AddOption("--experimental-wasm-modules",
"experimental ES Module support for webassembly modules",
&EnvironmentOptions::experimental_wasm_modules,
diff --git a/test/es-module/test-esm-exports.mjs b/test/es-module/test-esm-exports.mjs
index 8811502665..38cd81511f 100644
--- a/test/es-module/test-esm-exports.mjs
+++ b/test/es-module/test-esm-exports.mjs
@@ -1,9 +1,9 @@
-// Flags: --experimental-modules --experimental-exports
+// Flags: --experimental-modules
import { mustCall } from '../common/index.mjs';
import { ok, strictEqual } from 'assert';
-import { asdf, asdf2 } from '../fixtures/pkgexports.mjs';
+import { asdf, asdf2, space } from '../fixtures/pkgexports.mjs';
import {
loadMissing,
loadFromNumber,
@@ -12,6 +12,7 @@ import {
strictEqual(asdf, 'asdf');
strictEqual(asdf2, 'asdf');
+strictEqual(space, 'encoded path');
loadMissing().catch(mustCall((err) => {
ok(err.message.toString().startsWith('Package exports'));
diff --git a/test/fixtures/node_modules/pkgexports/package.json b/test/fixtures/node_modules/pkgexports/package.json
index 51c596ed86..b0c8867bb4 100644
--- a/test/fixtures/node_modules/pkgexports/package.json
+++ b/test/fixtures/node_modules/pkgexports/package.json
@@ -1,7 +1,9 @@
{
"exports": {
".": "./asdf.js",
+ "./space": "./sp%20ce.js",
"./asdf": "./asdf.js",
+ "./valid-cjs": "./asdf.js",
"./sub/": "./"
}
}
diff --git a/test/fixtures/node_modules/pkgexports/sp ce.js b/test/fixtures/node_modules/pkgexports/sp ce.js
new file mode 100644
index 0000000000..570237506e
--- /dev/null
+++ b/test/fixtures/node_modules/pkgexports/sp ce.js
@@ -0,0 +1,3 @@
+'use strict';
+
+module.exports = 'encoded path';
diff --git a/test/fixtures/pkgexports.mjs b/test/fixtures/pkgexports.mjs
index 4d82ba0560..8907ebcb0e 100644
--- a/test/fixtures/pkgexports.mjs
+++ b/test/fixtures/pkgexports.mjs
@@ -1,2 +1,3 @@
export { default as asdf } from 'pkgexports/asdf';
export { default as asdf2 } from 'pkgexports/sub/asdf.js';
+export { default as space } from 'pkgexports/space';
diff --git a/test/parallel/test-module-package-exports.js b/test/parallel/test-module-package-exports.js
new file mode 100644
index 0000000000..a1b9879448
--- /dev/null
+++ b/test/parallel/test-module-package-exports.js
@@ -0,0 +1,47 @@
+// Flags: --experimental-exports
+'use strict';
+
+require('../common');
+
+const assert = require('assert');
+const { createRequire } = require('module');
+const path = require('path');
+
+const fixtureRequire =
+ createRequire(path.resolve(__dirname, '../fixtures/imaginary.js'));
+
+assert.strictEqual(fixtureRequire('pkgexports/valid-cjs'), 'asdf');
+
+assert.strictEqual(fixtureRequire('baz/index'), 'eye catcher');
+
+assert.strictEqual(fixtureRequire('pkgexports/sub/asdf.js'), 'asdf');
+
+assert.strictEqual(fixtureRequire('pkgexports/space'), 'encoded path');
+
+assert.throws(
+ () => fixtureRequire('pkgexports/not-a-known-entry'),
+ (e) => {
+ assert.strictEqual(e.code, 'ERR_PATH_NOT_EXPORTED');
+ return true;
+ });
+
+assert.throws(
+ () => fixtureRequire('pkgexports-number/hidden.js'),
+ (e) => {
+ assert.strictEqual(e.code, 'ERR_PATH_NOT_EXPORTED');
+ return true;
+ });
+
+assert.throws(
+ () => fixtureRequire('pkgexports/sub/not-a-file.js'),
+ (e) => {
+ assert.strictEqual(e.code, 'MODULE_NOT_FOUND');
+ return true;
+ });
+
+assert.throws(
+ () => fixtureRequire('pkgexports/sub/./../asdf.js'),
+ (e) => {
+ assert.strictEqual(e.code, 'ERR_PATH_NOT_EXPORTED');
+ return true;
+ });