A third ecosystem: npm¶
The npm lane plans a project from its package-lock.json. Like the Rust
lane, it uses only pydd.core and the root wrapper lib/core/root.nix.
root = pydd.node.npm { project = ./.; };
The result is an ordinary derivation. It holds the project in
lib/node_modules/<name> and its programs in bin. It takes override
over the arguments, overrideAttrs and the install hooks.
The lock is the plan¶
A Python lock says nothing of how to build a package, and a Cargo lock
says nothing of units. An npm lock of version 2 or 3 says almost
everything. Its packages map holds one entry for each path that npm
installs, with the URL, the hash, the platform (os, cpu, libc),
dev, optional, bin and hasInstallScript.
So the planner runs no npm. It reads the lock in the build, and decides three things for the platform of the build:
Which packages install. An optional package whose
os,cpuorlibcdoes not fit is left out, as npm leaves it out. esbuild has one such package for each platform.What each package can
require. Node looks fornode_modules/<name>in the directory of the package, then in each parent directory. The planner walks the lock's paths the same way, and a workspace link resolves to its member.What the result leaves out.
omitis npm's--omit,[ "dev" ]by default.
The host evaluation never reads the lock. Its cost does not grow with the number of packages.
Other locks: pnpm, bun and Yarn v1¶
The planner also reads pnpm-lock.yaml, bun.lock and Yarn v1's
yarn.lock. A reader in pydd.node._readers turns each of them into
npm's map of install paths. The rest of the lane does not change: the
same nodes, the same fetches, the same root. By default the planner reads
the first lock of package-lock.json, pnpm-lock.yaml, bun.lock and
yarn.lock that the project holds. lockfile chooses another.
All three hash a package as npm does: the integrity of its tarball,
which a fixed-output fetch takes as it is.
lock |
install paths |
programs |
platforms |
install scripts |
|---|---|---|---|---|
npm |
in the lock |
in the lock |
in the lock |
in the lock |
bun |
in the lock |
in the lock |
in the lock |
those that |
pnpm 9 to 11 |
a graph; the reader places it |
only "has programs" |
in the lock |
those that |
Yarn v1 |
a graph; the reader places it |
not recorded |
not recorded |
not recorded |
The reader places a graph as npm does. Each version goes to the top
node_modules, unless another version of that name is visible from its
dependent. Then it goes inside the dependent. For every project of
tests/node, each tool's lock gives the layout of npm's lock
(tests/test_readers.py).
A node reads what the lock omits. When a lock does not name a
package's programs, the node reads them from the package's own
package.json, and makes the .bin links at build time.
Install scripts follow the tool. pnpm 10 and bun run the scripts of
the packages that the project allows, and no others. The reader does the
same. pnpm 9 ran every script, so a pnpm 9 project names its scripts, as a
Yarn project does. Yarn v1 runs every script, and its lock does not say which packages
have one. So a Yarn project names them: installScripts = [ "better-sqlite3" ];,
or "pydd": { "installScripts": [...] } in package.json. bun also has a
built-in list of packages that it trusts. pydd does not copy that list.
Yarn v1 records no platform. Every package of esbuild, for each platform, installs for the build. The result omits them as development dependencies, or keeps them all.
Yarn 2 and newer are refused. Their checksum is the hash of Yarn's
own zip of the package, not of the tarball. Nix cannot check a fetch
against it. This is the problem of Go's go.sum (pydd#25).
out reads npm's lock only. In out the host makes every node, in
Nix. Nix reads no YAML, and the host would need each reader a second
time.
One node per package¶
entry |
nodes |
output |
|---|---|---|
a package |
a fixed-output fetch, |
the tarball |
a node, |
the unpacked package |
|
a package with an install script |
the same two |
the package after |
the project |
|
the project, its tree and its programs |
The planner writes the fetches. The Python and Cargo lanes fetch on
the host. A fetch here is a Fixed node from the planner, with the hash
of the lock. Its path is known before it builds (fixed_output_path in
pydd.core), so the next node names the path itself, not a placeholder.
A node holds no dependency. Node resolves a module from its real
path. A package linked from the store looks for its dependencies beside
the store path, and finds none. So a node holds only its own package,
and the root copies each node to the path that the lock gives it. Node
then resolves every require as it would after npm ci, with nested
versions too. The cost is disk space at build time; the store's
deduplication shares the files.
The other layout gives each node links to its dependencies, as pnpm does. npm's trees have cycles, and a derivation cannot depend on itself, so that layout needs one node for each cycle. This layout needs none.
Install scripts¶
A package with hasInstallScript runs preinstall, install and
postinstall in its own node. A package with binding.gyp and no such
script runs node-gyp rebuild, as npm does. The node first copies the
part of the tree that the script can require: every package that the
lock resolves from the package, transitively, with each program in
node_modules/.bin.
The script gets what npm's run-script gives it, without a network:
variable |
value |
|---|---|
|
each |
|
Node of the menu, so node-gyp downloads no headers |
|
|
|
as npm sets them |
tests/node/native-npm builds better-sqlite3. Its script tries
prebuild-install, which refuses, and then node-gyp compiles the SQLite
that the package bundles.
Install scripts that depend on each other in a cycle are refused, with the cycle in the message. npm runs them in an order of its own choice.
Other sources¶
|
source |
|---|---|
|
a fixed-output fetch of the tarball |
|
a fixed-output fetch of the commit's archive, unpacked |
a path in the project, |
the project's own file |
a workspace member, with |
a link in the root, to the member |
A git dependency needs its hash. The lock names the commit and no
hash. The call gives outputHashes."<name>-<version>", the hash of the
unpacked archive, as fetchFromGitHub takes it. The planner's error
names the URL, and nix store prefetch-file --unpack <url> prints the
hash. nixpkgs' prefetch-npm-deps maps the URLs of the three hosts the
same way. A git dependency with a prepare script is refused: npm runs
it with the package's development dependencies, and the lock does not
name those.
overrides.<name>.src replaces a package, with a tarball or a
directory, wherever the lock installs it. Its fetch is not written. This
is how a patched or a prebuilt package goes in, as nixpkgs'
importNpmLock takes packageSourceOverrides. tests/node/git-dep
replaces ms, and fetches isarray from a commit on GitHub.
Native inputs¶
nixpkgs has no table of npm packages and their inputs, as
defaultCrateOverrides is for crates. So the lane has no hints. Two
sources give a package its tools, and the later one wins:
source |
example |
|---|---|
the project |
|
the call |
|
A tool reaches only the install script of its package, the same way as in
the Rust lane (tool_env of pydd.core).
The project¶
The root copies the project, places every node, and links each workspace
member where the lock names it. It runs each script of scripts
([ "build" ]) with npm run --if-present, in the project and in each
workspace member. With a pnpm, bun or Yarn lock it runs them in the
project only, as those tools do. The project builds in the build directory, and not in
$out/lib/node_modules: TypeScript, and api-extractor over it, skip
every file under a node_modules, the project's own included.
The root then removes what omit leaves out, and the directories that
this leaves empty. It links each program of the project and of its
workspace members into bin.
A program's #!/usr/bin/env node becomes the path of Node, as stdenv's
patchShebangs does. The packages' programs get the same.
The modes¶
Each project of tests/node gives one result in five modes:
nix run --file . test-node.
mode |
Nix |
writes through |
|---|---|---|
|
master, |
|
|
master, |
huggorm |
|
master, |
|
|
2.35, recursive-nix |
|
|
2.35, no socket |
the host makes every node; the planner writes the root to |
out: the host makes the graph¶
out exists for npm, and not for Cargo. On released Nix with no
socket, a planner writes one derivation. Every node that its root names
must exist before the planner runs, as an input of it. Only cargo, in a
build, knows Rust's units, so the host cannot make them. The npm lock
names every node, so the host can.
lib/node/graph.nix is the planner's resolution a second time, in Nix:
the platform, Node's walk, the closure of an install script, the .bin
links, and the spec of each node. It must give the .drv that the
planner gives, byte for byte. OutWriter refuses a root that names any
other, so a difference fails the build and does not pass unseen. Three
rules make the two agree:
JSON as
builtins.toJSONwrites it: sorted keys, no spaces, no escaped Unicode. Every list in a spec is sorted.A tool is an input derivation. A string with a package's path carries that derivation in Nix. So the menu gives the planner
refs, the derivation and output of each such path, and a node names the derivation and not the path.A fetch is named by its URL, and not by the first path that needs it, so the order of a walk cannot change a name.
The planner then only chooses the root. The graph is static, as
importNpmLock makes it, and the evaluation reads the lock:
nodemon, 807 lock entries |
evaluator CPU |
function calls |
|---|---|---|
|
1.01 s |
0.27 M |
|
1.31 s |
1.99 M |
The cost of cli does not grow with the lock. The cost of out does.
Whether a lock is the whole plan decides whether an ecosystem has out
at all.
A variable in overrides.<name>.env that names a store path differs
between the two in out: the menu gives the planner the text, without
the derivation.
What is missing¶
A git dependency outside GitHub, GitLab and sourcehut, and one with a
preparescript. The planner and the node refuse them by name.npm testasroot.tests.Yarn 2 and newer, whose checksums Nix cannot check.
outfor pnpm, bun and Yarn.