The Magic Behind npm Scripts

Adam pulls back the curtain to reveal how npm scripts actually work and how they help us write better build tools.

I recently learned some more about what ⁠⁠⁠⁠npm scripts⁠⁠⁠⁠ actually do when they are invoked via npm run. Demystifying and having an understanding of the internals of npm scripts enables me to write better build tools and diagnose build problems much more efficiently.

What does npm run when you run npm run?

So let’s get right to it, npm scripts⁠⁠⁠⁠ actually don’t run node at all, they run ⁠⁠⁠⁠sh⁠⁠⁠⁠. Don’t believe me? Check out the official npm docs on the environment (though to be fair the default test script after npm init is a dead give away).

The fact npm doesn’t run node means that an ⁠⁠⁠⁠npm script⁠⁠⁠⁠ can invoke node with a single file (e.g. node index.js) which calls a function that returns a ⁠⁠⁠⁠Promise⁠⁠⁠⁠ and things will “just work”. Any npm “post-” scripts will wait until the ⁠⁠⁠⁠Promise⁠⁠⁠⁠fied code finishes. There’s no magic there, what’s really happening is that ⁠⁠⁠⁠npm is just waiting for a shell exit code from our task before moving on to the next script lifecycle call.

Let me re-iterate here, npm scripts do not discriminate about the language or type of program that you tell it to run. You could write all of your build tooling in Go or Rust and then have npm run it for you (but that would probably be weird).

Writing Tasks That Return Promises

I put together a stripped down version of the situation I ran into that prompted this entire deep dive.

Here is a test.js file:

const magicBuildStep = () => {
 return new Promise((resolve, reject) => {
   setTimeout(() => {
     console.log('✨waiting✨');
   }, 10000);
 });
};

magicBuildStep();
process.exit(0);

And here is our package.json file:

{
 "scripts": {
   "start": "node test.js",
   "poststart": "echo '

Running npm start will exit immediately because ⁠⁠⁠⁠process.exit(0)⁠⁠⁠⁠ throws an exit code. If I remove the exit code, the ⁠⁠⁠⁠poststart⁠⁠⁠⁠ environment (sh) waits for an exit code which won’t happen until the start task finishes because node will not exit until the Promise is resolved. This is really useful! This means we can write tasks that return Promises and either run them in isolation or properly resolve them and chain them together, either way our npm script will work as intended.

I recently had a node-sass task that typically was used inside a larger process, so the only interface it returned was a Promise. On a recent project I needed to use that task on its own in an npm task, and I didn’t need to change anything, the returned Promise continued to work even though I didn’t have any then or done handlers set up since npm scripts default environment waits for an exit code.

Simple tools

Using npm scripts for our build tooling has been a big win here at Sparkbox. We’ve moved to writing more generic node scripts to handle just about every kind of build task and using npm scripts to invoke them. We don’t need to rely on plugins or limiting build tool APIs to get things done; we have the entire npm ecosystem and all the features of Javascript and node at our disposal. If you haven’t given npm scripts a shot I recommend trying it—even if just for a single task. It’s a rewarding experience that can potentially be reused on future projects.