Skip to content

Commit

Permalink
esm: implement "pkg-exports" proposal
Browse files Browse the repository at this point in the history
Refs: jkrems/proposal-pkg-exports#36

PR-URL: #28568
Reviewed-By: Anna Henningsen <anna@addaleax.net>
  • Loading branch information
guybedford authored and targos committed Jul 20, 2019
1 parent ff432c8 commit b379c0e
Show file tree
Hide file tree
Showing 13 changed files with 196 additions and 8 deletions.
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 @@ -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`
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.

## <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();
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';

0 comments on commit b379c0e

Please sign in to comment.