Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

module: package exports dot main support #29494

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,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:

<!-- eslint-skip -->
```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:

<!-- eslint-skip -->
```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.

Expand Down Expand Up @@ -781,6 +808,15 @@ _isMain_ is **true** when resolving the Node.js application entry point.
**PACKAGE_MAIN_RESOLVE**(_packageURL_, _pjson_)
> 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_.
Expand Down
8 changes: 6 additions & 2 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,11 +360,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);
Expand Down Expand Up @@ -397,6 +397,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 ` +
Expand Down
126 changes: 93 additions & 33 deletions src/module_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -773,39 +773,6 @@ Maybe<URL> FinalizeResolution(Environment* env,
return Just(resolved);
}

Maybe<URL> 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<URL> 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<URL>();
}

void ThrowExportsNotFound(Environment* env,
const std::string& subpath,
const URL& pjson_url,
Expand Down Expand Up @@ -887,6 +854,99 @@ Maybe<URL> ResolveExportsTarget(Environment* env,
return Just(subpath_resolved);
}

Maybe<URL> PackageMainResolve(Environment* env,
const URL& pjson_url,
const PackageConfig& pcfg,
const URL& base) {
if (pcfg.exists == Exists::Yes) {
Isolate* isolate = env->isolate();
Local<Context> context = env->context();
if (!pcfg.exports.IsEmpty()) {
guybedford marked this conversation as resolved.
Show resolved Hide resolved
Local<Value> exports = pcfg.exports.Get(isolate);
if (exports->IsString() || exports->IsObject() || exports->IsArray()) {
Local<Value> target;
if (!exports->IsObject()) {
target = exports;
} else {
Local<Object> exports_obj = exports.As<Object>();
Local<String> dot_string = String::NewFromUtf8(env->isolate(), ".",
v8::NewStringType::kNormal).ToLocalChecked();
target =
exports_obj->Get(env->context(), dot_string).ToLocalChecked();
}
if (target->IsString()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to reuse the existing logic for string/array in PackageExportsResolve?

Copy link
Contributor Author

@guybedford guybedford Sep 9, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be better, it's just a refactoring I haven't had the time for. Would you be ok with moving that to a follow-up?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me.

Utf8Value target_utf8(isolate, target.As<v8::String>());
std::string target(*target_utf8, target_utf8.length());
Maybe<URL> resolved = ResolveExportsTarget(env, target, "", ".",
pjson_url, base);
if (resolved.IsNothing()) {
ThrowExportsInvalid(env, ".", target, pjson_url, base);
return Nothing<URL>();
}
return FinalizeResolution(env, resolved.FromJust(), base);
} else if (target->IsArray()) {
Local<Array> target_arr = target.As<Array>();
const uint32_t length = target_arr->Length();
if (length == 0) {
ThrowExportsInvalid(env, ".", target, pjson_url, base);
return Nothing<URL>();
}
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<v8::String>());
std::string target_str(*target_utf8, target_utf8.length());
Maybe<URL> 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<URL>();
}
Utf8Value invalid_utf8(isolate, invalid.As<v8::String>());
std::string invalid_str(*invalid_utf8, invalid_utf8.length());
Maybe<URL> resolved = ResolveExportsTarget(env, invalid_str, "",
".", pjson_url, base);
CHECK(resolved.IsNothing());
return Nothing<URL>();
} else {
ThrowExportsInvalid(env, ".", target, pjson_url, base);
return Nothing<URL>();
}
}
}
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<URL> 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<URL>();
}

Maybe<URL> PackageExportsResolve(Environment* env,
const URL& pjson_url,
const std::string& pkg_subpath,
Expand Down
14 changes: 2 additions & 12 deletions test/es-module/test-esm-exports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down