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 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
89 changes: 77 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
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.
* A package wants to polyfill a builtin module and handle this through a package fallback mechanism like import maps.

## High Level Considerations
Expand All @@ -24,22 +25,19 @@
* The directory structure of a module should be treated as private implementation detail.
* Validations should apply similarly to import maps in supporting forwards-compatibility with possible future features.

## `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 `/`.
These 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.
Package _exports_ and _imports_ can be supported fully independently, and in both CommonJS and ES modules.

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 @@ -83,7 +81,7 @@ 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).

### Validations and Fallbacks
#### Validations and Fallbacks

The following validations are performed for an exports start to resolve:

Expand All @@ -101,7 +99,7 @@ Whenever there is a validation failure, any exports match must throw a Module No

Fallback arrays allow validation failures to continue checking the next item in the fallback array providing forwards compatiblitiy for new features in future based on extending these validation rules to new cases.

### Usage
#### 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 @@ -141,6 +139,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