diff --git a/doc/api/esm.md b/doc/api/esm.md index b2ca0f2b2848d7..b811f2c4cf7d3b 100644 --- a/doc/api/esm.md +++ b/doc/api/esm.md @@ -313,6 +313,33 @@ If a package has no exports, setting `"exports": false` can be used instead of `"exports": {}` to indicate the package does not intend for submodules to be exposed. +Exports can also be used to map the main entry point of a package: + + +```js +// ./node_modules/es-module-package/package.json +{ + "exports": { + ".": "./main.js" + } +} +``` + +where the "." indicates loading the package without any subpath. Exports will +always override any existing `"main"` value for both CommonJS and +ES module packages. + +For packages with only a main entry point, an `"exports"` value of just +a string is also supported: + + +```js +// ./node_modules/es-module-package/package.json +{ + "exports": "./main.js" +} +``` + Any invalid exports entries will be ignored. This includes exports not starting with `"./"` or a missing trailing `"/"` for directory exports. @@ -841,6 +868,15 @@ _isMain_ is **true** when resolving the Node.js application entry point. > 1. If _pjson_ is **null**, then > 1. Throw a _Module Not Found_ error. +> 1. If _pjson.exports_ is not **null** or **undefined**, then +> 1. If _pjson.exports_ is a String or Array, then +> 1. Return _PACKAGE_EXPORTS_TARGET_RESOLVE(packageURL, pjson.exports, +> "")_. +> 1. If _pjson.exports is an Object, then +> 1. If _pjson.exports_ contains a _"."_ property, then +> 1. Let _mainExport_ be the _"."_ property in _pjson.exports_. +> 1. Return _PACKAGE_EXPORTS_TARGET_RESOLVE(packageURL, mainExport, +> "")_. > 1. If _pjson.main_ is a String, then > 1. Let _resolvedMain_ be the URL resolution of _packageURL_, "/", and > _pjson.main_. diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 7c9b2ecb59b143..bfc4eb6514c327 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -361,11 +361,11 @@ function findLongestRegisteredExtension(filename) { // This only applies to requests of a specific form: // 1. name/.* // 2. @scope/name/.* -const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)$/; +const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/; function resolveExports(nmPath, request, absoluteRequest) { // The implementation's behavior is meant to mirror resolution in ESM. if (experimentalExports && !absoluteRequest) { - const [, name, expansion] = + const [, name, expansion = ''] = StringPrototype.match(request, EXPORTS_PATTERN) || []; if (!name) { return path.resolve(nmPath, request); @@ -398,6 +398,10 @@ function resolveExports(nmPath, request, absoluteRequest) { subpath, basePath, mappingKey); } } + if (mappingKey === '.' && typeof pkgExports === 'string') { + return resolveExportsTarget(pathToFileURL(basePath + '/'), pkgExports, + '', basePath, mappingKey); + } if (pkgExports != null) { // eslint-disable-next-line no-restricted-syntax const e = new Error(`Package exports for '${basePath}' do not define ` + diff --git a/src/module_wrap.cc b/src/module_wrap.cc index e27c644442888b..aeb44675f6127d 100644 --- a/src/module_wrap.cc +++ b/src/module_wrap.cc @@ -773,39 +773,6 @@ Maybe FinalizeResolution(Environment* env, return Just(resolved); } -Maybe PackageMainResolve(Environment* env, - const URL& pjson_url, - const PackageConfig& pcfg, - const URL& base) { - if (pcfg.exists == Exists::Yes) { - if (pcfg.has_main == HasMain::Yes) { - URL resolved(pcfg.main, pjson_url); - const std::string& path = resolved.ToFilePath(); - if (CheckDescriptorAtPath(path) == FILE) { - return Just(resolved); - } - } - if (env->options()->es_module_specifier_resolution == "node") { - if (pcfg.has_main == HasMain::Yes) { - return FinalizeResolution(env, URL(pcfg.main, pjson_url), base); - } else { - return FinalizeResolution(env, URL("index", pjson_url), base); - } - } - if (pcfg.type != PackageType::Module) { - Maybe resolved = LegacyMainResolve(pjson_url, pcfg); - if (!resolved.IsNothing()) { - return resolved; - } - } - } - std::string msg = "Cannot find main entry point for " + - URL(".", pjson_url).ToFilePath() + " imported from " + - base.ToFilePath(); - node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str()); - return Nothing(); -} - void ThrowExportsNotFound(Environment* env, const std::string& subpath, const URL& pjson_url, @@ -887,6 +854,99 @@ Maybe ResolveExportsTarget(Environment* env, return Just(subpath_resolved); } +Maybe PackageMainResolve(Environment* env, + const URL& pjson_url, + const PackageConfig& pcfg, + const URL& base) { + if (pcfg.exists == Exists::Yes) { + Isolate* isolate = env->isolate(); + Local context = env->context(); + if (!pcfg.exports.IsEmpty()) { + Local exports = pcfg.exports.Get(isolate); + if (exports->IsString() || exports->IsObject() || exports->IsArray()) { + Local target; + if (!exports->IsObject()) { + target = exports; + } else { + Local exports_obj = exports.As(); + Local dot_string = String::NewFromUtf8(env->isolate(), ".", + v8::NewStringType::kNormal).ToLocalChecked(); + target = + exports_obj->Get(env->context(), dot_string).ToLocalChecked(); + } + if (target->IsString()) { + Utf8Value target_utf8(isolate, target.As()); + std::string target(*target_utf8, target_utf8.length()); + Maybe resolved = ResolveExportsTarget(env, target, "", ".", + pjson_url, base); + if (resolved.IsNothing()) { + ThrowExportsInvalid(env, ".", target, pjson_url, base); + return Nothing(); + } + return FinalizeResolution(env, resolved.FromJust(), base); + } else if (target->IsArray()) { + Local target_arr = target.As(); + const uint32_t length = target_arr->Length(); + if (length == 0) { + ThrowExportsInvalid(env, ".", target, pjson_url, base); + return Nothing(); + } + for (uint32_t i = 0; i < length; i++) { + auto target_item = target_arr->Get(context, i).ToLocalChecked(); + if (target_item->IsString()) { + Utf8Value target_utf8(isolate, target_item.As()); + std::string target_str(*target_utf8, target_utf8.length()); + Maybe resolved = ResolveExportsTarget(env, target_str, "", + ".", pjson_url, base, false); + if (resolved.IsNothing()) continue; + return FinalizeResolution(env, resolved.FromJust(), base); + } + } + auto invalid = target_arr->Get(context, length - 1).ToLocalChecked(); + if (!invalid->IsString()) { + ThrowExportsInvalid(env, ".", invalid, pjson_url, base); + return Nothing(); + } + Utf8Value invalid_utf8(isolate, invalid.As()); + std::string invalid_str(*invalid_utf8, invalid_utf8.length()); + Maybe resolved = ResolveExportsTarget(env, invalid_str, "", + ".", pjson_url, base); + CHECK(resolved.IsNothing()); + return Nothing(); + } else { + ThrowExportsInvalid(env, ".", target, pjson_url, base); + return Nothing(); + } + } + } + if (pcfg.has_main == HasMain::Yes) { + URL resolved(pcfg.main, pjson_url); + const std::string& path = resolved.ToFilePath(); + if (CheckDescriptorAtPath(path) == FILE) { + return Just(resolved); + } + } + if (env->options()->es_module_specifier_resolution == "node") { + if (pcfg.has_main == HasMain::Yes) { + return FinalizeResolution(env, URL(pcfg.main, pjson_url), base); + } else { + return FinalizeResolution(env, URL("index", pjson_url), base); + } + } + if (pcfg.type != PackageType::Module) { + Maybe resolved = LegacyMainResolve(pjson_url, pcfg); + if (!resolved.IsNothing()) { + return resolved; + } + } + } + std::string msg = "Cannot find main entry point for " + + URL(".", pjson_url).ToFilePath() + " imported from " + + base.ToFilePath(); + node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str()); + return Nothing(); +} + Maybe PackageExportsResolve(Environment* env, const URL& pjson_url, const std::string& pkg_subpath, diff --git a/test/es-module/test-esm-exports.mjs b/test/es-module/test-esm-exports.mjs index 4f772521588898..5059a2d4189cee 100644 --- a/test/es-module/test-esm-exports.mjs +++ b/test/es-module/test-esm-exports.mjs @@ -20,6 +20,8 @@ import { requireFixture, importFixture } from '../fixtures/pkgexports.mjs'; // Fallbacks ['pkgexports/fallbackdir/asdf.js', { default: 'asdf' }], ['pkgexports/fallbackfile', { default: 'asdf' }], + // Dot main + ['pkgexports', { default: 'asdf' }], ]); for (const [validSpecifier, expected] of validSpecifiers) { if (validSpecifier === null) continue; @@ -81,18 +83,6 @@ import { requireFixture, importFixture } from '../fixtures/pkgexports.mjs'; })); } - // There's no main field so we won't find anything when importing the name. - // The fact that "." is mapped is ignored, it's not a valid main config. - loadFixture('pkgexports').catch(mustCall((err) => { - if (isRequire) { - strictEqual(err.code, 'MODULE_NOT_FOUND'); - assertStartsWith(err.message, 'Cannot find module \'pkgexports\''); - } else { - strictEqual(err.code, 'ERR_MODULE_NOT_FOUND'); - assertStartsWith(err.message, 'Cannot find main entry point'); - } - })); - // Covering out bases - not a file is still not a file after dir mapping. loadFixture('pkgexports/sub/not-a-file.js').catch(mustCall((err) => { strictEqual(err.code, (isRequire ? '' : 'ERR_') + 'MODULE_NOT_FOUND');