scripts.js

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

The 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"
            },
            "devDependencies": {
              "npm-run-all": "4.1.5"
            }
            

Note: For those who are not familiar with npm-run-all, it's a package that lets you run NPM Scripts in series or parallel with run-s and run-p.

Here our start script launches 2 servers in parallel, the check-all script cleans, lints and tests in series, and the deploy script runs check-all, builds with Webpack, and uploads our app somewhere, in series. In this scenario, these 3 scripts are the actual interface we want the developers of our app to use, the others are subtasks.

Our new package.json

Let's create a scripts.js file at the root of our project, and change package.json:


            "scripts" : {
              "start": "node scripts start",
              "check-all": "node scripts check-all",
              "deploy": "node scripts deploy"
            }
            

Here, we are simply using the node binary to run the scripts.js file with the additional argument of the script name. If you prefer the singular you can name your file script.js and use node script foo, or put your file in an src folder and use node src/scripts for instance. You can also use TypeScript or Babel for this file, just replace node, by ts-node or babel-node, and install the dependencies needed.

spawn

In order to run a command, and have its output streamed to the shell in real-time like a normal NPM Script, we need to use the native Node functions spawn and spawnSync from child_process, with the options { shell: true, stdio: 'inherit' }. In order to make that experience a bit more user-friendly, let's create 2 functions, runSync and runAsync, at the top of our scripts.js file:


            const { spawn, spawnSync } = require('child_process')

            const spawnOptions = { shell: true, stdio: 'inherit' }
            const runSync = command => spawnSync(command, spawnOptions)
            const runAsync = command => spawn(command, spawnOptions)
            

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


            const { spawn, spawnSync } = require('child_process')

            const spawnOptions = { shell: true, stdio: 'inherit' }
            const print = command => console.log(`\x1b[35mRunning\x1b[0m: ${command}`)
            const runSync = command => print(command) || spawnSync(command, spawnOptions)
            const runAsync = command => print(command) || spawn(command, spawnOptions)
            

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

Declaring the scripts

Now, using plain JavaScript variables and functions, we can declare our scripts:


            // First, create the plain commands

            const HTTP_SERVER = 'http-server'
            const WEBPACK_DEV_SERVER = 'webpack-dev-server'

            const CLEAN = 'rimraf dist'
            const LINT = 'eslint src'
            const TEST = 'jest'

            const BUILD = 'webpack -p'
            const UPLOAD = 'upload-somewhere'

            // Then create some sequences

            const checkAll = () => {
              runSync(CLEAN)
              runSync(LINT)
              runSync(TEST)
            }

            // And finally declare your scripts entrypoints

            const scripts = {
              start: () => {
                runAsync(HTTP_SERVER)
                runAsync(WEBPACK_DEV_SERVER)
              },
              deploy: () => {
                checkAll()
                runSync(BUILD)
                runSync(UPLOAD)
              },
              'check-all': checkAll,
            }
            

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:


            scripts[process.argv[2]]()
            

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 vanilla code

Here is some boilerplate for you to use. Note that I have prefixed all commands by 'echo' so you can test easily.


            const { spawn, spawnSync } = require('child_process')

            const spawnOptions = { shell: true, stdio: 'inherit' }
            const print = command => console.log(`\x1b[35mRunning\x1b[0m: ${command}`)
            const runSync = command => print(command) || spawnSync(command, spawnOptions)
            const runAsync = command => print(command) || spawn(command, spawnOptions)

            const HTTP_SERVER = 'echo http-server'
            const WEBPACK_DEV_SERVER = 'echo webpack-dev-server'

            const CLEAN = 'echo rimraf dist'
            const LINT = 'echo eslint src'
            const TEST = 'echo jest'

            const BUILD = 'echo webpack -p'
            const UPLOAD = 'echo upload-somewhere'

            const checkAll = () => {
              runSync(CLEAN)
              runSync(LINT)
              runSync(TEST)
            }

            const scripts = {
              start: () => {
                runAsync(HTTP_SERVER)
                runAsync(WEBPACK_DEV_SERVER)
              },
              deploy: () => {
                checkAll()
                runSync(BUILD)
                runSync(UPLOAD)
              },
              'check-all': checkAll,
            }

            scripts[process.argv[2]]()
            

Reduce the boilerplate with @sharyn/scripts

Now that you know how to do it yourself, how about externalizing that boilerplate code to a library? That's why I created the package @sharyn/scripts. Sharyn is my library of utilities and helpers, feel free to check it out. This is what we have now:


            const { runSync, runAsync, scripts } = require('@sharyn/scripts')

            const HTTP_SERVER = 'echo http-server'
            const WEBPACK_DEV_SERVER = 'echo webpack-dev-server'

            const CLEAN = 'echo rimraf dist'
            const LINT = 'echo eslint src'
            const TEST = 'echo jest'

            const BUILD = 'echo webpack -p'
            const UPLOAD = 'echo upload-somewhere'

            const checkAll = () => {
              runSync(CLEAN)
              runSync(LINT)
              runSync(TEST)
            }

            scripts({
              start: () => {
                runAsync(HTTP_SERVER)
                runAsync(WEBPACK_DEV_SERVER)
              },
              deploy: () => {
                checkAll()
                runSync(BUILD)
                runSync(UPLOAD)
              },
              'check-all': checkAll,
            })
            

