Skip to content

Commit 2262526

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

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
@@ -1583,6 +1583,13 @@ compiled with ICU support.
15831583

15841584
A given value is out of the accepted range.
15851585

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

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
@@ -1093,6 +1093,8 @@ E('ERR_OUT_OF_RANGE',
10931093
msg += ` It must be ${range}. Received ${received}`;
10941094
return msg;
10951095
}, RangeError);
1096+
E('ERR_PATH_NOT_EXPORTED',
1097+
'Package exports for \'%s\' do not define a \'%s\' subpath', Error);
10961098
E('ERR_REQUIRE_ESM', 'Must use import to load ES Module: %s', Error);
10971099
E('ERR_SCRIPT_EXECUTION_INTERRUPTED',
10981100
'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');
@@ -54,10 +60,12 @@ const { compileFunction } = internalBinding('contextify');
5460
const {
5561
ERR_INVALID_ARG_VALUE,
5662
ERR_INVALID_OPT_VALUE,
63+
ERR_PATH_NOT_EXPORTED,
5764
ERR_REQUIRE_ESM
5865
} = require('internal/errors').codes;
5966
const { validateString } = require('internal/validators');
6067
const pendingDeprecation = getOptionValue('--pending-deprecation');
68+
const experimentalExports = getOptionValue('--experimental-exports');
6169

6270
module.exports = Module;
6371

@@ -183,12 +191,10 @@ Module._debug = deprecate(debug, 'Module._debug is deprecated.', 'DEP0077');
183191

184192
// Check if the directory is a package.json dir.
185193
const packageMainCache = Object.create(null);
194+
// Explicit exports from package.json files
195+
const packageExportsCache = new SafeMap();
186196

187-
function readPackage(requestPath) {
188-
const entry = packageMainCache[requestPath];
189-
if (entry)
190-
return entry;
191-
197+
function readPackageRaw(requestPath) {
192198
const jsonPath = path.resolve(requestPath, 'package.json');
193199
const json = internalModuleReadJSON(path.toNamespacedPath(jsonPath));
194200

@@ -202,14 +208,44 @@ function readPackage(requestPath) {
202208
}
203209

204210
try {
205-
return packageMainCache[requestPath] = JSON.parse(json).main;
211+
const parsed = JSON.parse(json);
212+
packageMainCache[requestPath] = parsed.main;
213+
if (experimentalExports) {
214+
packageExportsCache.set(requestPath, parsed.exports);
215+
}
216+
return parsed;
206217
} catch (e) {
207218
e.path = jsonPath;
208219
e.message = 'Error parsing ' + jsonPath + ': ' + e.message;
209220
throw e;
210221
}
211222
}
212223

224+
function readPackage(requestPath) {
225+
const entry = packageMainCache[requestPath];
226+
if (entry)
227+
return entry;
228+
229+
const pkg = readPackageRaw(requestPath);
230+
if (pkg === false) return false;
231+
232+
return pkg.main;
233+
}
234+
235+
function readExports(requestPath) {
236+
if (packageExportsCache.has(requestPath)) {
237+
return packageExportsCache.get(requestPath);
238+
}
239+
240+
const pkg = readPackageRaw(requestPath);
241+
if (!pkg) {
242+
packageExportsCache.set(requestPath, null);
243+
return null;
244+
}
245+
246+
return pkg.exports;
247+
}
248+
213249
function tryPackage(requestPath, exts, isMain, originalPath) {
214250
const pkg = readPackage(requestPath);
215251

@@ -298,8 +334,59 @@ function findLongestRegisteredExtension(filename) {
298334
return '.js';
299335
}
300336

337+
// This only applies to requests of a specific form:
338+
// 1. name/.*
339+
// 2. @scope/name/.*
340+
const EXPORTS_PATTERN = /^((?:@[^./@\\][^/@\\]*\/)?[^@./\\][^/\\]*)(\/.*)$/;
341+
function resolveExports(nmPath, request, absoluteRequest) {
342+
// The implementation's behavior is meant to mirror resolution in ESM.
343+
if (experimentalExports && !absoluteRequest) {
344+
const [, name, expansion] =
345+
StringPrototype.match(request, EXPORTS_PATTERN) || [];
346+
if (!name) {
347+
return path.resolve(nmPath, request);
348+
}
349+
350+
const basePath = path.resolve(nmPath, name);
351+
const pkgExports = readExports(basePath);
352+
353+
if (pkgExports != null) {
354+
const mappingKey = `.${expansion}`;
355+
const mapping = pkgExports[mappingKey];
356+
if (typeof mapping === 'string') {
357+
return fileURLToPath(new URL(mapping, `${pathToFileURL(basePath)}/`));
358+
}
359+
360+
let dirMatch = '';
361+
for (const [candidateKey, candidateValue] of Object.entries(pkgExports)) {
362+
if (candidateKey[candidateKey.length - 1] !== '/') continue;
363+
if (candidateValue[candidateValue.length - 1] !== '/') continue;
364+
if (candidateKey.length > dirMatch.length &&
365+
StringPrototype.startsWith(mappingKey, candidateKey)) {
366+
dirMatch = candidateKey;
367+
}
368+
}
369+
370+
if (dirMatch !== '') {
371+
const dirMapping = pkgExports[dirMatch];
372+
const remainder = StringPrototype.slice(mappingKey, dirMatch.length);
373+
const expectedPrefix =
374+
new URL(dirMapping, `${pathToFileURL(basePath)}/`);
375+
const resolved = new URL(remainder, expectedPrefix).href;
376+
if (StringPrototype.startsWith(resolved, expectedPrefix.href)) {
377+
return fileURLToPath(resolved);
378+
}
379+
}
380+
throw new ERR_PATH_NOT_EXPORTED(basePath, mappingKey);
381+
}
382+
}
383+
384+
return path.resolve(nmPath, request);
385+
}
386+
301387
Module._findPath = function(request, paths, isMain) {
302-
if (path.isAbsolute(request)) {
388+
const absoluteRequest = path.isAbsolute(request);
389+
if (absoluteRequest) {
303390
paths = [''];
304391
} else if (!paths || paths.length === 0) {
305392
return false;
@@ -323,7 +410,7 @@ Module._findPath = function(request, paths, isMain) {
323410
// Don't search further if path doesn't exist
324411
const curPath = paths[i];
325412
if (curPath && stat(curPath) < 1) continue;
326-
var basePath = path.resolve(curPath, request);
413+
var basePath = resolveExports(curPath, request, absoluteRequest);
327414
var filename;
328415

329416
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.

0 commit comments

Comments
 (0)