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
); 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.
As we continue progress towards, & focus on, creating reproducible installations/builds, we should actively reduce the need for bespoke package distribution implementations & install scripts.
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).
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.
{
"name": "foo",
"optionalDependencies": {
"foo-win": "2.x",
"foo-darwin": "2.x",
"foo-android": "2.x"
}
}
{
"name": "foo-android",
"os": [
"android"
]
}
- 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)
distributions
will be anArray
of objects defining conditions where a different package will be refieid & linked as the original targetpackage-lock.json
will include all distributions alongside the origin package (as it would foroptionalDependencies
today), but only one of these will be reified on disk (as a link)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 onreact@15
and another onreact@16
)- the initial conditional fields available will include the existing environment information
npm
already supports (ie.platform/os
,arch/cpu
,engines
) npm
will create the same tree whendistributions
are found; the only thing that will change is that the package matching the conditions is reified in place
{
"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"
},
{
"platform": "linux",
"arch": "x64",
"package": "foo-native-linux-x64@2.x"
},
"..."
]
}
- Place all distribution targets as optional(or peer?) 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
- 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
- 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.
- 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.
While this is most useful for slow and costly binary builds, it is also interesting for providing polyfills for node features.
{
"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"
}
]
}
{
"name": "fs-readdir-polyfill",
"version": "1.2.3",
"description": "polyfill the fs.readdir withFileTypes using fs.stat"
}
{
"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
definefs-readdir-native
as a distribution when"engines": {"node": ">=10.11.0" }
- Yarn's Package Variants Proposal: yarnpkg/berry#2751
- 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 setengines
value fornpm
& educate maintainers/consumers on--engines-strict
- ex. a best practice we could recommend for maintainers to ensure they're consumers are using
{
...
"engines": {
"npm": "^8.4.0"
}
...
}