Skip to content

Commit 7268579

Browse files
jkremsgntem
authored andcommitted
module: implement "exports" proposal for CommonJS
Refs: jkrems/proposal-pkg-exports#36 Refs: nodejs#28568 PR-URL: nodejs#28759 Reviewed-By: Guy Bedford <[email protected]> Reviewed-By: Bradley Farias <[email protected]>
1 parent 3537dc5 commit 7268579

File tree

13 files changed

+200
-13
lines changed

13 files changed

+200
-13
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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,39 @@ 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+
234+
`"exports"` is only honored when loading a package "name" as defined above. Any
235+
`"exports"` values within nested directories and packages must be declared by
236+
the `package.json` responsible for the "name".
237+
205238
## Caching
206239

207240
<!--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: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@
2121

2222
'use strict';
2323

24-
const { JSON, Object, Reflect } = primordials;
24+
const {
25+
JSON,
26+
Object,
27+
Reflect,
28+
SafeMap,
29+
StringPrototype,
30+
} = primordials;
2531

2632
const { NativeModule } = require('internal/bootstrap/loaders');
2733
const { pathToFileURL, fileURLToPath, URL } = require('internal/url');
@@ -53,10 +59,12 @@ const { compileFunction } = internalBinding('contextify');
5359
const {
5460
ERR_INVALID_ARG_VALUE,
5561
ERR_INVALID_OPT_VALUE,
62+
ERR_PATH_NOT_EXPORTED,
5663
ERR_REQUIRE_ESM
5764
} = require('internal/errors').codes;
5865
const { validateString } = require('internal/validators');
5966
const pendingDeprecation = getOptionValue('--pending-deprecation');
67+
const experimentalExports = getOptionValue('--experimental-exports');
6068

6169
module.exports = { wrapSafe, Module };
6270

@@ -193,12 +201,10 @@ Module._debug = deprecate(debug, 'Module._debug is deprecated.', 'DEP0077');
193201

194202
// Check if the directory is a package.json dir.
195203
const packageMainCache = Object.create(null);
204+
// Explicit exports from package.json files
205+
const packageExportsCache = new SafeMap();
196206