Better, isn't it? You might also like that @sharyn/scripts's runAsync uses a Promise instead of a callback, so you can use async / await with it.

Options of runSync and runAsync

@sharyn/scripts also lets you pass options to spawn. For instance spawn can be executed with a custom env instead of the process.env of the parent:


            spawn('my-command', { env: { foo: 'foo' } })
            

But that replaces the whole process.env. If you simply want to add something to process.env you have to do this:


            spawn('my-command', { env: { ...process.env, foo: 'foo' } })
            

That gets a bit heavy on the eyes. That's why @sharyn/scripts gives you a shortcut with extraEnv:


            runSync('my-command', { extraEnv: { foo: 'foo' } })
            

There are a few more bits, like being able to silent the console.log or pass the command (or cmd) in the options object instead of the string argument:


            runSync({ cmd: 'my-command', silent: true, extraEnv: { foo: 'foo' } })
            

This syntax can help building commands more programmatically.

series and parallel

There is also a series and a parallel function to simplify our code even more:


            const { scripts, series, parallel } = require('@sharyn/scripts')

            const HTTP_SERVER = 'echo http-server'
            const WEBPACK_DEV_SERVER = 'echo webpack-dev-server'

            const CLEAN = 'echo rimraf dist'
            const LINT = 'echo eslint src'
            const TEST = 'echo jest'

            const BUILD = 'echo webpack -p'
            const UPLOAD = 'echo upload-somewhere'

            const checkAll = [CLEAN, LINT, TEST]

            scripts({
              start: () => parallel(HTTP_SERVER, WEBPACK_DEV_SERVER),
              deploy: () => series(checkAll, BUILD, UPLOAD),
              'check-all': () => series(checkAll),
            })
            

Note: The arguments of series and parallel are deeply flattened, so you can easily mix commands and arrays of commands.

Mixing series and parallel tasks

parallel returns a Promise.all, so you can mix parallel and series like this:


            const mixedScript = async () => {
              series(CLEAN)
              await parallel(BUILD_JS, BUILD_CSS)
              series(UPLOAD)
            }
            

Equivalent with Gulp

Gulp is a build system and task manager. If we are already using Webpack, we don't need the build part, but we can still take advantage of its tasks capabilities. In particular, its composable series and parallel functions are a good alternative to the ones in @sharyn/scripts. Let's see how we can modify our setup to use Gulp.

package.json and node_modules/.bin

You probably noticed that in this article, we are directly using local binaries like webpack or rimraf. This is coming from the fact that we are using scripts of package.json as entrypoints for our scripts. Yarn and NPM automatically resolve the node_modules/.bin/ folder for NPM Scripts, even for child processes started with spawn. So if we want to keep this nice feature, we need to call Gulp from scripts of package.json instead of using its global CLI.


              "scripts": {
                "start": "gulp start",
                "deploy": "gulp deploy",
                "check-all": "gulp check-all"
              },
              "devDependencies": {
                "@sharyn/scripts": "1.4.2",
                "gulp": "4.0.2",
                "gulp-cli": "2.2.0"
              }
            

We also need to rename our scripts.js file into gulpfile.js or one of the other supported names. Let's update its content with the following:


            const { runAsync } = require('@sharyn/scripts')
            const { series, parallel } = require('gulp')

            const httpServer = () => runAsync('echo http-server')
            const webpackDevServer = () => runAsync('echo webpack-dev-server')

            const clean = () => runAsync('echo rimraf dist')
            const lint = () => runAsync('echo eslint src')
            const test = () => runAsync('echo jest')

            const build = () => runAsync('echo webpack -p')
            const upload = () => runAsync('echo upload-somewhere')

            const checkAll = series(clean, lint, test)

            module.exports = {
              start: parallel(httpServer, webpackDevServer),
              deploy: series(checkAll, build, upload),
              'check-all': checkAll,
            }
            

Gulp tasks must be asynchronous functions, and unfortunately it is not possible to get rid of the extra () =>, since Gulp uses the name of the declared function in its logs. So using something like const cmd = (...args) => () => runAsync(...args) will show anonymous everywhere in the logs.

The equivalent of my example above regarding mixing series and parallel is very elegant and looks like this:


            const mixedScript = series(CLEAN, parallel(BUILD_JS, BUILD_CSS), UPLOAD)
            

That is a clear winner for this syntax, but the declaration of our commands is more verbose. So that's up to your personal preference.

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.

Check out @sharyn/scripts to get started and take a look at Sharyn too.

Posted on September 4th, 2019

Back to my site