Skip to content
Permalink
Browse files

esm: implement "pkg-exports" proposal

Refs: jkrems/proposal-pkg-exports#36

PR-URL: #28568
Reviewed-By: Anna Henningsen <anna@addaleax.net>
  • Loading branch information...
guybedford authored and targos committed Jan 16, 2019
1 parent ff432c8 commit b379c0e8b6b1f67fb7985d3c51f6200e2e3f2290
@@ -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`
@@ -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;
@@ -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'));
}));

Some generated files are not rendered by default. Learn more.

Some generated files are not rendered by default. Learn more.

Some generated files are not rendered by default. Learn more.

Some generated files are not rendered by default. Learn more.

@@ -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');
}
@@ -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.
You can’t perform that action at this time.