The most surprising thing about npm lifecycle hooks is that they’re not just for running scripts after installation; they’re fundamental to how npm manages dependencies and ensures a consistent build environment.
Let’s see them in action. Imagine you have a package, my-package, that needs a specific binary compiled before it can be used. You could put this compilation script directly in your package.json:
{
"name": "my-package",
"version": "1.0.0",
"scripts": {
"preinstall": "echo 'Compiling my-package binary...'",
"postinstall": "echo 'Binary compiled!'"
},
"dependencies": {
"some-dependency": "^1.0.0"
}
}
When you run npm install in a project that depends on my-package, npm doesn’t just download the files. It orchestrates a series of events:
preinstall: npm runs thepreinstallscript defined inmy-package’spackage.json. In our example, this prints "Compiling my-package binary…". This is crucial for tasks that must happen before the rest of the package code is unpacked or linked.install: The actual installation of the package happens here.postinstall: After the package is installed, npm runs thepostinstallscript. Here, it prints "Binary compiled!". This is for actions that need to happen after the package files are in place, like moving compiled binaries, setting up environment variables, or linking native modules.
The lifecycle isn’t limited to just preinstall and postinstall. There’s a whole sequence:
preinstall: Runs beforenpm installis executed.install: Runs by default if nopreinstall,postinstall, orpreparescript is present. If any of those are present,installis not run.postinstall: Runs afternpm installis executed.prepublish: Runs beforenpm publish. (Deprecated in favor ofprepublishOnly)prepare: Runs beforenpm publishand also beforenpm installwhen installing from a git URL or local path. It’s also run fornpm installin the package itself.prepublishOnly: Runs only beforenpm publish. This is the modern replacement forprepublish.prepack: Runs before a tarball is created fornpm packandnpm publish.postpack: Runs after a tarball is created.build: Runs after installation for packages with native modules, typically used to compile them.publish: Runs afternpm publish.postpublish: Runs afternpm publish.test: Runs whennpm testis executed.stop: Runs whennpm stopis executed.restart: Runs whennpm restartis executed (runsstop,restart,start).start: Runs whennpm startis executed.uninstall: Runs whennpm uninstallis executed.postuninstall: Runs afternpm uninstallis executed.
The primary problem lifecycle hooks solve is ensuring that packages are in a usable state after they’ve been installed, especially for packages with complex build requirements or external dependencies. For instance, many packages that use native C++ addons (like node-sass or certain database drivers) will have postinstall scripts that compile these addons based on the user’s operating system and architecture.
Consider this package.json for a hypothetical package needing a compiled binary:
{
"name": "binary-package",
"version": "1.0.0",
"scripts": {
"install": "node scripts/compile-binary.js",
"postinstall": "echo 'Binary compiled and ready!'"
},
"binary": {
"module_path": "bin/",
"host": "https://your-cdn.com",
"remote_path": "./{name}/{version}/{platform}-{arch}/binary.tar.gz"
}
}
When npm install binary-package is run:
- npm sees the
installscript. It will not run the defaultinstallaction. - It executes
node scripts/compile-binary.js. This script might download a pre-compiled binary or compile one from source. - After the script finishes, npm runs the
postinstallscript, printing "Binary compiled and ready!".
The binary field in the package.json is a convention used by some packages (like node-gyp itself) to specify where to find pre-compiled binaries. The postinstall script often leverages this information.
The prepare script is particularly interesting because it runs in more contexts than postinstall. If you’re developing a package locally and npm link it into another project, prepare will run in the linked package’s directory when you npm install in the consuming project. This ensures that any build steps are executed before the linked package is used, providing a more seamless local development experience.
If you’re ever tempted to put complex build logic directly into your main scripts section like build or start, pause. Lifecycle hooks are designed for the installation phase. They are the idiomatic way to handle setup that needs to occur once after dependencies are resolved but before the package is fully integrated into the project.
When you execute npm install --ignore-scripts, you’re telling npm to skip all lifecycle scripts, which can break packages that rely on them for essential setup, particularly for native modules.
This might seem like just running scripts, but it’s a sophisticated dependency management system. The order and conditions under which these scripts run are critical for ensuring that what you install is actually functional.
The next thing you’ll likely encounter is how these hooks interact with different installation sources (like git repositories or tarballs) and how npm ci behaves differently regarding lifecycle scripts compared to npm install.