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:
Jan Krems 2019-07-18 21:52:55 -07:00
parent 4fc7cd9bc1
commit dcb6929183
13 changed files with 200 additions and 13 deletions

View File

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

View File

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

View File

@ -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);

View File

@ -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);

View File

@ -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>();
} }

View File

@ -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) \

View File

@ -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 =

View File

@ -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,

View File

@ -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'));

View File

@ -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
View File

@ -0,0 +1,3 @@
'use strict';
module.exports = 'encoded path';

View File

@ -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';

View 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;
});