Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Imports proposal - first draft #40

Merged
merged 5 commits into from Aug 15, 2019
Merged
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
89 changes: 78 additions & 11 deletions README.md
Expand Up @@ -12,6 +12,7 @@
* A package is exposing both an ESM and a CJS interface.
* A project wants to mix both ESM and CJS code, with CJS running as part of the ESM module graph.
* A package wants to expose multiple entrypoints as its public API without leaking internal directory structure.
* A package wants to reference an internally aliased subpath, without exposing it publicly.

## High Level Considerations

Expand All @@ -22,22 +23,19 @@
* Resolution should not depend on file extensions, allowing ESM syntax in `.js` files.
* The directory structure of a module should be treated as private implementation detail.

## `package.json` Interface
## `package.json` Interfaces

We propose a field in `package.json` to specify one or more entrypoint locations when importing bare specifiers.
We propose two fields in `package.json` to specify entrypoints and internal aliasing of bare specifiers - [`"exports"`](#1-exports-field) and [`"imports"`](#2-imports-field).
guybedford marked this conversation as resolved.
Show resolved Hide resolved

> **The key is TBD, the examples use `"exports"` as a placeholder.**
> **Neither the name nor the fact that it exists top-level is final.**
> **For both fields the final names of `"exports"` and `"imports"` are still TBD, and these names should be considered placeholders.**

The `package.json` `"exports"` interface will only be respected for bare specifiers, e.g. `import _ from 'lodash'` where the specifier `'lodash'` doesn’t start with a `.` or `/`.
Both interfaces will only be respected for bare specifiers, e.g. `import _ from 'lodash'` where the specifier `'lodash'` doesn’t start with a `.` or `/`.

`"exports"` works in concert with the `package.json` `"type": "module"` signifier that a package can be imported as ESM by Node - `"exports"` by itself does not signify that a package should be treated as ESM.
Both features can be supported in both CommonJS and ES modules.
Copy link
Contributor

Choose a reason for hiding this comment

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

🎉


This feature can be supported for both CommonJS and ES modules.
### 1. Exports Field

For packages that only have a main and no exports, `"exports": false` can be used as a shorthand for `"exports": {}` providing an encapsulated package.

### Example
#### Example

Here’s a complete `package.json` example, for a hypothetical module named `@momentjs/moment`:

Expand Down Expand Up @@ -80,7 +78,9 @@ Rough outline of a possible resolution algorithm:

In the future, the algorithm might be adjusted to align with work done in the [import maps proposal](https://github.com/domenic/import-maps).

### Usage
For packages that only have a main and no exports, `"exports": false` can be used as a shorthand for `"exports": {}` providing an encapsulated package.
Copy link
Contributor

Choose a reason for hiding this comment

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

cc @GeoffreyBooth i think this exact use case is a reason it’s useful, in that other thread, to have exports override whatever main points to, instead of providing main itself.

Copy link
Owner

Choose a reason for hiding this comment

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

Can you elaborate? This suggests having: {"main": "./foo.mjs", "exports": false} which would break if exports would completely overwrite main..? It does work if it desugars to merge semantics (Object.assign({ ".": mainValue || [] }, exports)).

Copy link
Contributor

Choose a reason for hiding this comment

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

that’s a good point, but I’m more thinking conceptually.

Copy link
Collaborator

Choose a reason for hiding this comment

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

i think this exact use case is a reason it’s useful, in that other thread, to have exports override whatever main points to, instead of providing main itself.

I’m not sure I follow; you mean the reason that exports: false is useful?

A package needs to export something, right? exports: false and no main would be the package equivalent of unreachable code?

I think I agree with @jkrems that merge semantics make the most sense for this, since overriding main with exports: false would have the effect of creating an unusable package in all versions of Node that support exports. We’re obviously not dropping support for main, so if exports allows for setting the main as well it would just take precedence.

Copy link
Contributor

Choose a reason for hiding this comment

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

i don’t mean that. I mean that exports has the ability to override whatever main points to. exports false wouldn’t be using that ability.

Copy link
Collaborator

Choose a reason for hiding this comment

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

It’s a question of semantics. The docs could simply define exports: false as “no exports other than whatever is defined in "main".” or something like that. That’s probably what they should say.

Because if you think about it, the lack of "." in exports wouldn’t mean that there’s no main export defined for the package. I.e. we’d want to support:

"main": "./main.js",
"exports": { "./foo": "./foo/index.js" }

Where require('pkg') and require('pkg/foo') both work. This would be an example of the merging/Object.assign approach, rather than exports overriding main in the sense that this package would not have any main export defined because there’s no "." in exports.

Copy link
Owner

Choose a reason for hiding this comment

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

It sounds like you both support the same semantics and the only disagreement is how it should be worded? My vote is for calling it "merge" because as @GeoffreyBooth points out, it's only the "." key that actually overrides anything. But I think everyone agrees that we should bring back dot-main in some form (the ability to specify main in exports in a way to wins over the top-level main field).

Copy link
Collaborator

Choose a reason for hiding this comment

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

Perhaps "." should have its own small section in the docs. Something like:

"." can specify the root entry point for a package, i.e. the file loaded for require('pkg') or import 'pkg'. Historically this has been defined by the package.json "main" field, and "main" will continue to be supported. If "main" is defined and "." is not, the value in "main" will determine the package root entry point; if both "main" and "." are defined, the latter takes precedence. If "main" is defined and exports is set to false, the value in "main" is the only export available for the package.

Since "main" doesn’t define an object it’s a little weird to discuss merging at all. We’re really only talking about the precedence of "main" versus ".".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hopefully we can discuss this main problem at the coming meeting.

I would ideally like to get the same spec here merged so that we can also start to discuss it further though, and wouldn't want these main considerations to block that.

Is there something we can do here to resolve this further? Any note or clarifications?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think there's any disagreement? It seems like we're just discussing the best way to explain the behavior to users, which is something we can worry about when we get to writing the docs.


#### Usage

For a consumer, the above `@momentjs/moment` and `request` packages can be used as follows, assuming the user’s project is in `/app` with `/app/package.json` and `/app/node_modules`:

Expand Down Expand Up @@ -120,6 +120,73 @@ import utc from '@momentjs/moment/timezones/utc/'; // Note trailing slash
// Error: folders cannot be imported (there is no index.* magic)
```

### 2. Imports Field

Imports provide the ability to remap bare specifiers within packages before they hit the node_modules resolution process.

The current proposal prefixes all imports with `#` to provide a clear signal that it's a _symbolic specifier_ and also to prevent packages that use imports from working in any environment (runtime, bundler) that isn't aware of imports.

Choose a reason for hiding this comment

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

I like ~ for this instead of #. Parcel and a few other tools already support ~/foo to mean foo within the nearest folder with package.json. Supporting ~foo (without the slash) to mean the foo named import within the package.json kinda makes sense too. ~ always refers to the folder with package.json, and you can either refer to a file or a named import from there. Not sure if Node is interested in the ~/ specifier as well, but it would leave the option open for the future.

Copy link
Owner

Choose a reason for hiding this comment

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

I think node may be interested in ~/ (or something like it) to mean "this package as exported". Right now there's no good way to unit test the public interface when using exports for example. If not ~, we'd need to find another character for this.

Choose a reason for hiding this comment

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

I guess that's what I meant. ~/ gets you to the package root, and it's normal resolution after that. So if there is a foo export, then ~/foo would refer to that as it would normally. And ~foo would refer to an import. ~ or ~/ by itself could refer to main.

Copy link
Owner

@jkrems jkrems Aug 14, 2019

Choose a reason for hiding this comment

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

Is it worth opening a dedicated issue to resolve the sigil question? My current thoughts:

We have three kinds of specifiers that people have asked for (implied: that we may or may not want to support):

  1. Getting the public interface of the package. This will allow people using exports to actually unit test their packages (since exports do not apply to relative specifiers). Examples: (the main/default export), ✩/subpath
  2. Adding custom aliases that are only valid inside of the package boundary. Examples: ✩data/emoji.json, ✩fetch.
  3. Accessing arbitrary paths relative to the package boundary. Examples: ✩/src/model.mjs.

Of these, only (1) and (3) actually conflict. (2) could share a symbol with either one of them. A concern raised by @guybedford was that if (1) and (2) share a symbol, it may be confusing.

So to me the options are:

  1. One sigil, no (3):
    • Use ~ and ~/ to mean "this package as if it was imported by name".
    • Allow ~<name> to be used for custom aliases within the package.
  2. Two sigils, optional support for (3):
    • Use ~ and ~/` to mean "this package as if it was imported by name".
    • Use #<name> for "private names", aliases only visible inside of the package.
    • (optional) Use #/ for "paths relative to the package boundary".

For packages that don't use exports, ~/ and #/ are effectively the same but that would change once they choose to remap subpaths and/or lock themselves down.

Copy link
Owner

Choose a reason for hiding this comment

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

My preference right now would be ~ + #<name> without support for importing non-public paths relative to the project root. There would still be design space for adding #/ in the future if it becomes truly necessary.

Choose a reason for hiding this comment

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

Isn't # problematic since it's meaningful to URL parsing? ESM specifiers are URLs, so wouldn't that be considered a hash?

Copy link
Owner

Choose a reason for hiding this comment

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

While ESM uses URLs as cache keys in the browser, it has more restrictions for specifiers. The only kinds of specifiers it allows are:

  1. Relative specifiers starting with ./, ../, or /.
  2. An absolute URL, including protocol.

See: https://html.spec.whatwg.org/#resolve-a-module-specifier

So neither ? nor # may start a specifier unless something like an import map is involved. Even though both are valid relative URLs in other contexts like certain HTML attributes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought we would promise not to bikeshed yet :P

But if we must then my preference would be to use ~/ for the internal root and #/ or something else for the public interface.

Under that logic, perhaps we should use ~name?

But yeah opening a new issue to hash / tilde this out seems to make sense!

Copy link
Owner

Choose a reason for hiding this comment

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

Forked this to an issue: #41


> **Whether this restriction is maintained in the final proposal, or what exact symbol is used for `#` is still TBD.**

#### Example

For the same example package as provided for `"exports"`, consider if we wanted to make the `timezones` implementation something that is only referenced internally by code within `@momentjs/moment`, instead of exposing it to external importers.

```js
{
"name": "@momentjs/moment",
"version": "0.0.0",
"type": "module",
"main": "./dist/index.js",
"imports": {
"#timezones/": "./data/timezones/",
"#timezones/utc": "./data/timezones/utc/index.mjs",
"#external-feature": "external-pkg/feature",
"#moment/": "./"
}
}
```

As with package exports, mappings are mapped relative to the package base, and keys that end in slashes can map to folder roots.

The resolution algorithms remain the same except `"imports"` provide the added feature that they can also map into third-party packages that would be looked up in node_modules, including to subpaths that would be in turn resolved through `"exports"`. There is no risk of circular resolution here, since `"exports"` themselves only ever resolve to direct internal paths and can't in turn map to aliases.

The `"imports"` that apply within a given file are determined based on looking up the package boundary of that file.

#### Usage

For the author of `@momentjs/moment`, they can use these aliases with the following code in any file in `@momentjs/moment/*.js`, provided it matches the package boundary of the `package.json` file:

```js
import utc from '#timezones/utc';
// Loads file:///app/node_modules/@momentjs/moment/data/timezones/utc/index.mjs

import utc from './data/timezones/utc/index.mjs';
// Loads file:///app/node_modules/@momentjs/moment/data/timezones/utc/index.mjs

import utc from '#timezones/utc/index.mjs';
// Loads file:///app/node_modules/@momentjs/moment/data/timezones/utc/index.mjs

import utc from '#moment/data/timezones/utc/index.mjs';
// Loads file:///app/node_modules/@momentjs/moment/data/timezones/utc/index.mjs
```

The following don’t work - please note that **error messages and codes are TBD**:

```js
import utc from '#timezones/utc/';
// Error: trailing slash not mapped

import unknown from '#unknown';
// Error: no such import alias

import timezones from '#timezones/';
// Error: trailing slash not allowed (cannot import folders, only files)

import utc from '#moment';
// Error: no mapping provided (folder mappings require subpaths)
```

### Prior Art

* [`package.json#browser`](https://github.com/defunctzombie/package-browser-field-spec)
Expand Down