summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/api/cli.md8
-rw-r--r--doc/api/esm.md55
-rw-r--r--src/env.h2
-rw-r--r--src/module_wrap.cc81
-rw-r--r--src/node_options.cc4
-rw-r--r--src/node_options.h1
-rw-r--r--test/es-module/test-esm-exports.mjs28
-rw-r--r--test/fixtures/node_modules/pkgexports-number/hidden.js1
-rw-r--r--test/fixtures/node_modules/pkgexports-number/package.json3
-rw-r--r--test/fixtures/node_modules/pkgexports/asdf.js1
-rw-r--r--test/fixtures/node_modules/pkgexports/package.json7
-rw-r--r--test/fixtures/pkgexports-missing.mjs11
-rw-r--r--test/fixtures/pkgexports.mjs2
13 files changed, 196 insertions, 8 deletions
diff --git a/doc/api/cli.md b/doc/api/cli.md
index 685f2198d5..544a3acb16 100644
--- a/doc/api/cli.md
+++ b/doc/api/cli.md
@@ -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
@@ -946,6 +953,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`
diff --git a/doc/api/esm.md b/doc/api/esm.md
index 74d818946e..14035cbf5c 100644
--- a/doc/api/esm.md
+++ b/doc/api/esm.md
@@ -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
diff --git a/src/env.h b/src/env.h
index 29b9a036cc..1356f0bbbc 100644
--- a/src/env.h
+++ b/src/env.h
@@ -99,6 +99,8 @@ struct PackageConfig {
const HasMain has_main;
const std::string main;
const PackageType type;
+
+ v8::Global<v8::Value> exports;
};
} // namespace loader
diff --git a/src/module_wrap.cc b/src/module_wrap.cc
index e104afb736..f1c819874a 100644
--- a/src/module_wrap.cc
+++ b/src/module_wrap.cc
@@ -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.
diff --git a/src/node_options.cc b/src/node_options.cc
index b6ea82158c..829154c3bf 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -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,
diff --git a/src/node_options.h b/src/node_options.h
index 280de40c0a..c55aaf17a0 100644
--- a/src/node_options.h
+++ b/src/node_options.h
@@ -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;
diff --git a/test/es-module/test-esm-exports.mjs b/test/es-module/test-esm-exports.mjs
new file mode 100644
index 0000000000..8811502665
--- /dev/null
+++ b/test/es-module/test-esm-exports.mjs
@@ -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'));
+}));
diff --git a/test/fixtures/node_modules/pkgexports-number/hidden.js b/test/fixtures/node_modules/pkgexports-number/hidden.js
new file mode 100644
index 0000000000..c04e6ee618
--- /dev/null
+++ b/test/fixtures/node_modules/pkgexports-number/hidden.js
@@ -0,0 +1 @@
+module.exports = 'not-part-of-api';
diff --git a/test/fixtures/node_modules/pkgexports-number/package.json b/test/fixtures/node_modules/pkgexports-number/package.json
new file mode 100644
index 0000000000..315f39a66e
--- /dev/null
+++ b/test/fixtures/node_modules/pkgexports-number/package.json
@@ -0,0 +1,3 @@
+{
+ "exports": 42
+}
diff --git a/test/fixtures/node_modules/pkgexports/asdf.js b/test/fixtures/node_modules/pkgexports/asdf.js
new file mode 100644
index 0000000000..683f2d8ba6
--- /dev/null
+++ b/test/fixtures/node_modules/pkgexports/asdf.js
@@ -0,0 +1 @@
+module.exports = 'asdf';
diff --git a/test/fixtures/node_modules/pkgexports/package.json b/test/fixtures/node_modules/pkgexports/package.json
new file mode 100644
index 0000000000..51c596ed86
--- /dev/null
+++ b/test/fixtures/node_modules/pkgexports/package.json
@@ -0,0 +1,7 @@
+{
+ "exports": {
+ ".": "./asdf.js",
+ "./asdf": "./asdf.js",
+ "./sub/": "./"
+ }
+}
diff --git a/test/fixtures/pkgexports-missing.mjs b/test/fixtures/pkgexports-missing.mjs
new file mode 100644
index 0000000000..7d1d5b2e82
--- /dev/null
+++ b/test/fixtures/pkgexports-missing.mjs
@@ -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');
+}
diff --git a/test/fixtures/pkgexports.mjs b/test/fixtures/pkgexports.mjs
new file mode 100644
index 0000000000..4d82ba0560
--- /dev/null
+++ b/test/fixtures/pkgexports.mjs
@@ -0,0 +1,2 @@
+export { default as asdf } from 'pkgexports/asdf';
+export { default as asdf2 } from 'pkgexports/sub/asdf.js';