Skip to content

Commit ce45aae

Browse files
guybedfordGeoffreyBooth
authored andcommitted
module: reintroduce package exports dot main
This reintroduces the dot main in exports as discussed in the previous Node.js modules meeting. The implementation includes both CommonJS and ES module resolution with the associated documentation and resolver specification changes. In addition to the dot main, "exports" as a string or direct fallback array is supported as well. Co-Authored-By: Geoffrey Booth <[email protected]> PR-URL: #29494 Reviewed-By: Jan Krems <[email protected]> Reviewed-By: Myles Borins <[email protected]>
1 parent fa7de9b commit ce45aae

File tree

4 files changed

+137
-47
lines changed

4 files changed

+137
-47
lines changed

doc/api/esm.md

+36
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,33 @@ If a package has no exports, setting `"exports": false` can be used instead of
313313
`"exports": {}` to indicate the package does not intend for submodules to be
314314
exposed.
315315

316+
Exports can also be used to map the main entry point of a package:
317+
318+
<!-- eslint-skip -->
319+
```js
320+
// ./node_modules/es-module-package/package.json
321+
{
322+
"exports": {
323+
".": "./main.js"
324+
}
325+
}
326+
```
327+
328+
where the "." indicates loading the package without any subpath. Exports will
329+
always override any existing `"main"` value for both CommonJS and
330+
ES module packages.
331+
332+
For packages with only a main entry point, an `"exports"` value of just
333+
a string is also supported:
334+
335+
<!-- eslint-skip -->
336+
```js
337+
// ./node_modules/es-module-package/package.json
338+
{
339+
"exports": "./main.js"
340+
}
341+
```
342+
316343
Any invalid exports entries will be ignored. This includes exports not
317344
starting with `"./"` or a missing trailing `"/"` for directory exports.
318345

@@ -841,6 +868,15 @@ _isMain_ is **true** when resolving the Node.js application entry point.
841868
842869
> 1. If _pjson_ is **null**, then
843870
> 1. Throw a _Module Not Found_ error.
871+
> 1. If _pjson.exports_ is not **null** or **undefined**, then
872+
> 1. If _pjson.exports_ is a String or Array, then
873+
> 1. Return _PACKAGE_EXPORTS_TARGET_RESOLVE(packageURL, pjson.exports,
874+
> "")_.
875+
> 1. If _pjson.exports is an Object, then
876+
> 1. If _pjson.exports_ contains a _"."_ property, then
877+
> 1. Let _mainExport_ be the _"."_ property in _pjson.exports_.
878+
> 1. Return _PACKAGE_EXPORTS_TARGET_RESOLVE(packageURL, mainExport,
879+
> "")_.
844880
> 1. If _pjson.main_ is a String, then
845881
> 1. Let _resolvedMain_ be the URL resolution of _packageURL_, "/", and
846882
> _pjson.main_.

