Skip to content

Commit b379c0e

Browse files
guybedfordtargos
authored andcommitted
esm: implement "pkg-exports" proposal
Refs: jkrems/proposal-pkg-exports#36 PR-URL: #28568 Reviewed-By: Anna Henningsen <anna@addaleax.net>
1 parent ff432c8 commit b379c0e

File tree

13 files changed

+196
-8
lines changed

13 files changed

+196
-8
lines changed

doc/api/cli.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,13 @@ the ability to import a directory that has an index file.
148148

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

151+
### `--experimental-exports`
152+
<!-- YAML
153+
added: REPLACEME
154+
-->
155+
156+
Enable experimental resolution using the `exports` field in `package.json`.
157+
151158
### `--experimental-modules`
152159
<!-- YAML
153160
added: v8.5.0
@@ -946,6 +953,7 @@ Node.js options that are allowed are:
946953
<!-- node-options-node start -->
947954
- `--enable-fips`
948955
- `--es-module-specifier-resolution`
956+
- `--experimental-exports`
949957
- `--experimental-modules`
950958
- `--experimental-policy`
951959
- `--experimental-repl-await`

doc/api/esm.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,61 @@ a package would be accessible like `require('pkg')` and `import
216216
module entry point and legacy users could be informed of the CommonJS entry
217217
point path, e.g. `require('pkg/commonjs')`.
218218

219+
## Package Exports
220+
221+
By default, all subpaths from a package can be imported (`import 'pkg/x.js'`).
222+
Custom subpath aliasing and encapsulation can be provided through the
223+
`"exports"` field.
224+
225+
<!-- eslint-skip -->
226+
```js
227+
// ./node_modules/es-module-package/package.json
228+
{
229+
"exports": {
230+
"./submodule": "./src/submodule.js"
231+
}
232+
}
233+
```
234+
235+
```js
236+
import submodule from 'es-module-package/submodule';
237+
// Loads ./node_modules/es-module-package/src/submodule.js
238+
```
239+
240+
In addition to defining an alias, subpaths not defined by `"exports"` will
241+
throw when an attempt is made to import them:
242+
243+
```js
244+
import submodule from 'es-module-package/private-module.js';
245+
// Throws - Package exports error
246+
```
247+
248+
> Note: this is not a strong encapsulation as any private modules can still be
249+
> loaded by absolute paths.
250+
251+
Folders can also be mapped with package exports as well:
252+
253+
<!-- eslint-skip -->
254+
```js
255+
// ./node_modules/es-module-package/package.json
256+
{
257+
"exports": {
258+
"./features/": "./src/features/"
259+
}
260+
}
261+
```
262+
263+
```js
264+
import feature from 'es-module-package/features/x.js';
265+
// Loads ./node_modules/es-module-package/src/features/x.js
266+
```
267+
268+
If a package has no exports, setting `"exports": false` can be used instead of
269+
`"exports": {}` to indicate the package does not intend for submodules to be
270+
exposed.
271+
This is just a convention that works because `false`, just like `{}`, has no
272+
iterable own properties.
273+
219274
## <code>import</code> Specifiers
220275

221276
### Terminology

src/env.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ struct PackageConfig {
9999
const HasMain has_main;
100100
const std::string main;
101101
const PackageType type;
102+
103+
v8::Global<v8::Value> exports;
102104
};
103105
} // namespace loader
104106

src/module_wrap.cc

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ Maybe<const PackageConfig*> GetPackageConfig(Environment* env,
558558
if (source.IsNothing()) {
559559
auto entry = env->package_json_cache.emplace(path,
560560
PackageConfig { Exists::No, IsValid::Yes, HasMain::No, "",
561-
PackageType::None });
561+
PackageType::None, Global<Value>() });
562562
return Just(&entry.first->second);
563563
}
564564

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

611611
Local<Value> exports_v;
612-
if (pkg_json->Get(env->context(),
612+
if (env->options()->experimental_exports &&
613+
pkg_json->Get(env->context(),
613614
env->exports_string()).ToLocal(&exports_v) &&
614-
(exports_v->IsObject() || exports_v->IsString() ||
615-
exports_v->IsBoolean())) {
615+
!exports_v->IsNullOrUndefined()) {
616616
Global<Value> exports;
617617
exports.Reset(env->isolate(), exports_v);
618618

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

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

@@ -800,6 +800,66 @@ Maybe<URL> PackageMainResolve(Environment* env,
800800
return Nothing<URL>();
801801
}
802802

803+
Maybe<URL> PackageExportsResolve(Environment* env,
804+
const URL& pjson_url,
805+
const std::string& pkg_subpath,
806+
const PackageConfig& pcfg,
807+
const URL& base) {
808+
CHECK(env->options()->experimental_exports);
809+
Isolate* isolate = env->isolate();
810+
Local<Context> context = env->context();
811+
Local<Value> exports = pcfg.exports.Get(isolate);
812+
if (exports->IsObject()) {
813+
Local<Object> exports_obj = exports.As<Object>();
814+
Local<String> subpath = String::NewFromUtf8(isolate,
815+
pkg_subpath.c_str(), v8::NewStringType::kNormal).ToLocalChecked();
816+
817+
auto target = exports_obj->Get(context, subpath).ToLocalChecked();
818+
if (target->IsString()) {
819+
Utf8Value target_utf8(isolate, target.As<v8::String>());
820+
std::string target(*target_utf8, target_utf8.length());
821+
if (target.substr(0, 2) == "./") {
822+
URL target_url(target, pjson_url);
823+
return FinalizeResolution(env, target_url, base);
824+
}
825+
}
826+
827+
Local<String> best_match;
828+
std::string best_match_str = "";
829+
Local<Array> keys =
830+
exports_obj->GetOwnPropertyNames(context).ToLocalChecked();
831+
for (uint32_t i = 0; i < keys->Length(); ++i) {
832+
Local<String> key = keys->Get(context, i).ToLocalChecked().As<String>();
833+
Utf8Value key_utf8(isolate, key);
834+
std::string key_str(*key_utf8, key_utf8.length());
835+
if (key_str.back() != '/') continue;
836+
if (pkg_subpath.substr(0, key_str.length()) == key_str &&
837+
key_str.length() > best_match_str.length()) {
838+
best_match = key;
839+
best_match_str = key_str;
840+
}
841+
}
842+
843+
if (best_match_str.length() > 0) {
844+
auto target = exports_obj->Get(context, best_match).ToLocalChecked();
845+
if (target->IsString()) {
846+
Utf8Value target_utf8(isolate, target.As<v8::String>());
847+
std::string target(*target_utf8, target_utf8.length());
848+
if (target.back() == '/' && target.substr(0, 2) == "./") {
849+
std::string subpath = pkg_subpath.substr(best_match_str.length());
850+
URL target_url(target + subpath, pjson_url);
851+
return FinalizeResolution(env, target_url, base);
852+
}
853+
}
854+
}
855+
}
856+
std::string msg = "Package exports for '" +
857+
URL(".", pjson_url).ToFilePath() + "' do not define a '" + pkg_subpath +
858+
"' subpath, imported from " + base.ToFilePath();
859+
node::THROW_ERR_MODULE_NOT_FOUND(env, msg.c_str());
860+
return Nothing<URL>();
861+
}
862+
803863
Maybe<URL> PackageResolve(Environment* env,
804864
const std::string& specifier,
805865
const URL& base) {
@@ -847,7 +907,12 @@ Maybe<URL> PackageResolve(Environment* env,
847907
if (!pkg_subpath.length()) {
848908
return PackageMainResolve(env, pjson_url, *pcfg.FromJust(), base);
849909
} else {
850-
return FinalizeResolution(env, URL(pkg_subpath, pjson_url), base);
910+
if (!pcfg.FromJust()->exports.IsEmpty()) {
911+
return PackageExportsResolve(env, pjson_url, pkg_subpath,
912+
*pcfg.FromJust(), base);
913+
} else {
914+
return FinalizeResolution(env, URL(pkg_subpath, pjson_url), base);
915+
}
851916
}
852917
CHECK(false);
853918
// Cross-platform root check.

src/node_options.cc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,10 @@ DebugOptionsParser::DebugOptionsParser() {
304304
}
305305

306306
EnvironmentOptionsParser::EnvironmentOptionsParser() {
307+
AddOption("--experimental-exports",
308+
"experimental support for exports in package.json",
309+
&EnvironmentOptions::experimental_exports,
310+
kAllowedInEnvironment);
307311
AddOption("--experimental-modules",
308312
"experimental ES Module support and caching modules",
309313
&EnvironmentOptions::experimental_modules,

src/node_options.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class DebugOptions : public Options {
100100
class EnvironmentOptions : public Options {
101101
public:
102102
bool abort_on_uncaught_exception = false;
103+
bool experimental_exports = false;
103104
bool experimental_modules = false;
104105
std::string es_module_specifier_resolution;
105106
bool experimental_wasm_modules = false;

test/es-module/test-esm-exports.mjs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Flags: --experimental-modules --experimental-exports
2+
3+
import { mustCall } from '../common/index.mjs';
4+
import { ok, strictEqual } from 'assert';
5+
6+
import { asdf, asdf2 } from '../fixtures/pkgexports.mjs';
7+
import {
8+
loadMissing,
9+
loadFromNumber,
10+
loadDot,
11+
} from '../fixtures/pkgexports-missing.mjs';
12+
13+
strictEqual(asdf, 'asdf');
14+
strictEqual(asdf2, 'asdf');
15+
16+
loadMissing().catch(mustCall((err) => {
17+
ok(err.message.toString().startsWith('Package exports'));
18+
ok(err.message.toString().indexOf('do not define a \'./missing\' subpath'));
19+
}));
20+
21+
loadFromNumber().catch(mustCall((err) => {
22+
ok(err.message.toString().startsWith('Package exports'));
23+
ok(err.message.toString().indexOf('do not define a \'./missing\' subpath'));
24+
}));
25+
26+
loadDot().catch(mustCall((err) => {
27+
ok(err.message.toString().startsWith('Cannot find main entry point'));
28+
}));

test/fixtures/node_modules/pkgexports-number/hidden.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/node_modules/pkgexports-number/package.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/node_modules/pkgexports/asdf.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/node_modules/pkgexports/package.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/pkgexports-missing.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function loadMissing() {
2+
return import('pkgexports/missing');
3+
}
4+
5+
export function loadFromNumber() {
6+
return import('pkgexports-number/hidden.js');
7+
}
8+
9+
export function loadDot() {
10+
return import('pkgexports');
11+
}

test/fixtures/pkgexports.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as asdf } from 'pkgexports/asdf';
2+
export { default as asdf2 } from 'pkgexports/sub/asdf.js';

0 commit comments

Comments
 (0)