197-
function readPackage(requestPath) {
198-
const entry = packageMainCache[requestPath];
199-
if (entry)
200-
return entry;
201-
207+
function readPackageRaw(requestPath) {
202208
const jsonPath = path.resolve(requestPath, 'package.json');
203209
const json = internalModuleReadJSON(path.toNamespacedPath(jsonPath));
204210

@@ -212,14 +218,44 @@ function readPackage(requestPath) {
212218
}
213219

214220
try {
215-
return packageMainCache[requestPath] = JSON.parse(json).main;
221+
const parsed = JSON.parse(json);
222+
packageMainCache[requestPath] = parsed.main;
223+
if (experimentalExports) {
224+
packageExportsCache.set(requestPath, parsed.exports);
225+
}
226+
return parsed;
216227
} catch (e) {
217228
e.path = jsonPath;
218229
e.message = 'Error parsing ' + jsonPath + ': ' + e.message;
219230
throw e;
220231
}
221232
}
222233

234+
function readPackage(requestPath) {
235+
const entry = packageMainCache[requestPath];
236+
if (entry)
237+
return entry;
238+
239+
const pkg = readPackageRaw(requestPath);
240+
if (pkg === false) return false;
241+
242+
return pkg.main;
243+
}
244+
245+
function readExports(requestPath) {
246+
if (packageExportsCache.has(requestPath)) {
247+
return packageExportsCache.get(requestPath);
248+
}
249+
250+
const pkg = readPackageRaw(requestPath);
251+
if (!pkg) {
252+
packageExportsCache.set(requestPath, null);
253+
return null;
254+
}
255+
256+
return pkg.exports;
257+
}
258+
223259
function tryPackage(requestPath, exts, isMain, originalPath) {
224260
const pkg = readPackage(requestPath);
225261

@@ -308,8 +344,59 @@ function findLongestRegisteredExtension(filename) {
308344
return '.js';
309345
}
310346

347+
// This only applies to requests of a specific form:
348+
// 1. name/.*
349+
// 2. @scope/name/.*
350+
const EXPORTS_PATTERN = /^((?:@[^./@\\][^/@\\]*\/)?[^@./\\][^/\\]*)(\/.*)$/;
351+
function resolveExports(nmPath, request, absoluteRequest) {
352+
// The implementation's behavior is meant to mirror resolution in ESM.
353+
if (experimentalExports && !absoluteRequest) {
354+
const [, name, expansion] =
355+
StringPrototype.match(request, EXPORTS_PATTERN) || [];
356+
if (!name) {
357+
return path.resolve(nmPath, request);
358+
}
359+
360+
const basePath = path.resolve(nmPath, name);
361+
const pkgExports = readExports(basePath);
362+
363+
if (pkgExports != null) {
364+
const mappingKey = `.${expansion}`;
365+
const mapping = pkgExports[mappingKey];
366+
if (typeof mapping === 'string') {
367+
return fileURLToPath(new URL(mapping, `${pathToFileURL(basePath)}/`));
368+
}
369+
370+
let dirMatch = '';
371+
for (const [candidateKey, candidateValue] of Object.entries(pkgExports)) {
372+
if (candidateKey[candidateKey.length - 1] !== '/') continue;
373+
if (candidateValue[candidateValue.length - 1] !== '/') continue;
374+
if (candidateKey.length > dirMatch.length &&
375+
StringPrototype.startsWith(mappingKey, candidateKey)) {
376+
dirMatch = candidateKey;
377+
}
378+
}
379+
380+
if (dirMatch !== '') {
381+
const dirMapping = pkgExports[dirMatch];
382+
const remainder = StringPrototype.slice(mappingKey, dirMatch.length);
383+
const expectedPrefix =
384+
new URL(dirMapping, `${pathToFileURL(basePath)}/`);
385+
const resolved = new URL(remainder, expectedPrefix).href;
386+
if (StringPrototype.startsWith(resolved, expectedPrefix.href)) {
387+
return fileURLToPath(resolved);
388+
}
389+
}
390+
throw new ERR_PATH_NOT_EXPORTED(basePath, mappingKey);
391+
}
392+
}
393+
394+
return path.resolve(nmPath, request);
395+
}
396+
311397
Module._findPath = function(request, paths, isMain) {
312-
if (path.isAbsolute(request)) {
398+
const absoluteRequest = path.isAbsolute(request);
399+
if (absoluteRequest) {
313400
paths = [''];
314401
} else if (!paths || paths.length === 0) {
315402
return false;
@@ -333,7 +420,7 @@ Module._findPath = function(request, paths, isMain) {
333420
// Don't search further if path doesn't exist
334421
const curPath = paths[i];
335422
if (curPath && stat(curPath) < 1) continue;
336-
var basePath = path.resolve(curPath, request);
423+
var basePath = resolveExports(curPath, request, absoluteRequest);
337424
var filename;
338425

339426
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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
// Flags: --experimental-modules --experimental-exports
1+
// Flags: --experimental-modules
22

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

6-
import { asdf, asdf2 } from '../fixtures/pkgexports.mjs';
6+
import { asdf, asdf2, space } from '../fixtures/pkgexports.mjs';
77
import {
88
loadMissing,
99
loadFromNumber,
@@ -12,6 +12,7 @@ import {
1212

1313
strictEqual(asdf, 'asdf');
1414
strictEqual(asdf2, 'asdf');
15+
strictEqual(space, 'encoded path');
1516

1617
loadMissing().catch(mustCall((err) => {
1718
ok(err.message.toString().startsWith('Package exports'));

test/fixtures/node_modules/pkgexports/package.json

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

test/fixtures/node_modules/pkgexports/sp ce.js

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

test/fixtures/pkgexports.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { default as asdf } from 'pkgexports/asdf';
22
export { default as asdf2 } from 'pkgexports/sub/asdf.js';
3+
export { default as space } from 'pkgexports/space';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Flags: --experimental-exports
2+
'use strict';
3+
4+
require('../common');
5+
6+
const assert = require('assert');
7+
const { createRequire } = require('module');
8+
const path = require('path');
9+
10+
const fixtureRequire =
11+
createRequire(path.resolve(__dirname, '../fixtures/imaginary.js'));
12+
13+
assert.strictEqual(fixtureRequire('pkgexports/valid-cjs'), 'asdf');
14+
15+
assert.strictEqual(fixtureRequire('baz/index'), 'eye catcher');
16+
17+
assert.strictEqual(fixtureRequire('pkgexports/sub/asdf.js'), 'asdf');
18+
19+
assert.strictEqual(fixtureRequire('pkgexports/space'), 'encoded path');
20+
21+
assert.throws(
22+
() => fixtureRequire('pkgexports/not-a-known-entry'),
23+
(e) => {
24+
assert.strictEqual(e.code, 'ERR_PATH_NOT_EXPORTED');
25+
return true;
26+
});
27+
28+
assert.throws(
29+
() => fixtureRequire('pkgexports-number/hidden.js'),
30+
(e) => {
31+
assert.strictEqual(e.code, 'ERR_PATH_NOT_EXPORTED');
32+
return true;
33+
});
34+
35+
assert.throws(
36+
() => fixtureRequire('pkgexports/sub/not-a-file.js'),
37+
(e) => {
38+
assert.strictEqual(e.code, 'MODULE_NOT_FOUND');
39+
return true;
40+
});
41+
42+
assert.throws(
43+
() => fixtureRequire('pkgexports/sub/./../asdf.js'),
44+
(e) => {
45+
assert.strictEqual(e.code, 'ERR_PATH_NOT_EXPORTED');
46+
return true;
47+
});

0 commit comments

Comments
 (0)