Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

module: implement "exports" proposal for CommonJS #28759

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions doc/api/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-->
Expand Down
2 changes: 2 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
105 changes: 96 additions & 9 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 };

Expand Down Expand Up @@ -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));

Expand All @@ -201,14 +207,44 @@ 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);
bmeck marked this conversation as resolved.
Show resolved Hide resolved
}
return parsed;
} catch (e) {
e.path = jsonPath;
e.message = 'Error parsing ' + jsonPath + ': ' + e.message;
throw e;
}
}

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

Expand Down Expand Up @@ -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 = /^((?:@[^./@\\][^/@\\]*\/)?[^@./\\][^/\\]*)(\/.*)$/;
bmeck marked this conversation as resolved.
Show resolved Hide resolved
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)}/`));
}
bmeck marked this conversation as resolved.
Show resolved Hide resolved

let dirMatch = '';
for (const [candidateKey, candidateValue] of Object.entries(pkgExports)) {
bmeck marked this conversation as resolved.
Show resolved Hide resolved
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;
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/module_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
}

Expand Down
1 change: 1 addition & 0 deletions src/node_errors.h
Original file line number Diff line number Diff line change
Expand Up @@ -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) \
Expand Down
4 changes: 3 additions & 1 deletion src/node_file.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
1 change: 1 addition & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions test/es-module/test-esm-exports.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'));
Expand Down
2 changes: 2 additions & 0 deletions test/fixtures/node_modules/pkgexports/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions test/fixtures/node_modules/pkgexports/sp ce.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/fixtures/pkgexports.mjs
Original file line number Diff line number Diff line change
@@ -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';
47 changes: 47 additions & 0 deletions test/parallel/test-module-package-exports.js
Original file line number Diff line number Diff line change
@@ -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');
jkrems marked this conversation as resolved.
Show resolved Hide resolved

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;
});
jkrems marked this conversation as resolved.
Show resolved Hide resolved