diff --git a/doc/api/cli.md b/doc/api/cli.md index dc695baed91ca5..61e833b878d899 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` + + +Enable experimental resolution using the `exports` field in `package.json`. + ### `--experimental-modules` - `--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 74d818946e007c..14035cbf5ca243 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. + + +```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: + + +```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. + ## import Specifiers ### Terminology diff --git a/src/env.h b/src/env.h index 2bcb07a6333b6f..d2066f54d81887 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 exports; }; } // namespace loader diff --git a/src/module_wrap.cc b/src/module_wrap.cc index e104afb736c28d..f1c819874a3839 100644 --- a/src/module_wrap.cc +++ b/src/module_wrap.cc @@ -558,7 +558,7 @@ Maybe 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() }); return Just(&entry.first->second); } @@ -578,7 +578,7 @@ Maybe 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() }); 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 GetPackageConfig(Environment* env, } Local 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 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() }); return Just(&entry.first->second); } @@ -800,6 +800,66 @@ Maybe PackageMainResolve(Environment* env, return Nothing(); } +Maybe 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 = env->context(); + Local exports = pcfg.exports.Get(isolate); + if (exports->IsObject()) { + Local exports_obj = exports.As(); + Local 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()); + 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 best_match; + std::string best_match_str = ""; + Local keys = + exports_obj->GetOwnPropertyNames(context).ToLocalChecked(); + for (uint32_t i = 0; i < keys->Length(); ++i) { + Local key = keys->Get(context, i).ToLocalChecked().As(); + 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()); + 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(); +} + Maybe PackageResolve(Environment* env, const std::string& specifier, const URL& base) { @@ -847,7 +907,12 @@ Maybe 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 fe9479d1ccb660..e0a766994b5e67 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 7a4f61a287bc87..f988800fde6690 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 00000000000000..88115026654726 --- /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 00000000000000..c04e6ee618e15c --- /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 00000000000000..315f39a66e32a6 --- /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 00000000000000..683f2d8ba623a7 --- /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 00000000000000..51c596ed8673ab --- /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 00000000000000..7d1d5b2e821629 --- /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 00000000000000..4d82ba0560ef11 --- /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';