Skip to content

Commit 2262526

Browse 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

13 files changed

Lines changed: 200 additions & 13 deletions

File tree

‎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+
<aid="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
<aid="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
returnmsg;
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
constpendingDeprecation=getOptionValue('--pending-deprecation');
68+
constexperimentalExports=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
constpackageMainCache=Object.create(null);
194+
// Explicit exports from package.json files
195+
constpackageExportsCache=newSafeMap();
186196

187-
functionreadPackage(requestPath){
188-
constentry=packageMainCache[requestPath];
189-
if(entry)
190-
returnentry;
191-
197+
functionreadPackageRaw(requestPath){
192198
constjsonPath=path.resolve(requestPath,'package.json');
193199
constjson=internalModuleReadJSON(path.toNamespacedPath(jsonPath));
194200

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

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

224+
functionreadPackage(requestPath){
225+
constentry=packageMainCache[requestPath];
226+
if(entry)
227+
returnentry;
228+
229+
constpkg=readPackageRaw(requestPath);
230+
if(pkg===false)returnfalse;
231+
232+
returnpkg.main;
233+
}
234+
235+
functionreadExports(requestPath){
236+
if(packageExportsCache.has(requestPath)){
237+
returnpackageExportsCache.get(requestPath);
238+
}
239+
240+
constpkg=readPackageRaw(requestPath);
241+
if(!pkg){
242+
packageExportsCache.set(requestPath,null);
243+
returnnull;
244+
}
245+
246+
returnpkg.exports;
247+
}
248+
213249
functiontryPackage(requestPath,exts,isMain,originalPath){
214250
constpkg=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+
constEXPORTS_PATTERN=/^((?:@[^./@\\][^/@\\]*\/)?[^@./\\][^/\\]*)(\/.*)$/;
341+
functionresolveExports(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+
returnpath.resolve(nmPath,request);
348+
}
349+
350+
constbasePath=path.resolve(nmPath,name);
351+
constpkgExports=readExports(basePath);
352+
353+
if(pkgExports!=null){
354+
constmappingKey=`.${expansion}`;
355+
constmapping=pkgExports[mappingKey];
356+
if(typeofmapping==='string'){
357+
returnfileURLToPath(newURL(mapping,`${pathToFileURL(basePath)}/`));
358+
}
359+
360+
letdirMatch='';
361+
for(const[candidateKey,candidateValue]ofObject.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+
constdirMapping=pkgExports[dirMatch];
372+
constremainder=StringPrototype.slice(mappingKey,dirMatch.length);
373+
constexpectedPrefix=
374+
newURL(dirMapping,`${pathToFileURL(basePath)}/`);
375+
constresolved=newURL(remainder,expectedPrefix).href;
376+
if(StringPrototype.startsWith(resolved,expectedPrefix.href)){
377+
returnfileURLToPath(resolved);
378+
}
379+
}
380+
thrownewERR_PATH_NOT_EXPORTED(basePath,mappingKey);
381+
}
382+
}
383+
384+
returnpath.resolve(nmPath,request);
385+
}
386+
301387
Module._findPath=function(request,paths,isMain){
302-
if(path.isAbsolute(request)){
388+
constabsoluteRequest=path.isAbsolute(request);
389+
if(absoluteRequest){
303390
paths=[''];
304391
}elseif(!paths||paths.length===0){
305392
returnfalse;
@@ -323,7 +410,7 @@ Module._findPath = function(request, paths, isMain) {
323410
// Don't search further if path doesn't exist
324411
constcurPath=paths[i];
325412
if(curPath&&stat(curPath)<1)continue;
326-
varbasePath=path.resolve(curPath,request);
413+
varbasePath=resolveExports(curPath,request,absoluteRequest);
327414
varfilename;
328415

329416
varrc=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
constsize_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)