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

RFC: Package Distributions #519

Closed
wants to merge 7 commits into from
Closed
Changes from 1 commit
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
176 changes: 176 additions & 0 deletions accepted/0000-package-distributions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# Package Distributions

## Summary

Today, maintainers utilize various strategies to distribute platform-specific versions of their software under a singular package namespace. Often, these strategies rely on `install` scripts or `optionalDependencies` with some type of bootloader implementation (ex. [`esbuild`](https://npmjs.com/package/esbuild?activeTab=explore)); borth are not ideal. The `npm` CLI should support a first-class/standard way for maintainers to define conditions in which a package distribution is reified in place of the origin target.
saquibkhan marked this conversation as resolved.
Show resolved Hide resolved
Copy link

@lydell lydell Feb 3, 2022

Choose a reason for hiding this comment

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

Emphasis mine:

Often, these strategies rely on install scripts or optionalDependencies with some type of bootloader implementation (ex. esbuild)

In the case of esbuild it should be and: It uses both optionalDependencies and a postinstall script:

evanw/esbuild#1621

  • There is still a small post-install script but it's now optional in that the esbuild package should still function correctly if post-install scripts are disabled (such as with npm --ignore-scripts). This post-install script optimizes the installed package by replacing the esbuild JavaScript command shim with the actual binary executable at install time. This avoids the overhead of launching another node process when using the esbuild command. So keep in mind that installing with --ignore-scripts will result in a slower esbuild command.

https://github.com/evanw/esbuild/blob/049e765d95532055f87b1655196c3dc4505b72cf/lib/npm/node-install.ts#L150-L195

For a tool like esbuild – which can compile a small project in a couple of milliseconds – it’s a bit funny that the JavaScript wrapper around the executable can take more time than the compilation itself. If you only run esbuild once in a while it doesn’t matter super much of course.

But that extra startup time does add up in the Elm ecosystem. Editor plugins run both Elm and elm-format on save, and there you want an as snappy an experience as possible, so shaving 100 ms or so definitely counts.

Many people install elm-format with npm locally in each project (so that every contributor is on the exact same version, which is very important for formatters). So a JavaScript wrapper around the project hurts!

So – is this something that this RFC seeks to improve on too? (Just checking!) In my opinion it would be awesome to somehow achieve maximum performance and --ignore-scripts with no “shenanigans” to pull from package authors!

Copy link

@lydell lydell Feb 3, 2022

Choose a reason for hiding this comment

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

Hmm … like this?

{
  "name": "foo",
  "version": "1.2.3",
  "distributions": [
    {
      "platform": "win32",
      "package": "foo-native-win32-10@1.x",
      "bin": "./bin.js"
    },
    {
      "platform": "linux",
      "arch": "x64",
      "package": "foo-native-linux-x64@2.x",
      "bin": "./foo-native-linux-x64"
    },
    "..."
  ]
}

In other words – one "bin" per distributions object, pointing directly to the executables on Linux and macOS, and to a wrapper on Windows (Node.js based, or CMD based, or whatever you like, or maybe you can point directly to an .exe file even?)

If the above is correct, I’d love to see text about this added to the RFC!

Edit: The "bin" fields in foo points to files in foo-native-linux-x64 and other “sub” packages? 🤔


## Motivation

As we continue progress towards, & focus on, creating reproducible installations/builds, we should actively reduce the **need** for bespoke package distribution implementations & install scripts.

## Detailed Explanation

Introduce a new field called `distributions` which will be utilized by the `npm` CLI to add & then conditionally reify a package in place of the initially named version (if satisfied).

## Rationale and Alternatives

### Optional Dependency Fallback:

The current best practice/work-around is to define all package distributions as `optionalDependencies` & rely on the environment failing to install them - then testing to see which dep successfully installed & use accordingly.

#### Example:
```json
{
"name": "foo",
"optionalDependencies": {
"foo-win": "2.x",
"foo-darwin": "2.x",
"foo-android": "2.x"
}
}
```

#### foo-android `package.json
```json
{
"name": "foo-android",
"os": [
"android"
]
}
```

## Implementation

### Goals:
- Limit the amount of net-new concepts & scope (ie. try to use existing paradigms & building blocks as much as possible)
- Make this feature opt-in (at least initially)
- Implement in a way that allows for graceful degredation (ie. fallbacks/polyfills)

### Overview:
- `distributions` will be an `Array` of objects defining conditions where a different package will be refieid & linked as the original target
- `package-lock.json` will include _all_ distributions alongside the origin package (as it would for `optionalDependencies` today), but **only one** of these will be **reified on disk** ([as a link](#arborist-reify))
- `distributions` **cannot conflict** with one another or have different dependencies within the tree (ex. it is not possible to have one distribution with a peer on `react@15` and another on `react@16`)
- the **initial** conditional fields available will include the existing environment information `npm` already supports (ie. [`platform/os`](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#os), [`arch/cpu`](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#cpu), [`engines`](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#engines))
- `npm` will create the _same_ tree when `distributions` are found; the only thing that will change is that the package matching the conditions is reified in place

#### Example

```json
{
"name": "foo",
"version": "1.2.3",
"main": "index.js",
"scripts": {
"preinstall": "node-gyp rebuild"
},
"distributions": [
{
"engines": {
"node": "10"
},
"platform": "win32",
"package": "foo-native-win32-10@1.x"
Copy link
Contributor

Choose a reason for hiding this comment

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

does this mean that "distributions" only points to additional packages? How will this impact dependency graph analysis tools?

},
{
"platform": "linux",
"arch": "x64",

Choose a reason for hiding this comment

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

Don't forget libc here

"package": "foo-native-linux-x64@2.x"
},
"..."
]
}
```

### `Arborist.buildIdealTree`:

* Place _all_ distribution targets as optional<sup>(or peer?)</sup> of the `foo` package
* Any that cannot be placed are not placed (eg, package not found, cannot be placed for conflicting peer deps, etc.), similar to `optionalDependencies`
* Record status of `idealTree` placed distributions of the main "foo" package, along with their selection criteria
- the `idealTree` **should not** be platform-specific

### `Arborist.reify`:

* Choose one distribution of the main package, and reify that
* **Do not** reify the others, or any of their dependencies (unless they are required to meet another dependency in the tree)
* Reify the main package as a `Link` to the chosen distribution

### For Publisher's:

* Publish the main package with the source, pointing to distribution package specifiers and selection criteria
* Publish pre-built distributions as needed

This is amenable to publishing distributions post-hoc as a CI build. If any fail to build and publish, no matter, they will simply fail to install, and fall back to the main package.

### For Consumer's:
saquibkhan marked this conversation as resolved.
Show resolved Hide resolved

* Add a `foo` as a dependency
* Pre-built distributions will be added to lockfile.
* `require('foo')` will return the appropriate context-specific distribution if one was found successfully, or the original package if it was not.

> **Note:** There should be no chance of an exponential explosion in lockfile size, as we calculate every possible combination of distribution matrices.

## Use Cases

### Polyfills:
While this is most useful for slow and costly binary builds, it is also interesting for providing polyfills for node features.

#### Example:

```json
{
"name": "fs-readdir",
"version": "fs.readdir() guaranteed to have withFileTypes:true support",
"distributions": [
{
"engines": { "node": "<v10.11.0"},
"package": "fs-readdir-polyfill@1"
},
{
"package": "fs-readdir-native@1"
}
Comment on lines +155 to +157
Copy link
Contributor

Choose a reason for hiding this comment

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

what condition applies here?

]
}
```

#### fs-readdir-polyfill `package.json`

```json
{
"name": "fs-readdir-polyfill",
"version": "1.2.3",
"description": "polyfill the fs.readdir withFileTypes using fs.stat"
}
```

#### fs-readdir-native `package.json`

```json
{
"name": "fs-readdir-native",
"version": "1.2.3",
"description": "just export require('fs').readdir"
}
```

> Notably, we could just as easily have the `fs-readdir-polyfill` define `fs-readdir-native` as a distribution when `"engines": {"node": ">=10.11.0" }`

## Prior Art

- Yarn's **Package Variants** Proposal: https://github.com/yarnpkg/berry/issues/2751

## Unresolved Questions and Bikeshedding

- Do we need to feature flag this for `npm@8`? ex. put this feature under a new flag (ex. `--with-distributions`)? Does that limit it's impact/usage?
- Should we outline best practices?
- ex. a best practice we could recommend for maintainers to ensure they're consumers are using `distributions` properly, & to avoid confusion, is to set `engines` value for `npm` & educate maintainers/consumers on `--engines-strict`
saquibkhan marked this conversation as resolved.
Show resolved Hide resolved

```json
{
...
"engines": {
"npm": "^8.4.0"
}
...
}
```