Npm Q&A
Npm is a package manager for JavaScript projects. What this means is that it enables easy installation of packages (published to the public registry, https://www.npmjs.com, or private registries) while also installing nested dependencies between packages. I received a bunch of questions about npm, and here are the answers! Note that I haven't looked at implementation details or any specs on how npm works, these answers are merely based on my observations after nearly a decade of using npm.
Questions
Why is there a .package-lock.json file in node_modules?
- I'd guess that it's to keep track of exactly the
package-lock.jsonfile that generated the current installations. But idk for sure, and that file being there has never mattered to me.
How does npm handle a mono-repo?
- In a mono-repo, you define a
"workspaces"field in the root-levelpackage.json. Typically it's just["./packages/*"],. Each matched directory with apackage.jsonfile then turns in a "workspace", or a sub-package. For installation purposes (npm i), these workspaces are treated just like normal npm dependencies: they and all their dependencies get installed as part of the mono-repo'snpm icommand.
Why did you make mono-vir if npm handles mono-repos already?
- npm allows running npm scripts in each workspace with the
--workspacesflag (likenpm test --workspaces) but they are not run in dependency order. mono-virhandles running scripts within workspaces in the order of their dependency graph. For example, if you havebackendandfrontendworkspaces that depend on acommonworkspace, thecommonworkspace will be executed first when run inside ofmono-vir. This is critical for some workflows, like compiling TypeScript to JavaScript. (mono-vir for-each npm run compile)mono-viralso allows running scripts within workspaces in parallel. If the order of execution doesn't matter, this can be used to easily run tests in all packages all in parallel (mono-vir for-each-async npm test).
Why are some dependencies installed into a mono-repo's root node_modules and others are stored in individual workspace node_modules?
- This likely has to do with version mismatches. If mono-repo workspace
Adepends on TypeScript v3 but a different mono-repo workspaceBdepends on TypeScript v4, a shared top-level dependency can't satisfy both requirements. TypeScript in this case will be installed inside a workspacenode_modulesfor at least one of the workspaces.
If two mono-repo workspaces depend on the same package with the same version, will that package be downloaded twice?
- No.
- It is possible to get into a state where it could be downloaded twice. Like if they original were different versions but then changed to be the same version, the dependency might be duplicated in different workspace
node_modulesfolders (I've noticed behavior like this). But with a clean install (delete allnode_modulesfolders), there won't be duplicates like that.
Why are some folders in node_modules prefixed with an @?
- Because those are part of the package names.
- Packages can be scoped within a specific user or "organization" in npm. The
@signifies this. - Examples:
virmatoris not scoped within a user or organization.@virmator/lintis scoped within the organizationvirmator.
What is node_modules/.bin?
- This is populated by packages that you've installed that have self-defined "bin" scripts.
- These scripts are intended to be run directly in the CLI, just like you use
cd,git, orgrep. These are all appended to PATH whenever you enter an npm context, such as by usingnpxornpm runwith apackage.jsonscript. - Any globally installed packages (which I usually discourage doing) will be populated into your entire shell PATH. Those won't need
npxornpm run.
What about other node_modules/.* folders? Like node_modules/.cache or node_modules/.prisma?
- There are populated by packages themselves.
node_modulesis just a folder, so anything can save anything in there. - Prisma uses
node_modules/.prismaas a place to dump its generated JS client. - I've seen various packages stick outputs into
node_modules/.cache, like Prettier. - One interesting thing that I've noticed is that
node_modules/.*folders are not automatically overwritten when runningnpm i, so it's a good place to dump generated outputs.
What happens if you depend on different versions of a package?
- A single package can't directly depend on different versions of the same package.
- It is, however, common for nested dependencies to depend on incompatible versions of the same package. In this case, a separate
node_modulesfolder is created within the dependency innode_modules. - For example, if dependency
Adepends on packageCv3 and dependencyBdepends on packageCv5, the generated folder structure might look like this:node_modules/
A/
node_modules/
C/
package.json
B/
node_modules/
C/
package.json
Why do I frequently run into issues on Windows with sometimes needing npx and sometimes not needing npx?
- Bugs. Something to do with how sub-shells are launched that causes them to not inherit the parent's npx context. Probably because Windows is less tested by developers.
- It could also be related to interactions between Git Bash, WSL, cmd, PowerShell, etc.
- I don't use Windows, so I have no personal experience debugging this. Maybe there's a config somewhere that would fix this permanently.
In package.json, what do the following fields mean?
version: the version that the respective package was downloaded as (forpackage.jsons withinnode_modules) or the version that you're about to push for your own package (for a rootpackage.json).license: the license name that the package has licensed itself with.dependencies: the packages that this package depends on. These will be downloaded throughnpm i.peerDependencies: this is the same asdependenciesexcept that it is meant for listing compatibility with other packages that this package is meant to be used with. It is typically more lenient.- For example, if you're building a Prettier plugin, you would want to list the Prettier package as a peer dependency because your plugin won't be using Prettier directly but will actually be used by Prettier. This field is then used to list the versions of Prettier that your plugin is compatible with.
engines: the runtime engines that your package is compatible with. For example, you could list a set of Node.js versions here.bin: this lists the entry point(s) for your package as a CLI script.- all
package.jsonfields are explained in the docs: https://docs.npmjs.com/cli/configuring-npm/package-json
What about fields in package-lock.json?
- Read the docs here: https://docs.npmjs.com/cli/configuring-npm/package-lock-json
How do you determine whether an npm package should or should not be used in a given app?
Here are the steps:
- read the package's documentation
- look at what export fields the package's
package.jsondefines:"main": this is meant for CJS exports but can also be used for ESM exports"module": this is meant for ESM export
- look at the source code itself to determine which module format it's using
- any use of
requiremeans it's CJS - any use of top level
import(not the dynamic import functionimport()) means it's ESM
- any use of
- determine if your app's build process supports the module system that the package uses
- For example, without an expensive conversion process, frontend builds only support ESM.
- check if the package is using any built-in Node.js modules (like
node:path).- These won't work in a browser unless you have polyfills for them (which I discourage).
This will usually be good enough. In the end, though, the best way to check is to simply try importing and running the package in your app see if it works!
How do you publish package to npm?
- Create a
package.jsonwithin a given directory - Populate the
"name"and"version"field with valid values - run
npm publish
That's all you need! There's no requirement for code or directory structure, beyond the required package.json file.
To ship something with exports, you'll need to also populate the "module" and/or "main" package.json fields.
My personal flow, which has been heavily abstracted, goes as follows:
- Create a new repo in GitHub.
- Clone the repo locally.
- Run
npm init -yin that new repo. - Run
npm i -D virmatorandnpx virmator init - Write code and commit it.
- Run
virmator publish npm run test:all- this command does a bunch of stuff, including:
- automatic version incrementing
- running the provided command (
npm run test:allin this case) npm publishgit push
- this command does a bunch of stuff, including:
Steps 1-4 are only needed for new packages.
How do you publish a CLI command package?
- create an entry point file (I usually name mine
cli.ts) - make sure to include shebang at the top of the entry point
#!/usr/bin/env -S npx tsxfor TypeScript#!/usr/bin/env bashfor Bash#!/usr/bin/env nodefor raw (or compiled) JavaScript
- set
"bin"in yourpackage.jsonto point to the entry point file - publish your package as usual
What is package-lock.json?
package.jsondependencies frequently use version prefixes (like^or~), which I highly recommend using. This can result in different versions installed than the explicitly listed version in yourpackage.jsonfile.package-lock.jsonkeeps track of the exact version you installed so you can have reproducible builds in testing, staging, production, etc.
What do the different version prefixes mean?
^: version#.*.*, so any minor or patch version upgrades are included. (Note that major version0is special cased.)~: version#.#.*, or version#.*if the patch number is not included. (Note that major version0is special cased.)- For more prefixes, read https://github.com/npm/node-semver?tab=readme-ov-file#advanced-range-syntax
^is the npm default- you can change the default with
npm config set save-prefix, likenpm config set save-prefix='~'.
What is ncu?
ncuis the CLI command for thenpm-check-updatespackage. It is used to update yourpackage.jsondependency versions to match the latest.- It's an easy script for bulk updating dependencies.
Can you have nested workspaces?
- Meaning, can you have a
workspacesfield in apackage.jsonthat is itself a workspace (aka nested within a mono-repo)? - No. From personal testing, npm simply ignores the nested
workspacesfield.
What's the difference between npm ci and npm i?
npm i: (ornpm install)- installs all dependencies
- installs the latest version for each dependency that matches the used prefix (if any)
- updates
package-lock.json - can be used to add individual dependencies (like
npm i @augment-vir/common)
npm ci:- removes
node_modules - installs all dependencies exactly as defined within
package-lock.json(and does not updatepackage-lock.json) - cannot be used to add a new individual dependency
- should only be used (never
npm i) in automated or deploy environments - is good to use in local dev to make sure your dependencies are up to date if you don't want to update
package-lock.json
- removes
- Full docs: https://docs.npmjs.com/cli/commands/npm-ci