Create a zx Node.js script as binary with pkg
Tags: node, javascript
Published: 2022-03-17 16:04:19

So there is this really cool library called [zx](https://github.com/google/zx) which you can use to create scripts that are replacements for bash scripts.
But one downside of it is, that now you have to have the Node.js runtime installed on the machine where this script should run. That's sad :(

But what if you could create a binary which includes your script AND the Node.js runtime?

**[pkg](https://github.com/vercel/pkg) to the rescue!**

But first things first, let's create a simple zx script. Please make sure that you have Node.js 16+ installed on your machine and then open a shell and type the following commands to create a new directory and initialise a Node.js project.

```shell
$ cd /my/projects
$ mkdir my-cli
$ cd my-cli
$ npm init -y
```

Now you should have a `package.json` file and in this file you have to add "type": "module" for zx to work correctly:

```diff
{
  "name": "my-cli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
+ "type": "module"
}
```

Next we can add zx as a dependency:

```shell
$ npm install zx
```

We are ready to write our nice little script! Create a folder `src` and add a file `index.js` with this content:

```javascript
// src/index.js
import { $ } from "zx";

async function main() {
  await $`date`;
}

main().catch((err) => console.log(err));
```

You can test it now in the shell with

```shell
$ node src/index.js
```

This should output the current date and time on your machine. But as we saw we have to use the `node` runtime to execute our script (or the `zx` runtime as they show in their examples). Because this is sometimes not ideal, for example if the machine doesn't have Node.js or zx installed then our script can not run there.

The solution to this problem is to pack the runtime and our script in an executable binary and then you can start your script even if the machine has no runtime installed.

For the packaging we will use the [pkg](https://github.com/vercel/pkg) library. But unfortunately pkg [is not supporting ES modules](https://github.com/vercel/pkg/issues/1291) which we configured by adding the `"type": "module"` to the `package.json`. So before we can use pkg we need to compile our script to a version which is not using ES modules. To do the compilation we will use [esbuild](https://github.com/evanw/esbuild). esbuild can also bundle our script into one file, so no dependencies on a `node_modules` folder are left in the compiled file. So let's install it.

```shell
$ npm install --save-dev esbuild
```

And let's add a npm script in `package.json` to do the compilation:

```diff
{
  "name": "my-cli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "compile": "esbuild src/index.js --platform=node --target=node16 --bundle --outfile=dist/outfile.cjs"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "dependencies": {
    "zx": "^6.0.1"
  },
  "devDependencies": {
    "esbuild": "^0.14.27"
  }
}
```

This npm script will execute esbuild, use `src/index.js` as an entrypoint and configure esbuild to output a Node.js v16+ compatible file to `dist/outfile.cjs`. The `.cjs` file ending is important because otherwise pkg tries to load our bundle with ES modules even if we have compiled them away.

So now you can try the compiling script:

```shell
$ npm run compile
```

You will see something like this in the shell:

```
> my-cli@1.0.0 compile
> esbuild src/index.js --platform=node --target=node16 --bundle --outfile=dist/outfile.cjs

  dist/outfile.cjs  439.1kb

⚡ Done in 246ms
```

Next up we install the pkg library and also add a npm script to execute it.

```shell
$ npm install --save-dev pkg
```

`package.json`: (don't forget the comma after the compile script)

```diff
{
  "name": "my-cli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "compile": "esbuild src/index.js --platform=node --target=node16 --bundle --outfile=dist/outfile.cjs",
+   "package": "pkg dist/outfile.cjs --targets node16 --output dist/my-cli --debug"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "dependencies": {
    "zx": "^6.0.1"
  },
  "devDependencies": {
    "esbuild": "^0.14.27",
    "pkg": "^5.5.2"
  }
}
```

The `package` script is executing pkg and uses dist/outfile.cjs as entrypoint. Also it configures that we want to have Node.js 16 as a target runtime and it should output a file `dist/my-cli`.

Now if you execute the `package` script you should hopefully see a binary in your `dist` folder.

```shell
$ npm run package
```

This outputs a lot of debugging information which you can ignore for now. But if you have problems you can see there couple of helpful infos to diagnose the issue.

Please keep in mind, that this will output a binary which is only compatible with the same operating system and processor architecture as your developer machine. So if you execute the `npm run package` command on a Windows machine with x64 processor, the binary will not work on a Linux or macOS machine. To work there, you would either need to change the package command to include more targets (please see [the documentation for that](https://github.com/vercel/pkg#targets)) or execute the package command on the same OS/processor architecture.

Now in your file explorer you can already see a file `dist/my-cli` or `dist/my-cli.exe` depending on the OS you are working with. And this file is executable in the shell for example with this call:

```shell
$ ./dist/my-cli
```

**And if everything worked you should see the current date and time 🥳**

**This binary file can now be used without any runtime (as long as you execute it on the same OS/processor architecture) Great!**

_Have fun writing great scripts which are runtime agnostic!_


Photo by <a href="https://unsplash.com/@markusspiske?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Markus Spiske</a> on <a href="https://unsplash.com/s/photos/binary?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>