module: implement "exports" proposal for CommonJS
Refs: https://github.com/jkrems/proposal-pkg-exports/issues/36 Refs: https://github.com/nodejs/node/pull/28568 PR-URL: https://github.com/nodejs/node/pull/28759 Reviewed-By: Guy Bedford <guybedford@gmail.com> Reviewed-By: Bradley Farias <bradley.meck@gmail.com>
This commit is contained in:
parent
4fc7cd9bc1
commit
dcb6929183
@ -1585,6 +1585,13 @@ compiled with ICU support.
|
|||||||
|
|
||||||
A given value is out of the accepted range.
|
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>
|
<a id="ERR_REQUIRE_ESM"></a>
|
||||||
### ERR_REQUIRE_ESM
|
### ERR_REQUIRE_ESM
|
||||||
|
|
||||||
|
@ -202,6 +202,39 @@ NODE_MODULES_PATHS(START)
|
|||||||
5. return DIRS
|
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
|
## Caching
|
||||||
|
|
||||||
<!--type=misc-->
|
<!--type=misc-->
|
||||||
|
@ -1098,6 +1098,8 @@ E('ERR_OUT_OF_RANGE',
|
|||||||
msg += ` It must be ${range}. Received ${received}`;
|
msg += ` It must be ${range}. Received ${received}`;
|
||||||
return msg;
|
return msg;
|
||||||
}, RangeError);
|
}, 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_REQUIRE_ESM', 'Must use import to load ES Module: %s', Error);
|
||||||
E('ERR_SCRIPT_EXECUTION_INTERRUPTED',
|
E('ERR_SCRIPT_EXECUTION_INTERRUPTED',
|
||||||
'Script execution was interrupted by `SIGINT`', Error);
|
'Script execution was interrupted by `SIGINT`', Error);
|
||||||
|
@ -21,7 +21,13 @@
|
|||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { JSON, Object, Reflect } = primordials;
|
const {
|
||||||
|
JSON,
|
||||||
|
Object,
|
||||||
|
Reflect,
|
||||||
|
SafeMap,
|
||||||
|
StringPrototype,
|
||||||
|
} = primordials;
|
||||||
|
|
||||||
const { NativeModule } = require('internal/bootstrap/loaders');
|
const { NativeModule } = require('internal/bootstrap/loaders');
|
||||||
const { pathToFileURL, fileURLToPath, URL } = require('internal/url');
|
const { pathToFileURL, fileURLToPath, URL } = require('internal/url');
|
||||||
@ -53,10 +59,12 @@ const { compileFunction } = internalBinding('contextify');
|
|||||||
const {
|
const {
|
||||||
ERR_INVALID_ARG_VALUE,
|
ERR_INVALID_ARG_VALUE,
|
||||||
ERR_INVALID_OPT_VALUE,
|
ERR_INVALID_OPT_VALUE,
|
||||||
|
ERR_PATH_NOT_EXPORTED,
|
||||||
ERR_REQUIRE_ESM
|
ERR_REQUIRE_ESM
|
||||||
} = require('internal/errors').codes;
|
} = require('internal/errors').codes;
|
||||||
const { validateString } = require('internal/validators');
|
const { validateString } = require('internal/validators');
|
||||||
const pendingDeprecation = getOptionValue('--pending-deprecation');
|
const pendingDeprecation = getOptionValue('--pending-deprecation');
|
||||||
|
const experimentalExports = getOptionValue('--experimental-exports');
|
||||||
|
|
||||||
module.exports = { wrapSafe, Module };
|
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.
|
// Check if the directory is a package.json dir.
|
||||||
const packageMainCache = Object.create(null);
|
const packageMainCache = Object.create(null);
|
||||||
|
// Explicit exports from package.json files
|
||||||
|
const packageExportsCache = new SafeMap();
|
||||||
|
|
||||||
function readPackage(requestPath) {
|
function readPackageRaw(requestPath) {
|
||||||
const entry = packageMainCache[requestPath];
|
|
||||||
if (entry)
|
|
||||||
return entry;
|
|
||||||
|
|
||||||
const jsonPath = path.resolve(requestPath, 'package.json');
|
const jsonPath = path.resolve(requestPath, 'package.json');
|
||||||
const json = internalModuleReadJSON(path.toNamespacedPath(jsonPath));
|
const json = internalModuleReadJSON(path.toNamespacedPath(jsonPath));
|
||||||
|
|
||||||
@ -201,7 +207,12 @@ function readPackage(requestPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
e.path = jsonPath;
|
e.path = jsonPath;
|
||||||
e.message = 'Error parsing ' + jsonPath + ': ' + e.message;
|
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) {
|
function tryPackage(requestPath, exts, isMain, originalPath) {
|
||||||
const pkg = readPackage(requestPath);
|
const pkg = readPackage(requestPath);
|
||||||
|
|
||||||
@ -297,8 +333,59 @@ function findLongestRegisteredExtension(filename) {
|
|||||||
return '.js';
|
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) {
|
Module._findPath = function(request, paths, isMain) {
|
||||||
if (path.isAbsolute(request)) {
|
const absoluteRequest = path.isAbsolute(request);
|
||||||
|
if (absoluteRequest) {
|
||||||
paths = [''];
|
paths = [''];
|
||||||
} else if (!paths || paths.length === 0) {
|
} else if (!paths || paths.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
@ -322,7 +409,7 @@ Module._findPath = function(request, paths, isMain) {
|
|||||||
// Don't search further if path doesn't exist
|
// Don't search further if path doesn't exist
|
||||||
const curPath = paths[i];
|
const curPath = paths[i];
|
||||||
if (curPath && stat(curPath) < 1) continue;
|
if (curPath && stat(curPath) < 1) continue;
|
||||||
var basePath = path.resolve(curPath, request);
|
var basePath = resolveExports(curPath, request, absoluteRequest);
|
||||||
var filename;
|
var filename;
|
||||||
|
|
||||||
var rc = stat(basePath);
|
var rc = stat(basePath);
|
||||||
|
@ -856,7 +856,7 @@ Maybe<URL> PackageExportsResolve(Environment* env,
|
|||||||
std::string msg = "Package exports for '" +
|
std::string msg = "Package exports for '" +
|
||||||
URL(".", pjson_url).ToFilePath() + "' do not define a '" + pkg_subpath +
|
URL(".", pjson_url).ToFilePath() + "' do not define a '" + pkg_subpath +
|
||||||
"' subpath, imported from " + base.ToFilePath();
|
"' 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>();
|
return Nothing<URL>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,6 +53,7 @@ void PrintErrorString(const char* format, ...);
|
|||||||
V(ERR_MISSING_PLATFORM_FOR_WORKER, Error) \
|
V(ERR_MISSING_PLATFORM_FOR_WORKER, Error) \
|
||||||
V(ERR_MODULE_NOT_FOUND, Error) \
|
V(ERR_MODULE_NOT_FOUND, Error) \
|
||||||
V(ERR_OUT_OF_RANGE, RangeError) \
|
V(ERR_OUT_OF_RANGE, RangeError) \
|
||||||
|
V(ERR_PATH_NOT_EXPORTED, Error) \
|
||||||
V(ERR_SCRIPT_EXECUTION_INTERRUPTED, Error) \
|
V(ERR_SCRIPT_EXECUTION_INTERRUPTED, Error) \
|
||||||
V(ERR_SCRIPT_EXECUTION_TIMEOUT, Error) \
|
V(ERR_SCRIPT_EXECUTION_TIMEOUT, Error) \
|
||||||
V(ERR_STRING_TOO_LONG, Error) \
|
V(ERR_STRING_TOO_LONG, Error) \
|
||||||
|
@ -872,7 +872,9 @@ static void InternalModuleReadJSON(const FunctionCallbackInfo<Value>& args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const size_t size = offset - start;
|
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;
|
return;
|
||||||
} else {
|
} else {
|
||||||
Local<String> chars_string =
|
Local<String> chars_string =
|
||||||
|
@ -319,6 +319,7 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
|
|||||||
"experimental ES Module support and caching modules",
|
"experimental ES Module support and caching modules",
|
||||||
&EnvironmentOptions::experimental_modules,
|
&EnvironmentOptions::experimental_modules,
|
||||||
kAllowedInEnvironment);
|
kAllowedInEnvironment);
|
||||||
|
Implies("--experimental-modules", "--experimental-exports");
|
||||||
AddOption("--experimental-wasm-modules",
|
AddOption("--experimental-wasm-modules",
|
||||||
"experimental ES Module support for webassembly modules",
|
"experimental ES Module support for webassembly modules",
|
||||||
&EnvironmentOptions::experimental_wasm_modules,
|
&EnvironmentOptions::experimental_wasm_modules,
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
// Flags: --experimental-modules --experimental-exports
|
// Flags: --experimental-modules
|
||||||
|
|
||||||
import { mustCall } from '../common/index.mjs';
|
import { mustCall } from '../common/index.mjs';
|
||||||
import { ok, strictEqual } from 'assert';
|
import { ok, strictEqual } from 'assert';
|
||||||
|
|
||||||
import { asdf, asdf2 } from '../fixtures/pkgexports.mjs';
|
import { asdf, asdf2, space } from '../fixtures/pkgexports.mjs';
|
||||||
import {
|
import {
|
||||||
loadMissing,
|
loadMissing,
|
||||||
loadFromNumber,
|
loadFromNumber,
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
|
|
||||||
strictEqual(asdf, 'asdf');
|
strictEqual(asdf, 'asdf');
|
||||||
strictEqual(asdf2, 'asdf');
|
strictEqual(asdf2, 'asdf');
|
||||||
|
strictEqual(space, 'encoded path');
|
||||||
|
|
||||||
loadMissing().catch(mustCall((err) => {
|
loadMissing().catch(mustCall((err) => {
|
||||||
ok(err.message.toString().startsWith('Package exports'));
|
ok(err.message.toString().startsWith('Package exports'));
|
||||||
|
2
test/fixtures/node_modules/pkgexports/package.json
generated
vendored
2
test/fixtures/node_modules/pkgexports/package.json
generated
vendored
@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./asdf.js",
|
".": "./asdf.js",
|
||||||
|
"./space": "./sp%20ce.js",
|
||||||
"./asdf": "./asdf.js",
|
"./asdf": "./asdf.js",
|
||||||
|
"./valid-cjs": "./asdf.js",
|
||||||
"./sub/": "./"
|
"./sub/": "./"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
3
test/fixtures/node_modules/pkgexports/sp ce.js
generated
vendored
Normal file
3
test/fixtures/node_modules/pkgexports/sp ce.js
generated
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = 'encoded path';
|
1
test/fixtures/pkgexports.mjs
vendored
1
test/fixtures/pkgexports.mjs
vendored
@ -1,2 +1,3 @@
|
|||||||
export { default as asdf } from 'pkgexports/asdf';
|
export { default as asdf } from 'pkgexports/asdf';
|
||||||
export { default as asdf2 } from 'pkgexports/sub/asdf.js';
|
export { default as asdf2 } from 'pkgexports/sub/asdf.js';
|
||||||
|
export { default as space } from 'pkgexports/space';
|
||||||
|
47
test/parallel/test-module-package-exports.js
Normal file
47
test/parallel/test-module-package-exports.js
Normal file
@ -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;
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user