lib/internal/modules/cjs/loader.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -361,11 +361,11 @@ function findLongestRegisteredExtension(filename) {
361361
// This only applies to requests of a specific form:
362362
// 1. name/.*
363363
// 2. @scope/name/.*
364-
const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)$/;
364+
const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/;
365365
function resolveExports(nmPath, request, absoluteRequest) {
366366
// The implementation's behavior is meant to mirror resolution in ESM.
367367
if (experimentalExports && !absoluteRequest) {
368-
const [, name, expansion] =
368+
const [, name, expansion = ''] =
369369
StringPrototype.match(request, EXPORTS_PATTERN) || [];
370370
if (!name) {
371371
return path.resolve(nmPath, request);
@@ -398,6 +398,10 @@ function resolveExports(nmPath, request, absoluteRequest) {
398398
subpath, basePath, mappingKey);
399399
}
400400
}
401+
if (mappingKey === '.' && typeof pkgExports === 'string') {
402+
return resolveExportsTarget(pathToFileURL(basePath + '/'), pkgExports,
403+
'', basePath, mappingKey);
404+
}
401405
if (pkgExports != null) {
402406
// eslint-disable-next-line no-restricted-syntax
403407
const e = new Error(`Package exports for '${basePath}' do not define ` +

src/module_wrap.cc

+93-33
Original file line numberDiff line numberDiff line change
@@ -773,39 +773,6 @@ Maybe<URL> FinalizeResolution(Environment* env,
773773
return Just(resolved);
774774
}
775775

776-
Maybe<URL> PackageMainResolve(Environment* env,
777-
const URL& pjson_url,
778-
const PackageConfig& pcfg,
779-
const URL& base) {
780-
if (pcfg.exists == Exists::Yes) {
781-
if (pcfg.has_main == HasMain::Yes) {
782-
URL resolved(pcfg.main, pjson_url);
783-
const std::string& path = resolved.ToFilePath();
784-
if (CheckDescriptorAtPath(path) == FILE) {
785-
return Just(resolved);
786-
}
787-
}
788-
if (env->options()->es_module_specifier_resolution == "node") {
789-
if (pcfg.has_main == HasMain::Yes) {
790-
return FinalizeResolution(env, URL(pcfg.main, pjson_url), base);
791-
} else {
792-
return FinalizeResolution(env, URL("index", pjson_url), base);
793-
}
794-
}
795-
if (pcfg.type != PackageType::Module) {
796-
Maybe<URL> resolved = LegacyMainResolve(pjson_url, pcfg);
797-
if (!resolved.IsNothing()) {
798-
return resolved;
799-
}
800-
}
801-
}
802-
std::string msg = "Cannot find main entry point for " +
803-
URL(".", pjson_url).ToFilePath() + " imported from " +
804-
base.ToFilePath();
805-
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
806-
return Nothing<URL>();
807-
}
808-
809776
void ThrowExportsNotFound(Environment* env,
810777
const std::string& subpath,
811778
const URL& pjson_url,
@@ -887,6 +854,99 @@ Maybe<URL> ResolveExportsTarget(Environment* env,
887854
return Just(subpath_resolved);
888855
}
889856

857+
Maybe<URL> PackageMainResolve(Environment* env,
858+
const URL& pjson_url,
859+
const PackageConfig& pcfg,
860+
const URL& base) {
861+
if (pcfg.exists == Exists::Yes) {
862+
Isolate* isolate = env->isolate();
863+
Local<Context> context = env->context();
864+
if (!pcfg.exports.IsEmpty()) {
865+
Local<Value> exports = pcfg.exports.Get(isolate);
866+
if (exports->IsString() || exports->IsObject() || exports->IsArray()) {
867+
Local<Value> target;
868+
if (!exports->IsObject()) {
869+
target = exports;
870+
} else {
871+
Local<Object> exports_obj = exports.As<Object>();
872+
Local<String> dot_string = String::NewFromUtf8(env->isolate(), ".",
873+
v8::NewStringType::kNormal).ToLocalChecked();
874+
target =
875+
exports_obj->Get(env->context(), dot_string).ToLocalChecked();
876+
}
877+
if (target->IsString()) {
878+
Utf8Value target_utf8(isolate, target.As<v8::String>());
879+
std::string target(*target_utf8, target_utf8.length());
880+
Maybe<URL> resolved = ResolveExportsTarget(env, target, "", ".",
881+
pjson_url, base);
882+
if (resolved.IsNothing()) {
883+
ThrowExportsInvalid(env, ".", target, pjson_url, base);
884+
return Nothing<URL>();
885+
}
886+
return FinalizeResolution(env, resolved.FromJust(), base);
887+
} else if (target->IsArray()) {
888+
Local<Array> target_arr = target.As<Array>();
889+
const uint32_t length = target_arr->Length();
890+
if (length == 0) {
891+
ThrowExportsInvalid(env, ".", target, pjson_url, base);
892+
return Nothing<URL>();
893+
}
894+
for (uint32_t i = 0; i < length; i++) {
895+
auto target_item = target_arr->Get(context, i).ToLocalChecked();
896+
if (target_item->IsString()) {
897+
Utf8Value target_utf8(isolate, target_item.As<v8::String>());
898+
std::string target_str(*target_utf8, target_utf8.length());
899+
Maybe<URL> resolved = ResolveExportsTarget(env, target_str, "",
900+
".", pjson_url, base, false);
901+
if (resolved.IsNothing()) continue;
902+
return FinalizeResolution(env, resolved.FromJust(), base);
903+
}
904+
}
905+
auto invalid = target_arr->Get(context, length - 1).ToLocalChecked();
906+
if (!invalid->IsString()) {
907+
ThrowExportsInvalid(env, ".", invalid, pjson_url, base);
908+
return Nothing<URL>();
909+
}
910+
Utf8Value invalid_utf8(isolate, invalid.As<v8::String>());
911+
std::string invalid_str(*invalid_utf8, invalid_utf8.length());
912+
Maybe<URL> resolved = ResolveExportsTarget(env, invalid_str, "",
913+
".", pjson_url, base);
914+
CHECK(resolved.IsNothing());
915+
return Nothing<URL>();
916+
} else {
917+
ThrowExportsInvalid(env, ".", target, pjson_url, base);
918+
return Nothing<URL>();
919+
}
920+
}
921+
}
922+
if (pcfg.has_main == HasMain::Yes) {
923+
URL resolved(pcfg.main, pjson_url);
924+
const std::string& path = resolved.ToFilePath();
925+
if (CheckDescriptorAtPath(path) == FILE) {
926+
return Just(resolved);
927+
}
928+
}
929+
if (env->options()->es_module_specifier_resolution == "node") {
930+
if (pcfg.has_main == HasMain::Yes) {
931+
return FinalizeResolution(env, URL(pcfg.main, pjson_url), base);
932+
} else {
933+
return FinalizeResolution(env, URL("index", pjson_url), base);
934+
}
935+
}
936+
if (pcfg.type != PackageType::Module) {
937+
Maybe<URL> resolved = LegacyMainResolve(pjson_url, pcfg);
938+
if (!resolved.IsNothing()) {
939+
return resolved;
940+
}
941+
}
942+
}
943+
std::string msg = "Cannot find main entry point for " +
944+
URL(".", pjson_url).ToFilePath() + " imported from " +
945+
base.ToFilePath();
946+
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
947+
return Nothing<URL>();
948+
}
949+
890950
Maybe<URL> PackageExportsResolve(Environment* env,
891951
const URL& pjson_url,
892952
const std::string& pkg_subpath,

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

+2-12
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { requireFixture, importFixture } from '../fixtures/pkgexports.mjs';
2020
// Fallbacks
2121
['pkgexports/fallbackdir/asdf.js', { default: 'asdf' }],
2222
['pkgexports/fallbackfile', { default: 'asdf' }],
23+
// Dot main
24+
['pkgexports', { default: 'asdf' }],
2325
]);
2426
for (const [validSpecifier, expected] of validSpecifiers) {
2527
if (validSpecifier === null) continue;
@@ -81,18 +83,6 @@ import { requireFixture, importFixture } from '../fixtures/pkgexports.mjs';
8183
}));
8284
}
8385

84-
// There's no main field so we won't find anything when importing the name.
85-
// The fact that "." is mapped is ignored, it's not a valid main config.
86-
loadFixture('pkgexports').catch(mustCall((err) => {
87-
if (isRequire) {
88-
strictEqual(err.code, 'MODULE_NOT_FOUND');
89-
assertStartsWith(err.message, 'Cannot find module \'pkgexports\'');
90-
} else {
91-
strictEqual(err.code, 'ERR_MODULE_NOT_FOUND');
92-
assertStartsWith(err.message, 'Cannot find main entry point');
93-
}
94-
}));
95-
9686
// Covering out bases - not a file is still not a file after dir mapping.
9787
loadFixture('pkgexports/sub/not-a-file.js').catch(mustCall((err) => {
9888
strictEqual(err.code, (isRequire ? '' : 'ERR_') + 'MODULE_NOT_FOUND');

0 commit comments

Comments
 (0)