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

Implement "exports" proposal #28568

Closed
wants to merge 1 commit 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
8 changes: 8 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ the ability to import a directory that has an index file.

Please see [customizing esm specifier resolution][] for example usage.

### `--experimental-exports`
<!-- YAML
added: REPLACEME
-->

Enable experimental resolution using the `exports` field in `package.json`.

### `--experimental-modules`
<!-- YAML
added: v8.5.0
Expand Down Expand Up @@ -935,6 +942,7 @@ Node.js options that are allowed are:
<!-- node-options-node start -->
- `--enable-fips`
- `--es-module-specifier-resolution`
- `--experimental-exports`
- `--experimental-modules`
- `--experimental-policy`
- `--experimental-repl-await`
Expand Down
55 changes: 55 additions & 0 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,61 @@ a package would be accessible like `require('pkg')` and `import
module entry point and legacy users could be informed of the CommonJS entry
point path, e.g. `require('pkg/commonjs')`.

## Package Exports

By default, all subpaths from a package can be imported (`import 'pkg/x.js'`).
Custom subpath aliasing and encapsulation can be provided through the
`"exports"` field.

<!-- eslint-skip -->
```js
// ./node_modules/es-module-package/package.json
{
"exports": {
"./submodule": "./src/submodule.js"
}
}
```

```js
import submodule from 'es-module-package/submodule';
// Loads ./node_modules/es-module-package/src/submodule.js
```

In addition to defining an alias, subpaths not defined by `"exports"` will
throw when an attempt is made to import them:

```js
import submodule from 'es-module-package/private-module.js';
// Throws - Package exports error
```

> Note: this is not a strong encapsulation as any private modules can still be
> loaded by absolute paths.

Folders can also be mapped with package exports as well:

<!-- eslint-skip -->
```js
// ./node_modules/es-module-package/package.json
{
"exports": {
"./features/": "./src/features/"
}
}
```

```js
import feature from 'es-module-package/features/x.js';
// Loads ./node_modules/es-module-package/src/features/x.js
```

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.
This is just a convention that works because `false`, just like `{}`, has no
iterable own properties.
Copy link
Member

Choose a reason for hiding this comment

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

can we document what happens if an exports:

  • starts with '../'
  • starts with '/'
  • is an absolute URL
  • doesn't lead with './', '../', or '/'
  • escapes a package boundary (I actually assume this is allowed looking at the code?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You mean the values/targets of an export mapping? In any case - this is something we want to dig into in a dedicated change for both keys and values. I'd be concerned of missing important cases in the noise if we try to address it in this initial PR. It's listed as one of the blockers to unflagging.

Copy link
Member

Choose a reason for hiding this comment

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

seems fine to push off until unflagging.


## <code>import</code> Specifiers

### Terminology
Expand Down
2 changes: 2 additions & 0 deletions src/env.h
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ struct PackageConfig {
const HasMain has_main;
const std::string main;
const PackageType type;

v8::Global<v8::Value> exports;
};
} // namespace loader

Expand Down
81 changes: 73 additions & 8 deletions src/module_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ Maybe<const PackageConfig*> GetPackageConfig(Environment* env,
if (source.IsNothing()) {
auto entry = env->package_json_cache.emplace(path,
PackageConfig { Exists::No, IsValid::Yes, HasMain::No, "",
PackageType::None });
PackageType::None, Global<Value>() });
return Just(&entry.first->second);
}

Expand All @@ -578,7 +578,7 @@ Maybe<const PackageConfig*> GetPackageConfig(Environment* env,
!pkg_json_v->ToObject(context).ToLocal(&pkg_json)) {
env->package_json_cache.emplace(path,
PackageConfig { Exists::Yes, IsValid::No, HasMain::No, "",
PackageType::None });
PackageType::None, Global<Value>() });
std::string msg = "Invalid JSON in '" + path +
"' imported from " + base.ToFilePath();
node::THROW_ERR_INVALID_PACKAGE_CONFIG(env, msg.c_str());
Expand Down Expand Up @@ -609,22 +609,22 @@ Maybe<const PackageConfig*> GetPackageConfig(Environment* env,
}

Local<Value> exports_v;
if (pkg_json->Get(env->context(),
if (env->options()->experimental_exports &&
pkg_json->Get(env->context(),
env->exports_string()).ToLocal(&exports_v) &&
(exports_v->IsObject() || exports_v->IsString() ||
exports_v->IsBoolean())) {
!exports_v->IsNullOrUndefined()) {
Global<Value> exports;
exports.Reset(env->isolate(), exports_v);

auto entry = env->package_json_cache.emplace(path,
PackageConfig { Exists::Yes, IsValid::Yes, has_main, main_std,
pkg_type });
pkg_type, std::move(exports) });
return Just(&entry.first->second);
}

auto entry = env->package_json_cache.emplace(path,
PackageConfig { Exists::Yes, IsValid::Yes, has_main, main_std,
pkg_type });
pkg_type, Global<Value>() });
return Just(&entry.first->second);
}

Expand Down Expand Up @@ -800,6 +800,66 @@ Maybe<URL> PackageMainResolve(Environment* env,
return Nothing<URL>();
}

Maybe<URL> PackageExportsResolve(Environment* env,
const URL& pjson_url,
const std::string& pkg_subpath,
const PackageConfig& pcfg,
const URL& base) {
CHECK(env->options()->experimental_exports);
Isolate* isolate = env->isolate();
Local<Context> context = env->context();
Local<Value> exports = pcfg.exports.Get(isolate);
if (exports->IsObject()) {
Local<Object> exports_obj = exports.As<Object>();
Local<String> subpath = String::NewFromUtf8(isolate,
pkg_subpath.c_str(), v8::NewStringType::kNormal).ToLocalChecked();

auto target = exports_obj->Get(context, subpath).ToLocalChecked();
addaleax marked this conversation as resolved.
Show resolved Hide resolved
if (target->IsString()) {
Utf8Value target_utf8(isolate, target.As<v8::String>());
std::string target(*target_utf8, target_utf8.length());
if (target.substr(0, 2) == "./") {
URL target_url(target, pjson_url);
return FinalizeResolution(env, target_url, base);
}
}

Local<String> best_match;
std::string best_match_str = "";
Local<Array> keys =
exports_obj->GetOwnPropertyNames(context).ToLocalChecked();
for (uint32_t i = 0; i < keys->Length(); ++i) {
Local<String> key = keys->Get(context, i).ToLocalChecked().As<String>();
Utf8Value key_utf8(isolate, key);
std::string key_str(*key_utf8, key_utf8.length());
if (key_str.back() != '/') continue;
if (pkg_subpath.substr(0, key_str.length()) == key_str &&
key_str.length() > best_match_str.length()) {
best_match = key;
best_match_str = key_str;
}
}

if (best_match_str.length() > 0) {
auto target = exports_obj->Get(context, best_match).ToLocalChecked();
if (target->IsString()) {
Utf8Value target_utf8(isolate, target.As<v8::String>());
std::string target(*target_utf8, target_utf8.length());
if (target.back() == '/' && target.substr(0, 2) == "./") {
std::string subpath = pkg_subpath.substr(best_match_str.length());
URL target_url(target + subpath, pjson_url);
return FinalizeResolution(env, target_url, base);
}
}
}
}
std::string msg = "Package exports for '" +
URL(".", pjson_url).ToFilePath() + "' do not define a '" + pkg_subpath +
"' subpath, imported from " + base.ToFilePath();
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
return Nothing<URL>();
}

Maybe<URL> PackageResolve(Environment* env,
const std::string& specifier,
const URL& base) {
Expand Down Expand Up @@ -847,7 +907,12 @@ Maybe<URL> PackageResolve(Environment* env,
if (!pkg_subpath.length()) {
return PackageMainResolve(env, pjson_url, *pcfg.FromJust(), base);
} else {
return FinalizeResolution(env, URL(pkg_subpath, pjson_url), base);
if (!pcfg.FromJust()->exports.IsEmpty()) {
return PackageExportsResolve(env, pjson_url, pkg_subpath,
*pcfg.FromJust(), base);
} else {
return FinalizeResolution(env, URL(pkg_subpath, pjson_url), base);
}
}
CHECK(false);
// Cross-platform root check.
Expand Down
4 changes: 4 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ DebugOptionsParser::DebugOptionsParser() {
}

EnvironmentOptionsParser::EnvironmentOptionsParser() {
AddOption("--experimental-exports",
"experimental support for exports in package.json",
&EnvironmentOptions::experimental_exports,
kAllowedInEnvironment);
AddOption("--experimental-modules",
"experimental ES Module support and caching modules",
&EnvironmentOptions::experimental_modules,
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ class DebugOptions : public Options {
class EnvironmentOptions : public Options {
public:
bool abort_on_uncaught_exception = false;
bool experimental_exports = false;
bool experimental_modules = false;
std::string es_module_specifier_resolution;
bool experimental_wasm_modules = false;
Expand Down
28 changes: 28 additions & 0 deletions test/es-module/test-esm-exports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Flags: --experimental-modules --experimental-exports

import { mustCall } from '../common/index.mjs';
import { ok, strictEqual } from 'assert';

import { asdf, asdf2 } from '../fixtures/pkgexports.mjs';
import {
loadMissing,
loadFromNumber,
loadDot,
} from '../fixtures/pkgexports-missing.mjs';

strictEqual(asdf, 'asdf');
strictEqual(asdf2, 'asdf');

loadMissing().catch(mustCall((err) => {
ok(err.message.toString().startsWith('Package exports'));
ok(err.message.toString().indexOf('do not define a \'./missing\' subpath'));
}));

loadFromNumber().catch(mustCall((err) => {
ok(err.message.toString().startsWith('Package exports'));
ok(err.message.toString().indexOf('do not define a \'./missing\' subpath'));
}));

loadDot().catch(mustCall((err) => {
ok(err.message.toString().startsWith('Cannot find main entry point'));
}));
1 change: 1 addition & 0 deletions test/fixtures/node_modules/pkgexports-number/hidden.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions test/fixtures/node_modules/pkgexports-number/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/fixtures/node_modules/pkgexports/asdf.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions test/fixtures/node_modules/pkgexports/package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions test/fixtures/pkgexports-missing.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function loadMissing() {
return import('pkgexports/missing');
}

export function loadFromNumber() {
return import('pkgexports-number/hidden.js');
}

export function loadDot() {
return import('pkgexports');
}
2 changes: 2 additions & 0 deletions test/fixtures/pkgexports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as asdf } from 'pkgexports/asdf';
export { default as asdf2 } from 'pkgexports/sub/asdf.js';