esm: implement "pkg-exports" proposal
Refs: https://github.com/jkrems/proposal-pkg-exports/issues/36 PR-URL: https://github.com/nodejs/node/pull/28568 Reviewed-By: Anna Henningsen <anna@addaleax.net>
This commit is contained in:
parent
9b772250f1
commit
6df7b6a4f9
@ -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
|
||||
@ -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`
|
||||
|
@ -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.
|
||||
|
||||
## <code>import</code> Specifiers
|
||||
|
||||
### Terminology
|
||||
|
@ -99,6 +99,8 @@ struct PackageConfig {
|
||||
const HasMain has_main;
|
||||
const std::string main;
|
||||
const PackageType type;
|
||||
|
||||
v8::Global<v8::Value> exports;
|
||||
};
|
||||
} // namespace loader
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
@ -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());
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
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) {
|
||||
@ -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.
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
28
test/es-module/test-esm-exports.mjs
Normal file
28
test/es-module/test-esm-exports.mjs
Normal file
@ -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
test/fixtures/node_modules/pkgexports-number/hidden.js
generated
vendored
Normal file
1
test/fixtures/node_modules/pkgexports-number/hidden.js
generated
vendored
Normal file
@ -0,0 +1 @@
|
||||
module.exports = 'not-part-of-api';
|
3
test/fixtures/node_modules/pkgexports-number/package.json
generated
vendored
Normal file
3
test/fixtures/node_modules/pkgexports-number/package.json
generated
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"exports": 42
|
||||
}
|
1
test/fixtures/node_modules/pkgexports/asdf.js
generated
vendored
Normal file
1
test/fixtures/node_modules/pkgexports/asdf.js
generated
vendored
Normal file
@ -0,0 +1 @@
|
||||
module.exports = 'asdf';
|
7
test/fixtures/node_modules/pkgexports/package.json
generated
vendored
Normal file
7
test/fixtures/node_modules/pkgexports/package.json
generated
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"exports": {
|
||||
".": "./asdf.js",
|
||||
"./asdf": "./asdf.js",
|
||||
"./sub/": "./"
|
||||
}
|
||||
}
|
11
test/fixtures/pkgexports-missing.mjs
vendored
Normal file
11
test/fixtures/pkgexports-missing.mjs
vendored
Normal file
@ -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
test/fixtures/pkgexports.mjs
vendored
Normal file
2
test/fixtures/pkgexports.mjs
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as asdf } from 'pkgexports/asdf';
|
||||
export { default as asdf2 } from 'pkgexports/sub/asdf.js';
|
Loading…
x
Reference in New Issue
Block a user