Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 2262526

Browse filesBrowse files
hybristtargos
authored andcommitted
module: implement "exports" proposal for CommonJS
Refs: hybrist/proposal-pkg-exports#36 Refs: #28568 PR-URL: #28759 Reviewed-By: Guy Bedford <guybedford@gmail.com> Reviewed-By: Bradley Farias <bradley.meck@gmail.com>
1 parent 386d5d7 commit 2262526
Copy full SHA for 2262526

File tree

Expand file treeCollapse file tree

13 files changed

+200
-13
lines changed
Open diff view settings
Filter options
Expand file treeCollapse file tree

13 files changed

+200
-13
lines changed
Open diff view settings
Collapse file

‎doc/api/errors.md‎

Copy file name to clipboardExpand all lines: doc/api/errors.md
+7Lines changed: 7 additions & 0 deletions
  • Display the source diff
  • Display the rich diff
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

Collapse file

‎doc/api/modules.md‎

Copy file name to clipboardExpand all lines: doc/api/modules.md
+33Lines changed: 33 additions & 0 deletions
  • Display the source diff
  • Display the rich diff
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-->
Collapse file

‎lib/internal/errors.js‎

Copy file name to clipboardExpand all lines: lib/internal/errors.js
+2Lines 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);
Collapse file

‎lib/internal/modules/cjs/loader.js‎

Copy file name to clipboardExpand all lines: lib/internal/modules/cjs/loader.js
+96-9Lines 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);
Collapse file

‎src/module_wrap.cc‎

Copy file name to clipboardExpand all lines: src/module_wrap.cc
+1-1Lines 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

Collapse file

‎src/node_errors.h‎

Copy file name to clipboardExpand all lines: src/node_errors.h
+1Lines 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) \
Collapse file

‎src/node_file.cc‎

Copy file name to clipboardExpand all lines: src/node_file.cc
+3-1Lines 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 =
Collapse file

‎src/node_options.cc‎

Copy file name to clipboardExpand all lines: src/node_options.cc
+1Lines 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,
Collapse file

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

Copy file name to clipboardExpand all lines: test/es-module/test-esm-exports.mjs
+3-2Lines 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'));
Collapse file

‎test/fixtures/node_modules/pkgexports/package.json‎

Copy file name to clipboardExpand all lines: test/fixtures/node_modules/pkgexports/package.json
+2Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.