V1V2
Blog & Projects

Moving NPM scripts to JavaScript

Let's explore an alternative to NPM scripts, using plain JavaScript.

The initial NPM scripts

These are the package.json scripts we're going to work with:

"scripts" : {
  "start": "run-p dev:*",
  "dev:server": "http-server",
  "dev:wds": "webpack-dev-server",
  "clean": "rimraf dist",
  "lint": "eslint src",
  "test": "jest",
  "check-all": "run-s clean lint test",
  "build": "webpack -p",
  "upload": "upload-somewhere",
  "deploy": "run-s check-all build upload"
}

The new NPM scripts

Let's create a run.js file at the root of our project, and update package.json with:

"scripts" : {
  "start": "node run start",
  "dev:server": "node run dev:server",
  "dev:wds": "node run dev:wds",
  "clean": "node run clean",
  "lint": "node run lint",
  "test": "node run test",
  "check-all": "node run check-all",
  "build": "node run build",
  "upload": "node run upload",
  "deploy": "node run deploy"
}

Here, we are simply using the node binary to run the run.js file with the additional argument of the script name.

spawnSync

In order to run a command in run.js, and have its output streamed to the shell in real-time like a normal NPM Script, we need to use the native Node function spawnSync from child_process, with { shell: true, stdio: 'inherit' }. Let's create a run function at the top of our run.js file:

const { spawnSync } = require('child_process')

const run = cmd => spawnSync(cmd, { shell: true, stdio: 'inherit' })

Feel free to add a console.log() for a better experience:

const { spawnSync } = require('child_process')

const run = cmd => {
  console.log(`\x1b[35mRunning\x1b[0m: ${cmd}`)
  return spawnSync(cmd, { shell: true, stdio: 'inherit' })
}

Note: \x1b[35m and \x1b[0m are shell color symbols.

Declaring the scripts

Now, using a plain JavaScript object, we can bring back our scripts:

const scripts = {
  // Dev
  start: 'run-p dev:*',
  'dev:server': 'http-server',
  'dev:wds': 'webpack-dev-server',

  // Code quality
  clean: 'rimraf dist',
  lint: 'eslint src',
  test: 'jest',
  'check-all': 'run-s clean lint test',
  
  // Deployment
  build: 'webpack -p',
  upload: 'upload-somewhere',
  deploy: 'run-s check-all build upload',
}

Note: We can use comments and newlines to organize our scripts. Mind-blowing.

Finally, when this file is executed, we want to launch the script name that corresponds to the second argument of the node command, and exit with the right error code:

process.exitCode = run(scripts[process.argv[2]]).status

Packages and functions

Note that unlike NPM Scripts, we can use any NPM package, like dotenv to load environment variables. We can also construct commands with functions:

require('dotenv/config')

const httpServer = port => `http-server ${port ? `-p ${port}` : ''}`

httpServer(process.env.DEV_PORT) // 'http-server -p [your port from .env]'

Also, in this article, we are only using CLI binaries, but by using JavaScript, we can use programmatic Node APIs too if your packages offer that.

Complete code

And here is the final code of our run.js file:

const { spawnSync } = require('child_process')

const run = cmd => {
  console.log(`\x1b[35mRunning\x1b[0m: ${cmd}`)
  return spawnSync(cmd, { shell: true, stdio: 'inherit' })
}

const scripts = {
  // Dev
  start: 'run-p dev:*',
  'dev:server': 'http-server',
  'dev:wds': 'webpack-dev-server',

  // Code quality
  clean: 'rimraf dist',
  lint: 'eslint src',
  test: 'jest',
  'check-all': 'run-s clean lint test',
  
  // Deployment
  build: 'webpack -p',
  upload: 'upload-somewhere',
  deploy: 'run-s check-all build upload',
}

process.exitCode = run(scripts[process.argv[2]]).status

That's it

I've been using this approach in my last projects and haven't looked back. It is much more feature-rich and in my opinion cleaner than declaring scripts in package.json.

What do you think? Let me know on Reddit or Twitter.