Skip to content

Commit 817d5c8

Browse files
committed
module: implement "exports" proposal for CommonJS
Refs: hybrist/proposal-pkg-exports#36 Refs: nodejs#28568
1 parent 89e4b36 commit 817d5c8

File tree

11 files changed

+179
-11
lines changed

11 files changed

+179
-11
lines changed

doc/api/errors.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1585,6 +1585,13 @@ compiled with ICU support.
15851585

15861586
A given value is out of the accepted range.
15871587

1588+
<a id="ERR_PATH_NOT_EXPORTED"></a>
1589+
### ERR_PATH_NOT_EXPORTED
1590+
1591+
> Stability: 1 - Experimental
1592+
1593+
An attempt was made to load a protected path from a package using `exports`.
1594+
15881595
<a id="ERR_REQUIRE_ESM"></a>
15891596
### ERR_REQUIRE_ESM
15901597

doc/api/modules.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,35 @@ NODE_MODULES_PATHS(START)
202202
5. return DIRS
203203
```
204204

205+
If `--experimental-exports` is enabled,
206+
node allows packages loaded via `LOAD_NODE_MODULES` to explicitly declare
207+
which filepaths to expose and how they should be interpreted.
208+
This expands on the control packages already had using the `main` field.
209+
With this feature enabled, the `LOAD_NODE_MODULES` changes as follows:
210+
211+
```txt
212+
LOAD_NODE_MODULES(X, START)
213+
1. let DIRS = NODE_MODULES_PATHS(START)
214+
2. for each DIR in DIRS:
215+
a. let FILE_PATH = RESOLVE_BARE_SPECIFIER(DIR, X)
216+
a. LOAD_AS_FILE(FILE_PATH)
217+
b. LOAD_AS_DIRECTORY(FILE_PATH)
218+
219+
RESOLVE_BARE_SPECIFIER(DIR, X)
220+
1. Try to interpret X as a combination of name and subpath where the name
221+
may have a @scope/ prefix and the subpath begins with a slash (`/`).
222+
2. If X matches this pattern and DIR/name/package.json is a file:
223+
a. Parse DIR/name/package.json, and look for "exports" field.
224+
b. If "exports" is null or undefined, GOTO 3.
225+
c. Find the longest key in "exports" that the subpath starts with.
226+
d. If no such key can be found, throw "not exported".
227+
e. If the key matches the subpath entirely, return DIR/name/${exports[key]}.
228+
f. If either the key or exports[key] do not end with a slash (`/`),
229+
throw "not exported".
230+
g. Return DIR/name/${exports[key]}${subpath.slice(key.length)}.
231+
3. return DIR/X
232+
```
233+
205234
## Caching
206235

207236
<!--type=misc-->

lib/internal/errors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,8 @@ E('ERR_OUT_OF_RANGE',
10981098
msg += ` It must be ${range}. Received ${received}`;
10991099
return msg;
11001100
}, RangeError);
1101+
E('ERR_PATH_NOT_EXPORTED',
1102+
'Package exports for \'%s\' do not define a \'%s\' subpath', Error);
11011103
E('ERR_REQUIRE_ESM', 'Must use import to load ES Module: %s', Error);
11021104
E('ERR_SCRIPT_EXECUTION_INTERRUPTED',
11031105
'Script execution was interrupted by `SIGINT`', Error);

lib/internal/modules/cjs/loader.js

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,12 @@ const { compileFunction } = internalBinding('contextify');
5353
const {
5454
ERR_INVALID_ARG_VALUE,
5555
ERR_INVALID_OPT_VALUE,
56+
ERR_PATH_NOT_EXPORTED,
5657
ERR_REQUIRE_ESM
5758
} = require('internal/errors').codes;
5859
const { validateString } = require('internal/validators');
5960
const pendingDeprecation = getOptionValue('--pending-deprecation');
61+
const experimentalExports = getOptionValue('--experimental-exports');
6062

6163
module.exports = { wrapSafe, Module };
6264

@@ -182,12 +184,10 @@ Module._debug = deprecate(debug, 'Module._debug is deprecated.', 'DEP0077');
182184

183185
// Check if the directory is a package.json dir.
184186
const packageMainCache = Object.create(null);
187+
// Explicit exports from package.json files
188+
const packageExportsCache = new Map();
185189

186-
function readPackage(requestPath) {
187-
const entry = packageMainCache[requestPath];
188-
if (entry)
189-
return entry;
190-
190+
function readPackageRaw(requestPath) {
191191
const jsonPath = path.resolve(requestPath, 'package.json');
192192
const json = internalModuleReadJSON(path.toNamespacedPath(jsonPath));
193193

@@ -201,14 +201,44 @@ function readPackage(requestPath) {
201201
}
202202

203203
try {
204-
return packageMainCache[requestPath] = JSON.parse(json).main;
204+
const parsed = JSON.parse(json);
205+
packageMainCache[requestPath] = parsed.main;
206+
if (experimentalExports) {
207+
packageExportsCache.set(requestPath, parsed.exports);
208+
}
209+
return parsed;
205210
} catch (e) {
206211
e.path = jsonPath;
207212
e.message = 'Error parsing ' + jsonPath + ': ' + e.message;
208213
throw e;
209214
}
210215
}
211216

217+
function readPackage(requestPath) {
218+
const entry = packageMainCache[requestPath];
219+
if (entry)
220+
return entry;
221+
222+
const pkg = readPackageRaw(requestPath);
223+
if (pkg === false) return false;
224+
225+
return pkg.main;
226+
}
227+
228+
function readExports(requestPath) {
229+
if (packageExportsCache.has(requestPath)) {
230+
return packageExportsCache.get(requestPath);
231+
}
232+
233+
const pkg = readPackageRaw(requestPath);
234+
if (!pkg) {
235+
packageExportsCache.set(requestPath, null);
236+
return null;
237+
}
238+
239+
return pkg.exports;
240+
}
241+
212242
function tryPackage(requestPath, exts, isMain, originalPath) {
213243
const pkg = readPackage(requestPath);
214244

@@ -297,8 +327,58 @@ function findLongestRegisteredExtension(filename) {
297327
return '.js';
298328
}
299329

330+
// This only applies to requests of a specific form:
331+
// 1. name/.*
332+
// 2. @scope/name/.*
333+
const EXPORTS_PATTERN = /^((?:@[^./@\\][^/@\\]*\/)?[^@./\\][^/\\]*)(\/.*)$/;
334+
function resolveExports(nmPath, request, absoluteRequest) {
335+
// The implementation's behavior is meant to mirror resolution in ESM.
336+
if (experimentalExports && !absoluteRequest) {
337+
const [, name, expansion] =
338+
request.match(EXPORTS_PATTERN) || [];
339+
if (!name) {
340+
return path.resolve(nmPath, request);
341+
}
342+
343+
const basePath = path.resolve(nmPath, name);
344+
const pkgExports = readExports(basePath);
345+
346+
if (pkgExports != null) {
347+
const mappingKey = `.${expansion}`;
348+
const mapping = pkgExports[mappingKey];
349+
if (typeof mapping === 'string') {
350+
return path.resolve(basePath, mapping);
351+
}
352+
353+
let dirMatch = '';
354+
for (const [candidateKey, candidateValue] of Object.entries(pkgExports)) {
355+
if (candidateKey[candidateKey.length - 1] !== '/') continue;
356+
if (candidateValue[candidateValue.length - 1] !== '/') continue;
357+
if (candidateKey.length > dirMatch.length &&
358+
mappingKey.startsWith(candidateKey)) {
359+
dirMatch = candidateKey;
360+
}
361+
}
362+
363+
if (dirMatch !== '') {
364+
const dirMapping = pkgExports[dirMatch];
365+
const remainder = mappingKey.slice(dirMatch.length);
366+
const expectedPrefix = path.resolve(basePath, dirMapping);
367+
const resolved = path.resolve(expectedPrefix, remainder);
368+
if (resolved.startsWith(expectedPrefix)) {
369+
return resolved;
370+
}
371+
}
372+
throw new ERR_PATH_NOT_EXPORTED(basePath, mappingKey);
373+
}
374+
}
375+
376+
return path.resolve(nmPath, request);
377+
}
378+
300379
Module._findPath = function(request, paths, isMain) {
301-
if (path.isAbsolute(request)) {
380+
const absoluteRequest = path.isAbsolute(request);
381+
if (absoluteRequest) {
302382
paths = [''];
303383
} else if (!paths || paths.length === 0) {
304384
return false;
@@ -322,7 +402,7 @@ Module._findPath = function(request, paths, isMain) {
322402
// Don't search further if path doesn't exist
323403
const curPath = paths[i];
324404
if (curPath && stat(curPath) < 1) continue;
325-
var basePath = path.resolve(curPath, request);
405+
var basePath = resolveExports(curPath, request, absoluteRequest);
326406
var filename;
327407

328408
var rc = stat(basePath);

src/module_wrap.cc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,7 @@ Maybe<URL> PackageExportsResolve(Environment* env,
856856
std::string msg = "Package exports for '" +
857857
URL(".", pjson_url).ToFilePath() + "' do not define a '" + pkg_subpath +
858858
"' subpath, imported from " + base.ToFilePath();
859-
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
859+
node::THROW_ERR_PATH_NOT_EXPORTED(env, msg.c_str());
860860
return Nothing<URL>();
861861
}
862862

src/node_errors.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ void PrintErrorString(const char* format, ...);
5353
V(ERR_MISSING_PLATFORM_FOR_WORKER, Error) \
5454
V(ERR_MODULE_NOT_FOUND, Error) \
5555
V(ERR_OUT_OF_RANGE, RangeError) \
56+
V(ERR_PATH_NOT_EXPORTED, Error) \
5657
V(ERR_SCRIPT_EXECUTION_INTERRUPTED, Error) \
5758
V(ERR_SCRIPT_EXECUTION_TIMEOUT, Error) \
5859
V(ERR_STRING_TOO_LONG, Error) \

src/node_file.cc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -872,7 +872,9 @@ static void InternalModuleReadJSON(const FunctionCallbackInfo<Value>& args) {
872872
}
873873

874874
const size_t size = offset - start;
875-
if (size == 0 || size == SearchString(&chars[start], size, "\"main\"")) {
875+
if (size == 0 || (
876+
size == SearchString(&chars[start], size, "\"main\"") &&
877+
size == SearchString(&chars[start], size, "\"exports\""))) {
876878
return;
877879
} else {
878880
Local<String> chars_string =

src/node_options.cc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
319319
"experimental ES Module support and caching modules",
320320
&EnvironmentOptions::experimental_modules,
321321
kAllowedInEnvironment);
322+
Implies("--experimental-modules", "--experimental-exports");
322323
AddOption("--experimental-wasm-modules",
323324
"experimental ES Module support for webassembly modules",
324325
&EnvironmentOptions::experimental_wasm_modules,

test/es-module/test-esm-exports.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Flags: --experimental-modules --experimental-exports
1+
// Flags: --experimental-modules
22

33
import { mustCall } from '../common/index.mjs';
44
import { ok, strictEqual } from 'assert';

test/fixtures/node_modules/pkgexports/package.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)