npm and packages Core
The npm ecosystem in depth
Go beyond npm install: exact semver rules, the dependency tree and lockfile, peer dependencies, lifecycle scripts, publishing and supply-chain defence.
BY THE END OF THIS LESSON YOU CAN
- Predict exactly which versions a range allows, including 0.x versions and prereleases
- Read a lockfile and explain why a package is installed, duplicated or overridden
- Diagnose and fix a peer dependency conflict without --force
- Use lifecycle scripts, script arguments and npm environment variables
- Test a package as a tarball, publish it with the right access and dist-tag, and version prereleases
- Assess npm audit output honestly and defend against typosquatting and dependency confusion
"It worked yesterday"
Monday morning, the shop's automated build fails. Nobody changed any code since Friday. The error comes from deep inside a package nobody on the team has heard of. How can code that nobody touched break?
Because your project is not only your code. You met npm in npm and packages: install, ranges, the lockfile, scripts, workspaces and the basic safety habits. This lesson looks at what happens underneath, because that is where "it worked yesterday" problems come from. Start by installing one popular web framework into an empty project:
mkdir shop-api cd shop-api npm init -y Wrote to ~/shop-api/package.json: … npm install express added 68 packages, and audited 69 packages in 9s 28 packages are looking for funding run `npm fund` for details found 0 vulnerabilities
One command, 68 packages. You chose one; its authors chose the rest, and their authors chose more. Each of those 68 has its own version range, maintainers, release schedule and security history. Every one can change your build.
REASON IT OUT
The build that broke overnight
Before reading on, list every way the build could have changed between Friday and Monday without anyone editing code. Think about:
- What does the build machine run:
npm installornpm ci? Ispackage-lock.jsoncommitted? - What does a range like
^4.4.0allow, and who decides when a new version inside it appears? - What else comes from outside your repository: the Node.js version, the registry itself?
Show the reasoning
- If the lockfile is not committed, or the build runs
npm install, every build resolves ranges again. Any of the 68 packages may have published a new version inside its range over the weekend. Semver says a minor or patch release should not break you, but it is a promise made by people, and people make mistakes. So the first fix is: commit the lockfile, build withnpm ci. - A new version may be malicious, not just buggy: a stolen maintainer account publishing a patch release is a real, repeated attack. The lockfile protects you from this too, until you update.
- The build machine's Node.js version may have changed (a floating "latest" image). The
enginesfield and a pinned version in the build configuration prevent that. - The registry may be down or slow, or a package may have been removed. Builds that cache dependencies survive that.
Each point is a section of this lesson: versions, the lockfile, the tree, and the supply chain.
Semantic versioning, precisely
You know the basics: MAJOR.MINOR.PATCH, ^ accepts new minor versions and ~ only new patches. The full rules have three more parts that matter in real projects.
Prerelease versions
A version can carry a prerelease label after a hyphen: 2.0.0-beta.3, 1.4.0-rc.1. It means "not final yet". The label is split at the dots, and the parts are compared one by one: numbers as numbers, text alphabetically, and a version with a label comes before the same version without one. The official semver command sorts versions when you give it no range:
npx semver 1.0.0 1.0.0-beta.10 1.0.0-beta.2 1.0.0-alpha 1.0.0-rc.1 0.9.9 1.0.0-alpha.1 0.9.9 1.0.0-alpha 1.0.0-alpha.1 1.0.0-beta.2 1.0.0-beta.10 1.0.0-rc.1 1.0.0
Two details trip people up: beta.2 comes before beta.10 (numbers compare as numbers, not as text), and alpha comes before alpha.1 (fewer parts comes first when all shared parts are equal). Here is the same comparison written out, so you can see every rule. A plus sign starts build metadata (1.0.0+build.42), which is ignored when comparing:
function parse(version) {
const [core, pre] = version.split("+")[0].split(/-(.*)/s);
const [major, minor, patch] = core.split(".").map(Number);
return { major, minor, patch, pre: pre ? pre.split(".") : [] };
}
function compareIdentifiers(a, b) {
const aNum = /^\d+$/.test(a);
const bNum = /^\d+$/.test(b);
if (aNum && bNum) return Number(a) - Number(b);
if (aNum) return -1;
if (bNum) return 1;
return a < b ? -1 : a > b ? 1 : 0;
}
function compare(x, y) {
const a = parse(x);
const b = parse(y);
for (const part of ["major", "minor", "patch"]) {
if (a[part] !== b[part]) return a[part] - b[part];
}
if (a.pre.length === 0 || b.pre.length === 0) return b.pre.length - a.pre.length;
for (let i = 0; i < Math.min(a.pre.length, b.pre.length); i++) {
const order = compareIdentifiers(a.pre[i], b.pre[i]);
if (order !== 0) return order;
}
return a.pre.length - b.pre.length;
}
const published = ["1.0.0", "1.0.0-beta.10", "1.0.0-beta.2", "1.0.0-alpha", "1.0.0-rc.1", "0.9.9", "1.0.0-alpha.1"];
console.log(published.toSorted(compare).join(" "));
console.log(compare("1.0.0+build.42", "1.0.0"));
node semver-compare.js and of the browser terminal0.9.9 1.0.0-alpha 1.0.0-alpha.1 1.0.0-beta.2 1.0.0-beta.10 1.0.0-rc.1 1.0.0 0
The order matches the semver command exactly. In compare, the line with b.pre.length - a.pre.length encodes "a release beats its prereleases": if only one version has a label, the one without it is greater.
Prereleases and ranges
You would not want ^1.2.0 to install someone's experimental 1.3.0-beta.1 on your production server. So ranges skip prereleases, unless the range itself mentions a prerelease of the same MAJOR.MINOR.PATCH:
npx semver --range "^1.2.0" 1.2.5 1.3.0-beta.1 2.0.0-rc.1 1.9.9 1.2.5 1.9.9 npx semver --range "^1.3.0-beta.1" 1.3.0-beta.0 1.3.0-beta.2 1.3.0 1.4.0-beta.1 1.3.0-beta.2 1.3.0
Opting into 1.3.0-beta.1 lets you receive later betas of 1.3.0 and the final 1.3.0, but not betas of 1.4.0.
The 0.x rule
Before 1.0.0, a package promises nothing: any release may break. So ^ is stricter there. It allows changes only to the right of the first non-zero number:
npx semver --range "^0.2.3" 0.2.3 0.2.9 0.3.0 1.0.0 0.2.3 0.2.9 npx semver --range "^0.0.3" 0.0.3 0.0.4 0.0.3
Every ^ and ~ is shorthand for a pair of comparisons. Writing the translation yourself makes the rule concrete:
function desugar(range) {
const [major, minor, patch] = range.slice(1).split(".").map(Number);
const low = `>=${major}.${minor}.${patch}`;
if (range[0] === "~") return `${low} <${major}.${minor + 1}.0`;
if (major > 0) return `${low} <${major + 1}.0.0`;
if (minor > 0) return `${low} <0.${minor + 1}.0`;
return `${low} <0.0.${patch + 1}`;
}
for (const range of ["^1.2.3", "~1.2.3", "^0.2.3", "~0.2.3", "^0.0.3"]) {
console.log(range.padEnd(7), "means", desugar(range));
}
node desugar.js and of the browser terminal^1.2.3 means >=1.2.3 <2.0.0 ~1.2.3 means >=1.2.3 <1.3.0 ^0.2.3 means >=0.2.3 <0.3.0 ~0.2.3 means >=0.2.3 <0.3.0 ^0.0.3 means >=0.0.3 <0.0.4
More range forms
You will meet these in other people's package.json files:
| Range | Means |
|---|---|
>=1.2.0 <1.5.0 | Both conditions (space means AND) |
^1.2.0 || ^2.0.0 | Either range (|| means OR); common for peer dependencies that support two majors |
1.2.0 - 1.4.0 | Hyphen range: >=1.2.0 <=1.4.0 |
1.x, 1.*, 1 | Any 1.x.x version, the same as ^1.0.0 |
* or "" | Any version at all. Avoid |
latest, next | Not a range but a dist-tag (see Publishing) |
npx semver --range ">=1.2.0 <1.5.0 || >=2.1.0" 1.1.9 1.4.9 1.5.0 2.0.0 2.1.3 1.4.9 2.1.3 npx semver --range "1.x" 0.9.0 1.0.0 1.9.3 2.0.0 1.0.0 1.9.3
When npm installs a range, it picks the highest published version that satisfies it, with one exception: if the latest dist-tag satisfies the range, npm uses that, so a publisher can hold back a version that is technically higher.
Which range should you write?
- Applications (a server you deploy): keep npm's default
^. The committed lockfile already pins exact versions; the range only matters when you deliberately update. Some teams setsave-exact=truein.npmrcso every update is an explicit change inpackage.json; that works too. - Libraries (packages others install): use
^ranges for dependencies. If your library pinsmsexactly at2.1.2and another library pins2.1.3, users get two copies. Wide ranges let npm share one. - 0.x dependencies: remember that
^0.4.0already means "0.4.x only". Read the changelog on every update.
The dependency tree
npm installs a tree: your dependencies, their dependencies, and so on. Node.js finds an imported package by looking in the nearest node_modules folder, then in the parent folder's node_modules, all the way up. npm uses that rule to save space: it puts packages as high as possible (hoisting) so they can be shared, and nests a copy only when two packages need incompatible versions. npm ls shows the tree, npm explain shows why a package is there:
npm ls ms shop-api@1.0.0 ~/shop-api └─┬ express@5.2.1 ├─┬ debug@4.4.3 │ └── ms@2.1.3 └─┬ send@1.2.1 └── ms@2.1.3 deduped npm explain ms ms@2.1.3 node_modules/ms ms@"^2.1.3" from debug@4.4.3 node_modules/debug debug@"^4.4.3" from body-parser@2.3.0 node_modules/body-parser body-parser@"^2.2.1" from express@5.2.1 node_modules/express express@"^5.2.1" from the root project …
deduped means "this need is met by a copy installed higher up": one ms in node_modules/ms serves both. Now watch what happens when your project also needs an old major version of a package the tree already uses. Some older code in the shop imports debug version 2:
npm install debug@2 added 6 packages, changed 1 package, and audited 75 packages in 2s … npm ls ms shop-api@1.0.0 ~/shop-api ├─┬ debug@2.6.9 │ └── ms@2.0.0 └─┬ express@5.2.1 ├─┬ body-parser@2.3.0 │ └─┬ debug@4.4.3 │ └── ms@2.1.3 deduped ├─┬ debug@4.4.3 │ └── ms@2.1.3 … find node_modules -path "*/debug/package.json" node_modules/body-parser/node_modules/debug/package.json node_modules/debug/package.json node_modules/express/node_modules/debug/package.json node_modules/finalhandler/node_modules/debug/package.json node_modules/router/node_modules/debug/package.json node_modules/send/node_modules/debug/package.json
Your direct dependency takes the top-level slot, node_modules/debug, so version 2.6.9 lives there. Five packages need debug 4, which no longer fits at the top, so each gets its own nested copy: one package added to your package.json, six copies of debug on disk. And debug@2 needs ms@2.0.0, which conflicts with the shared ms@2.1.3, so it is nested too. Duplicates like these cost disk space and download time, and they can cause real bugs: two copies of a library each have their own state, and an instanceof check against one copy's class fails for objects made by the other.
The placement rule is simple enough to simulate. Each package is placed at the top level if that slot is free or already holds the same version; otherwise it is nested inside the package that needs it. Your direct dependencies are placed first:
const requests = [
{ by: "(root)", name: "debug", version: "2.6.9" },
{ by: "(root)", name: "express", version: "5.2.1" },
{ by: "express@5.2.1", name: "debug", version: "4.4.3" },
{ by: "express@5.2.1", name: "send", version: "1.2.1" },
{ by: "send@1.2.1", name: "debug", version: "4.4.3" },
{ by: "debug@4.4.3", name: "ms", version: "2.1.3" },
{ by: "debug@2.6.9", name: "ms", version: "2.0.0" },
];
const top = new Map();
const placedAt = new Map([["(root)", ""]]);
for (const { by, name, version } of requests) {
const id = `${name}@${version}`;
let path;
if (!top.has(name) || top.get(name) === version) {
top.set(name, version);
path = `node_modules/${name}`;
} else {
path = `${placedAt.get(by)}/node_modules/${name}`;
}
if (!placedAt.has(id)) placedAt.set(id, path);
console.log(id.padEnd(14), "for", by.padEnd(14), "->", path);
}
node hoist.js and of the browser terminaldebug@2.6.9 for (root) -> node_modules/debug express@5.2.1 for (root) -> node_modules/express debug@4.4.3 for express@5.2.1 -> node_modules/express/node_modules/debug send@1.2.1 for express@5.2.1 -> node_modules/send debug@4.4.3 for send@1.2.1 -> node_modules/send/node_modules/debug ms@2.1.3 for debug@4.4.3 -> node_modules/ms ms@2.0.0 for debug@2.6.9 -> node_modules/debug/node_modules/ms
This matches the real install: debug@2.6.9 and ms@2.1.3 at the top, every other version nested under the package that asked for it. The order of the requests matters, though. Move the last line to the front and ms@2.0.0 takes the top slot, so every debug@4 would need its own nested ms@2.1.3. Real npm therefore does not place packages naively one by one: it builds the whole tree and prefers placements that need fewer copies. The core rule stays the same: one version per name per folder, and a conflict means a nested copy.
Two tools manage the tree. npm dedupe looks for duplicates that one shared version could replace. overrides in package.json force a version anywhere in the tree, which you need when a deep dependency has a security fix that its parent has not adopted yet:
npm pkg set overrides.ms=2.1.3 npm install removed 1 package, and audited 74 packages in 2s … npm ls ms shop-api@1.0.0 ~/shop-api ├─┬ debug@2.6.9 │ └── ms@2.1.3 overridden …
debug@2 asked for ms@2.0.0 and got 2.1.3. That is a promise you now make on the package's behalf, so test it, and remove the override once the parent package updates. An override can also be scoped to one parent, such as "overrides": { "debug": { "ms": "2.1.3" } }.
Reading the lockfile
The lockfile is the tree written down. Since npm 7 it uses "lockfileVersion": 3: a packages object whose keys are folder paths inside node_modules, and "" for your own project. This is a real lockfile from a small project with one dependency (lodash) and one dev dependency (minimatch, which brings two more packages with it):
{
"name": "invoice-app",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "invoice-app",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"lodash": "^4.17.20"
},
"devDependencies": {
"minimatch": "^3.0.4"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
}
}
}
A lockfile is data, so a program can answer questions about it. This one works out, for each package, whether it is direct or transitive, who needs it, and whether a production install would include it:
import { readFile } from "node:fs/promises";
const lock = JSON.parse(await readFile("package-lock.json", "utf8"));
const root = lock.packages[""];
const direct = new Set([...Object.keys(root.dependencies ?? {}), ...Object.keys(root.devDependencies ?? {})]);
const neededBy = new Map();
for (const [path, info] of Object.entries(lock.packages)) {
const parent = path === "" ? "(your project)" : path.replace("node_modules/", "");
for (const name of Object.keys({ ...info.dependencies, ...info.devDependencies })) {
if (!neededBy.has(name)) neededBy.set(name, []);
neededBy.get(name).push(parent);
}
}
for (const [path, info] of Object.entries(lock.packages)) {
if (path === "") continue;
const name = path.replace("node_modules/", "");
const kind = direct.has(name) ? "direct" : "transitive";
console.log(`${name}@${info.version}`.padEnd(22), kind.padEnd(11), (info.dev ? "dev" : "prod").padEnd(5), "needed by", neededBy.get(name).join(", "));
}
const production = Object.entries(lock.packages).filter(([path, info]) => path !== "" && !info.dev);
console.log("npm ci --omit=dev installs:", production.map(([path]) => path.replace("node_modules/", "")));
node lock-report.jsbalanced-match@1.0.2 transitive dev needed by brace-expansion brace-expansion@1.1.21 transitive dev needed by minimatch concat-map@0.0.1 transitive dev needed by brace-expansion lodash@4.18.1 direct prod needed by (your project) minimatch@3.1.5 direct dev needed by (your project) npm ci --omit=dev installs: [ 'lodash' ]
The dev flag is set on every package that is only reachable through dev dependencies, including transitive ones such as concat-map. That is how npm knows what to skip. The real commands agree:
npm ci added 5 packages, and audited 6 packages in 2s found 0 vulnerabilities npm ci --omit=dev added 1 package, and audited 2 packages in 2s found 0 vulnerabilities ls node_modules lodash
The integrity field is a Subresource Integrity string: the hash algorithm, a dash, and the digest in base64. npm hashes every downloaded tarball and refuses it if the digest differs, using exactly the hashing you learned in Cryptography with node:crypto:
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
const lock = JSON.parse(await readFile("package-lock.json", "utf8"));
const recorded = lock.packages["node_modules/lodash"].integrity;
const [algorithm, digest] = recorded.split(/-(.*)/s);
console.log(algorithm, Buffer.from(digest, "base64").length, "bytes");
function sri(bytes) {
return "sha512-" + createHash("sha512").update(bytes).digest("base64");
}
const tarball = Buffer.from("pretend these are the bytes of lodash-4.18.1.tgz");
const expected = sri(tarball);
const tampered = Buffer.concat([tarball, Buffer.from("\nsteal(process.env)")]);
console.log("original accepted:", sri(tarball) === expected);
console.log("tampered accepted:", sri(tampered) === expected);
node integrity.jssha512 64 bytes original accepted: true tampered accepted: false
So the lockfile protects you in two ways: the version fixes which release is installed, and the integrity fixes which bytes, even if someone manages to replace a file on a registry mirror.
npm install versus npm ci
npm install | npm ci | |
|---|---|---|
| Starts from | Your existing node_modules | Deletes node_modules first |
| Lockfile | Updates it when package.json changed | Never writes it; fails if it does not match package.json |
| Use it | On your computer, when you change dependencies | In CI, Docker builds and deployments |
When two people change dependencies on different branches, package-lock.json gets a merge conflict. Do not edit it by hand: take either side, then run npm install, which rebuilds the lockfile from the merged package.json.
Kinds of dependencies
You know dependencies and devDependencies. Three more fields exist, and one of them causes most confusing install errors.
peerDependencies
Some packages are plugins: they do not work on their own, only together with a host package that the project installs. react-dom renders React components, so it needs React, but it must use the same React as the rest of the app. Two copies of React in one app break it. So react-dom declares React as a peer dependency: "I need this, but my user provides it, in a version I accept":
npm view react-dom@19.3.0 peerDependencies { react: '^19.3.0' } npm install react@18 … npm install react-dom@19 npm error code ERESOLVE npm error ERESOLVE unable to resolve dependency tree npm error npm error While resolving: storefront@1.0.0 npm error Found: react@18.3.1 npm error node_modules/react npm error react@"^18.3.1" from the root project npm error npm error Could not resolve dependency: npm error peer react@"^19.3.0" from react-dom@19.3.0 npm error node_modules/react-dom npm error react-dom@"19" from the root project npm error npm error Fix the upstream dependency conflict, or retry this command with --force or --legacy-peer-deps to accept an incorrect (and potentially broken) dependency resolution. …
Read it from the bottom up: react-dom@19.3.0 needs a peer react@^19.3.0, but the project has react@18.3.1. npm refuses rather than install a combination that the authors say does not work. The fix is to make the versions agree, here by moving both to 19 in one command:
npm install react@19 react-dom@19 added 2 packages, removed 2 packages, changed 1 package, and audited 4 packages in 2s found 0 vulnerabilities npm ls storefront@1.0.0 ~/storefront ├── react-dom@19.3.0 └── react@19.3.0
--force AND --legacy-peer-deps
The error suggests two flags.--legacy-peer-deps ignores peer dependencies entirely, as npm 6 did; --force installs the conflicting versions anyway. Both make the error disappear and leave you with a combination the authors say is broken, which fails later, at run time, in a harder way. Use them only as a temporary step when you know the peer range is simply out of date, and write down why.Peer dependencies are common for plugins of all kinds: ESLint plugins peer-depend on ESLint, and TypeScript tooling on typescript. If you publish a plugin yourself, declare the host as a peer with a range as wide as you actually support ("^8.0.0 || ^9.0.0"), and also as a dev dependency so your own tests have it.
optionalDependencies and bundleDependencies
- optionalDependencies may fail to install without failing the whole install. Tools that ship a fast native program per operating system use them: on Linux, the Windows build simply is not installed.
- bundleDependencies are packed inside your package's tarball instead of downloaded separately. Rare; used when a dependency must not be fetched from the registry.
Scripts in depth
Scripts are more than named commands. npm runs pre<name> before and post<name> after a script automatically, passes extra arguments after -- to the command, and gives every script a set of environment variables. Take a package with these scripts:
{
"name": "@naija-shop/invoice-kit",
"version": "1.0.0",
"type": "module",
"exports": "./index.js",
"scripts": {
"prebuild": "node scripts/clean.js",
"build": "node scripts/build.js",
"postbuild": "node scripts/report.js"
}
}
The build script prints what npm gave it: npm_lifecycle_event is the name of the script being run, and npm_package_name and npm_package_version come from package.json:
const args = process.argv.slice(2);
console.log(`[${process.env.npm_lifecycle_event}] building ${process.env.npm_package_name}@${process.env.npm_package_version}`);
console.log(`[${process.env.npm_lifecycle_event}] extra arguments: ${JSON.stringify(args)}`);
npm run build -- --minify --target=node24 > @naija-shop/invoice-kit@1.0.0 prebuild > node scripts/clean.js [prebuild] removing old dist/ > @naija-shop/invoice-kit@1.0.0 build > node scripts/build.js --minify --target=node24 [build] building @naija-shop/invoice-kit@1.0.0 [build] extra arguments: ["--minify","--target=node24"] > @naija-shop/invoice-kit@1.0.0 postbuild > node scripts/report.js [postbuild] done npm run build --silent [prebuild] removing old dist/ [build] building @naija-shop/invoice-kit@1.0.0 [build] extra arguments: [] [postbuild] done
- Everything after
--goes to the script's command, not to npm. Without the--, npm would treat--minifyas its own option. --silent(or-s) hides npm's>header lines, which matters when a script's output is piped into another program.- If any step fails, the chain stops, and
npm runexits with the failing step's exit code:
npm run build > @naija-shop/invoice-kit@1.0.0 prebuild > node scripts/clean.js [prebuild] cannot remove dist/: permission denied echo $? 1
Some script names are special lifecycle scripts that npm runs by itself: prepare runs after npm install in your own project and before packing, which is where libraries often build; prepublishOnly runs only before npm publish, a good place for "run the tests first". A dependency's preinstall, install and postinstall scripts are the install scripts that npm 11 asks you to approve, as you saw in npm and packages.
TIP
Scripts run in a shell:sh on macOS and Linux, cmd.exe on Windows. A script like rm -rf dist fails on Windows. Write anything beyond a single command as a small Node.js file (node scripts/clean.js, which can call fs.rm), and it works everywhere.Local packages, scopes and registries
Testing a package before you publish it
Workspaces (from npm and packages) link a folder, which is convenient but hides mistakes: a file you forgot in files, or a wrong exports path, still works through a link because the whole folder is there. The honest test installs the tarball, exactly what the registry would receive:
cd invoice-kit npm pack --silent naija-shop-invoice-kit-1.1.0.tgz cd ../checkout npm install ../invoice-kit/naija-shop-invoice-kit-1.1.0.tgz added 1 package, and audited 2 packages in 1s found 0 vulnerabilities node main.js ₦52,500.00 ls node_modules/@naija-shop/invoice-kit index.js package.json README.md
Only the three allowed files arrived: no tests, no .env. package.json now contains "@naija-shop/invoice-kit": "file:../invoice-kit/naija-shop-invoice-kit-1.1.0.tgz". A file: dependency can also point at a folder (npm install ../invoice-kit), which npm installs as a link, like a workspace. npm link does the same across unrelated projects on your computer. Folder links are fine while developing; run the tarball test before every first release.
Scopes and registries
A scope (@naija-shop/) is a namespace owned by an npm user or organisation: nobody else can publish @naija-shop/anything. Scopes can also point to a different registry, for example your company's private one or GitHub Packages. That is configured in .npmrc, in the project or in your home folder:
@naija-shop:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
The token is read from the NPM_TOKEN environment variable at install time, so the file can be committed without the secret. Never commit a .npmrc with a real token in it; that is one of the commonest ways npm tokens leak.
Workspaces at scale
A monorepo is one repository holding several packages and apps. npm can create workspace packages and target commands at them with -w (one workspace) or --workspaces (all of them):
npm init -y -w packages/invoice-kit --scope=@naija-shop Wrote to ~/shop-mono/packages/invoice-kit/package.json: … added 1 package in 690ms npm init -y -w apps/api … added 1 package in 621ms npm install @naija-shop/invoice-kit -w apps/api up to date, audited 5 packages in 516ms found 0 vulnerabilities npm install ms -w apps/api added 1 package, and audited 6 packages in 18s found 0 vulnerabilities npm ls shop-mono@ ~/shop-mono ├── @naija-shop/invoice-kit@1.0.0 -> ./packages/invoice-kit └─┬ api@1.0.0 -> ./apps/api ├── @naija-shop/invoice-kit@1.0.0 deduped -> ./packages/invoice-kit └── ms@2.1.3 npm run test --workspaces --if-present > @naija-shop/invoice-kit@1.0.0 test > node --test ✔ adds up invoice lines (1.3123ms) ℹ tests 1 ℹ suites 0 ℹ pass 1 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 184.467108
npm init -wcreated each folder with its ownpackage.jsonand added it to the root'sworkspaceslist.- Installing
@naija-shop/invoice-kitintoapps/apidownloaded nothing ("up to date"): npm saw a workspace with that name and a matching version and linked it. --if-presentskips workspaces without atestscript instead of failing.- There is one lockfile and one
node_modulesat the root, shared by all workspaces.
Larger monorepos often use pnpm or Yarn instead of npm. pnpm stores each package version once on disk and links it into projects, and it only lets a package import what it declares, which catches missing dependencies that npm's hoisting hides. The ZudoJS repository itself is a pnpm workspace, where internal dependencies are written "workspace:*" so they always resolve to the local folder. The ideas are the same; the commands differ slightly.
Publishing and versioning
Before a first release, a library's package.json needs a few fields beyond name and version:
{
"name": "@naija-shop/invoice-kit",
"version": "1.1.0",
"description": "Invoice helpers for Nigerian shops",
"license": "MIT",
"type": "module",
"exports": "./index.js",
"files": ["index.js"],
"engines": { "node": ">=24" },
"repository": { "type": "git", "url": "git+https://github.com/naija-shop/invoice-kit.git" }
}
- exports is the package's public entry point. Anything not listed cannot be imported by users (
import "@naija-shop/invoice-kit/internal.js"fails), so you can reorganise internals without breaking anyone. It can also map several entry points and give different files toimportandrequire; Module systems in depth goes into that. - engines states which Node.js versions you support. npm warns on install when it does not match.
- repository links the package to its source code; provenance (below) checks it.
The engines field is only a warning at install time, so a server should also check at startup. process.versions.node holds the running version as text:
const required = ">=24";
const minimum = Number(required.replace(">=", ""));
const running = Number(process.versions.node.split(".")[0]);
if (running < minimum) {
console.error(`This server needs Node.js ${required}, but this is ${process.version}.`);
process.exit(1);
}
console.log(`Node.js major version ${running >= minimum ? "is supported" : "is too old"} (needs ${required})`);
node engines-check.jsNode.js major version is supported (needs >=24)
npm version and prereleases
npm version bumps the version in package.json and the lockfile. Inside a Git repository it also commits and creates a tag such as v1.1.0, which you will use in Professional Git:
npm version minor v1.1.0 npm version prerelease --preid beta v1.1.1-beta.0 npm version prerelease v1.1.1-beta.1
Notice that prerelease from 1.1.0 made a beta of 1.1.1. For a beta of the next minor or major, use preminor or premajor (1.2.0-beta.0, 2.0.0-beta.0).
dist-tags and access
A dist-tag is a name pointing at one version. npm install express means "install the version tagged latest". Projects keep other tags for other audiences:
npm view express dist-tags { latest: '5.2.1', 'latest-4': '4.22.3' }
Express 4 still gets fixes, published under latest-4 so that they do not become the default install. When you publish, npm moves latest to your new version unless you pass --tag. A beta published as latest would be what every new user installs, so npm 11 refuses to publish a prerelease without an explicit tag. npm publish --dry-run shows what would happen, without uploading. First at version 1.1.0, then at 1.1.1-beta.1:
npm publish --dry-run npm notice npm notice 📦 @naija-shop/invoice-kit@1.1.0 npm notice Tarball Contents npm notice 75B README.md npm notice 238B index.js npm notice 397B package.json … npm notice Publishing to https://registry.npmjs.org/ with tag latest and default access (dry-run) + @naija-shop/invoice-kit@1.1.0 npm version 1.1.1-beta.1 v1.1.1-beta.1 npm publish --dry-run npm error You must specify a tag using --tag when publishing a prerelease version. … npm publish --dry-run --tag next --access public … npm notice Publishing to https://registry.npmjs.org/ with tag next and public access (dry-run) + @naija-shop/invoice-kit@1.1.1-beta.1
"Default access" for a scoped package means restricted: private, which needs a paid npm account. The first publish of a public scoped package needs --access public (or "publishConfig": { "access": "public" } in package.json).
After publishing
- A published version can never be changed. To fix a bad release, publish a new patch.
npm deprecate @naija-shop/invoice-kit@1.1.0 "Wrong VAT rate, use 1.1.1"shows a warning to everyone who installs that version.npm unpublishis heavily restricted: within 72 hours of publishing only if no other public package depends on the version, and after that only for packages nobody depends on that are barely downloaded. Deleting published code breaks everyone who uses it. In 2016, the removal of a tiny package calledleft-padbroke builds across the internet, and these rules came from that.- Publish from CI rather than from a laptop, with provenance:
npm publish --provenancefrom GitHub Actions (or npm's trusted publishing, which needs no long-lived token at all) attaches a signed statement of which repository and which workflow built the package. Users can check it withnpm audit signatures. - Tools such as Changesets automate the version bumps and changelogs for many packages at once; the ZudoJS packages are released that way.
Supply-chain security
Every dependency is code from strangers that runs with your permissions. Attacks on that supply chain are not hypothetical. Each of these happened:
| Attack | What happened | What defends you |
|---|---|---|
| Typosquatting | crossenv (2017) copied cross-env and stole environment variables | Copy names from official docs; check with npm view |
| Malicious maintainer | event-stream (2018): a new maintainer added code targeting a Bitcoin wallet app | Lockfile; reviewing updates; fewer dependencies |
| Account takeover | ua-parser-js (2021): a stolen account published versions with a password stealer and crypto miner | Lockfile; npm ci; waiting a few days before adopting new releases |
| Dependency confusion | 2021: public packages named like companies' private ones were installed inside those companies | Scoped names you own; a scoped registry in .npmrc |
| Install-script worm | 2025: a worm stole npm tokens through install scripts and published itself into more packages | npm 11 install-script approval; --ignore-scripts in CI |
The registry cleans up after such incidents. The malicious event-stream version is gone, and the typosquat name now belongs to npm itself:
npm view event-stream@3.3.6 npm error code E404 npm error 404 No match found for version 3.3.6 … npm view crossenv crossenv@0.0.2-security | Proprietary | deps: none | versions: 4 security holding package https://github.com/npm/security-holder#readme …
Catching typos before they install
Typosquats are one or two keystrokes away from a real name. The number of single-character edits (insert, delete, replace) that turn one word into another is the edit distance, and it makes a simple, useful check:
function editDistance(a, b) {
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
let diagonal = row[0];
row[0] = i;
for (let j = 1; j <= b.length; j++) {
const above = row[j];
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
diagonal = above;
}
}
return row[b.length];
}
const popular = ["express", "lodash", "cross-env", "react", "dotenv", "axios"];
function check(name) {
if (popular.includes(name)) return `${name}: known package`;
const close = popular.filter((p) => editDistance(name, p) <= 2);
return close.length ? `${name}: suspicious, looks like ${close.join(", ")}` : `${name}: not similar to a popular package`;
}
for (const name of ["express", "expres", "crossenv", "lodahs", "dotenv", "invoice-kit"]) {
console.log(check(name));
}
node typosquat.js and of the browser terminalexpress: known package expres: suspicious, looks like express crossenv: suspicious, looks like cross-env lodahs: suspicious, looks like lodash dotenv: known package invoice-kit: not similar to a popular package
Real tools use the same idea with the thousands of most-downloaded names, and some registries block new names that are too close to popular ones.
npm audit, honestly
npm audit compares your installed versions with the public advisory database. It is useful, and it is also noisy. Here a project has an old lodash as a dependency and an old minimatch as a dev dependency:
npm audit # npm audit report lodash <=4.17.23 Severity: high Command Injection in lodash - https://github.com/advisories/GHSA-35jh-r3h4-6jhm Regular Expression Denial of Service (ReDoS) in lodash - https://github.com/advisories/GHSA-29mw-wpgm-hmr9 lodash vulnerable to Code Injection via `_.template` imports key names - https://github.com/advisories/GHSA-r5fr-rjxr-66jc lodash vulnerable to Prototype Pollution via array path bypass in `_.unset` and `_.omit` - https://github.com/advisories/GHSA-f23m-r3pf-42rh Lodash has Prototype Pollution Vulnerability in `_.unset` and `_.omit` functions - https://github.com/advisories/GHSA-xxjr-mmjv-4gpg fix available via `npm audit fix` node_modules/lodash minimatch <=3.1.3 Severity: high minimatch ReDoS vulnerability - https://github.com/advisories/GHSA-f8q6-p94x-37v3 … fix available via `npm audit fix` node_modules/minimatch 2 high severity vulnerabilities To address all issues, run: npm audit fix npm audit --omit=dev … 1 high severity vulnerability … npm audit fix changed 2 packages, and audited 6 packages in 2s found 0 vulnerabilities npm ls audit-demo@1.0.0 ~/audit-demo ├── lodash@4.18.1 └── minimatch@3.1.5
How to read a report like this:
- Does the vulnerable code run in production?
--omit=devleaves out dev dependencies. A ReDoS in a build tool that only ever reads your own files is far less urgent than one in code that handles requests. - Do you use the affected feature? The lodash advisories concern specific functions such as
_.templateand_.unset. Read the advisory before deciding how urgent it is, but do not skip the update because of this: code changes, and someone may start using that function next month. - Is the fix inside your range? Here it was, so
npm audit fixupdated both packages within their^ranges. When it is not, npm suggestsnpm audit fix --force, which may jump a major version and break your code. Update deliberately instead, or use an override for a transitive dependency. - Exit code.
npm auditexits with 1 when it finds anything, so it can fail a CI job. Many teams fail only on high severity in production dependencies:npm audit --omit=dev --audit-level=high.
That CI rule is easy to state as code. Given the findings of an audit (the fields mirror what npm audit --json reports), decide whether the build should fail:
const levels = ["info", "low", "moderate", "high", "critical"];
const findings = [
{ name: "lodash", severity: "high", dev: false, fixAvailable: true },
{ name: "minimatch", severity: "high", dev: true, fixAvailable: true },
{ name: "tough-cookie", severity: "moderate", dev: false, fixAvailable: false },
];
function gate(findings, { omitDev, auditLevel }) {
const min = levels.indexOf(auditLevel);
const blocking = findings.filter((f) => !(omitDev && f.dev) && levels.indexOf(f.severity) >= min);
return { exitCode: blocking.length > 0 ? 1 : 0, blocking: blocking.map((f) => f.name) };
}
console.log("everything: ", gate(findings, { omitDev: false, auditLevel: "low" }));
console.log("prod, high and above:", gate(findings, { omitDev: true, auditLevel: "high" }));
console.log("after fixing lodash: ", gate(findings.slice(1), { omitDev: true, auditLevel: "high" }));
node audit-gate.js and of the browser terminaleverything: { exitCode: 1, blocking: [ 'lodash', 'minimatch', 'tough-cookie' ] }
prod, high and above: { exitCode: 1, blocking: [ 'lodash' ] }
after fixing lodash: { exitCode: 0, blocking: [] }The moderate finding without a fix still deserves a look, in a regular review rather than a failed build: a CI gate that fails on things nobody can fix teaches the team to ignore it.
Habits that cover the rest
- Commit the lockfile and install with
npm cieverywhere except your own computer. - Prefer fewer dependencies. Ten lines you write and test yourself have no maintainer who can be hacked.
- Before adding a package:
npm viewit, check how long it has existed, its weekly downloads, its repository, and whether it has an install script it does not obviously need. - Give private packages a scope you own, so a public package cannot take their name.
- In CI,
npm ci --ignore-scriptswhen your build does not need install scripts, and keep npm tokens out of jobs that do not publish. - Turn on automated update pull requests (GitHub's Dependabot or Renovate). They update one package at a time, run your tests, and show the changelog, which makes updating a routine instead of a yearly emergency.
Practice
TRY IT YOURSELF
Pick the highest version a range allows
Write maxSatisfying(versions, range) for caret ranges without prereleases, using the desugar idea: a version satisfies ^X.Y.Z when it is at least X.Y.Z and below the upper bound. Try it on ^1.2.0, ^0.2.0 and ^2.0.0.
Show a solution
const parse = (v) => v.split(".").map(Number);
const lessThan = (a, b) => {
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i];
return false;
};
function upperBound([major, minor, patch]) {
if (major > 0) return [major + 1, 0, 0];
if (minor > 0) return [0, minor + 1, 0];
return [0, 0, patch + 1];
}
function maxSatisfying(versions, range) {
const low = parse(range.slice(1));
const high = upperBound(low);
const ok = versions.filter((v) => !lessThan(parse(v), low) && lessThan(parse(v), high));
return ok.sort((a, b) => (lessThan(parse(a), parse(b)) ? -1 : 1)).at(-1) ?? null;
}
const published = ["0.2.1", "0.2.7", "0.3.0", "1.2.0", "1.4.2", "1.10.0", "2.0.0"];
for (const range of ["^1.2.0", "^0.2.0", "^2.0.0", "^3.0.0"]) {
console.log(range, "->", maxSatisfying(published, range));
}
node max-satisfying.js and of the browser terminal^1.2.0 -> 1.10.0 ^0.2.0 -> 0.2.7 ^2.0.0 -> 2.0.0 ^3.0.0 -> null
1.10.0 beats 1.4.2 because versions compare as numbers, part by part. Sorting them as strings would pick 1.4.2, a classic bug. ^3.0.0 has no match, which is when npm reports ETARGET.
TRY IT YOURSELF
Find duplicated packages in a lockfile
Write a program that reads the packages of a lockfile and lists every package name installed in more than one version, with the paths. Test it on this excerpt of the shop-api lockfile after npm install debug@2.
Show a solution
const packages = {
"": {},
"node_modules/debug": { version: "2.6.9" },
"node_modules/debug/node_modules/ms": { version: "2.0.0" },
"node_modules/express/node_modules/debug": { version: "4.4.3" },
"node_modules/send/node_modules/debug": { version: "4.4.3" },
"node_modules/ms": { version: "2.1.3" },
"node_modules/express": { version: "5.2.1" },
};
const byName = new Map();
for (const [path, info] of Object.entries(packages)) {
if (path === "") continue;
const name = path.split("node_modules/").at(-1);
if (!byName.has(name)) byName.set(name, new Map());
const versions = byName.get(name);
if (!versions.has(info.version)) versions.set(info.version, []);
versions.get(info.version).push(path);
}
for (const [name, versions] of byName) {
if (versions.size < 2) continue;
console.log(name);
for (const [version, paths] of versions) console.log(` ${version}: ${paths.join(", ")}`);
}
node duplicates.js and of the browser terminaldebug 2.6.9: node_modules/debug 4.4.3: node_modules/express/node_modules/debug, node_modules/send/node_modules/debug ms 2.0.0: node_modules/debug/node_modules/ms 2.1.3: node_modules/ms
The package name is whatever follows the last node_modules/ in the path, which handles nested copies (and scoped names like @naija-shop/invoice-kit). Two versions of debug are expected here, since the project asked for version 2; the check is useful to spot duplicates nobody asked for.
TRY IT YOURSELF
Diagnose the peer conflict
A teammate ran npm install eslint-plugin-shop@3 and got ERESOLVE with peer eslint@"^9.0.0" from eslint-plugin-shop@3.0.0 and Found: eslint@8.57.0. List the three possible fixes, and say which one you would choose and why.
Show a solution
- Upgrade ESLint to 9 so it satisfies the peer range. This is usually right, but ESLint 9 is a major version, so read its migration notes and run the linter on the whole project.
- Use the previous major of the plugin (
eslint-plugin-shop@2), if its peer range includes ESLint 8. This is the safe short-term choice when you cannot upgrade ESLint yet. --legacy-peer-deps: installs the plugin against a version its authors say it does not support. Only acceptable if you have checked that the plugin really works with ESLint 8 and the range is merely conservative, and then only with a note explaining it.
Check the options with npm view eslint-plugin-shop@2 peerDependencies before deciding. Choose 1 when you have time for the upgrade, 2 when you do not; avoid 3.
Recap
- Prereleases sort before their release and are skipped by normal ranges. Below 1.0.0,
^only allows changes to the right of the first non-zero number. npm installs the highest match, orlatestif it matches. - npm hoists packages to share them and nests copies when versions conflict.
npm ls,npm explain,npm dedupeandoverridesinspect and shape the tree. - The lockfile pins versions and integrity hashes;
devflags drivenpm ci --omit=dev. Build withnpm ci; fix lockfile conflicts by re-runningnpm install. - Peer dependencies say "my user provides this". Fix
ERESOLVEby making versions agree, not with--force. - Scripts get
pre/posthooks, arguments after--andnpm_*variables. Test packages as tarballs, publish scoped packages with--access public, betas with--tag next, from CI with provenance. - Read
npm auditwith judgement: production or dev, affected feature, fix in range. Defend against typosquats, takeovers and dependency confusion with lockfiles, scopes, fewer dependencies and careful names.
Next, An HTTP server with no framework builds a server with no dependencies at all.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.