Skip to content
This repository was archived by the owner on Apr 16, 2020. It is now read-only.

Implement "exports" proposal #72

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should just be “any non-nullish value is passed through Object.keys to get the export list; any nullish value disables the feature”, because then “false” is just a convention instead of a magic special value.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So:

  1. null and undefined: Same as { './': './' }.
  2. Anything else: Normalized as an object (e.g. Object.assign({}, pkgExports)).

Makes sense!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’d use { ...pkgExports } but yes, exactly!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think guiding people towards a convention here may still have value. How about adding the following sentence:

This is just a convention that works because false, just like {}, has no iterable own properties.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and that text sounds good to me.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

`"exports": {}` to indicate the package does not intent 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
4 changes: 4 additions & 0 deletions src/env.h
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,17 @@ struct PackageConfig {
enum class Exists { Yes, No };
enum class IsValid { Yes, No };
enum class HasMain { Yes, No };
enum class HasExports { Yes, No };
enum PackageType : uint32_t { None = 0, CommonJS, Module };

const Exists exists;
const IsValid is_valid;
const HasMain has_main;
const std::string main;
const PackageType type;

const HasExports has_exports;
const v8::CopyablePersistentTraits<v8::Value>::CopyablePersistent exports;
};
} // namespace loader

Expand Down
88 changes: 78 additions & 10 deletions src/module_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ using v8::Context;
using v8::Function;
using v8::FunctionCallbackInfo;
using v8::FunctionTemplate;
using v8::Global;
using v8::HandleScope;
using v8::Integer;
using v8::IntegrityLevel;
Expand All @@ -39,6 +38,7 @@ using v8::Module;
using v8::Nothing;
using v8::Number;
using v8::Object;
using v8::Persistent;
using v8::PrimitiveArray;
using v8::Promise;
using v8::ScriptCompiler;
Expand Down Expand Up @@ -537,6 +537,7 @@ using Exists = PackageConfig::Exists;
using IsValid = PackageConfig::IsValid;
using HasMain = PackageConfig::HasMain;
using PackageType = PackageConfig::PackageType;
using HasExports = PackageConfig::HasExports;

Maybe<const PackageConfig*> GetPackageConfig(Environment* env,
const std::string& path,
Expand All @@ -558,7 +559,8 @@ 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, HasExports::No,
Persistent<Value>() });
return Just(&entry.first->second);
}

Expand All @@ -578,7 +580,8 @@ 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, HasExports::No,
Persistent<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 +612,22 @@ Maybe<const PackageConfig*> GetPackageConfig(Environment* env,
}

Local<Value> exports_v;
Persistent<Value> exports;
if (pkg_json->Get(env->context(),
env->exports_string()).ToLocal(&exports_v) &&
(exports_v->IsObject() || exports_v->IsString() ||
exports_v->IsBoolean())) {
Global<Value> exports;
!exports_v->IsNullOrUndefined()) {
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, HasExports::Yes, 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, HasExports::No,
Persistent<Value>() });
return Just(&entry.first->second);
}

Expand All @@ -646,7 +649,8 @@ Maybe<const PackageConfig*> GetPackageScopeConfig(Environment* env,
if (pjson_url.path() == last_pjson_url.path()) {
auto entry = env->package_json_cache.emplace(pjson_url.ToFilePath(),
PackageConfig { Exists::No, IsValid::Yes, HasMain::No, "",
PackageType::None });
PackageType::None, HasExports::No,
Persistent<Value>() });
const PackageConfig* pcfg = &entry.first->second;
return Just(pcfg);
}
Expand Down Expand Up @@ -800,6 +804,65 @@ 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) {
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 +910,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()->has_exports == HasExports::Yes) {
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
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

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';