So there is this really cool library called 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 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.
$ 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:
{
"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:
$ 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:
// 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
$ 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 library. But unfortunately pkg is not supporting ES modules 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. 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.
$ npm install --save-dev esbuild
And let's add a npm script in package.json
to do the compilation:
{
"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:
$ 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.
$ npm install --save-dev pkg
package.json
: (don't forget the comma after the compile script)
{
"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.
$ 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) 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:
$ ./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 Markus Spiske on Unsplash
Top comments (0)