How close are we to being able to create CLI executables with Node.js?
Tags: typescript, node, cli, tooling
Published: 2024-03-20 22:16:51

In recent days, I had the opportunity to revisit and reorganize the tools we use to set up our development environments. When we start a new development project, we always need two things:

- A web server (usually Nginx);
- A proxy to quickly share local or test instances both publicly and internally.

In addition to those two, many other secondary tools are currently all managed through a Node.js CLI tool. Hence my concern: before even being able to run the configuration tools, there is the need to install Node.js.

> "And what if I wanted to get rid of this additional initial step as well?" — No one

In the past, I had already experimented with [pkg](https://github.com/vercel/pkg) (now archived), so I decided to dedicate a day to updating myself on the topic and improving our tools.

## Step 1: Creating the bundle

The first step towards creating an executable is to generate a bundle of the CLI tool. I've delved into the tool I know best: Webpack. 
Given that build efficiency and speed are not variables of our problem, Webpack allows me, with a couple of loaders, to quickly create a single entry point:

> File webpack.config.js

```typescript
...
{
	test: /\.tsx?$/,
	use: "ts-loader",
	exclude: /node_modules/,
},
{
	test: /\.(node)$/,
	loader: "node-loader",
},
...
```

In a few minutes, the bundle task is solved; we add a script to the package.json, and we are ready to proceed.

> File package.json

```json
"scripts" : {
...
	"bundle": "webpack",
...
}
```

## Step 2: Creating a .blob file

The next step, now that we have our cli.js bundle in the dist folder, is to generate a .blob file that we will use to create our executable file. To do this, the first step is to define within a configuration file:

- the entry point of our .blob (i.e., the bundle just created);
- the output where the .blob file will be generate.

> File seaconfig.json

```json
{
	"main": "./dist/cli.js",
	"output": "./dist/cli.blob",
}
```

Once the configuration file is ready, the next step is to create our executable... here all the credit goes to Chad R. Stewart for saving me a lot of work. Here's a link to  [his article](https://dev.to/chad_r_stewart/compile-a-single-executable-from-your-node-app-with-nodejs-20-and-esbuild-210j) where he goes into more detail for each platform.

I will report here only the steps I had to execute on my Ubuntu machine:

- Generate the .blob file:

```bash
node --experimental-sea-config seaconfig.json
```

Prepare the Node.js executable into which we will inject our file in order to prepare the final file.

> Note: Currently, the version of Node.js I'm working with is 21.7.1.

```bash
cp $(command -v node) ./dist/server-tools
```

- Finally, we inject the .blob file into our Node.js executable to complete the task (Depending on the platform, refer to the article linked above):

```bash
npx postject ./dist/server-tools NODE_SEA_BLOB ./dist/cli.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
```

At this point, I was ecstatic to have obtained a perfectly functioning executable of my CLI tool... until...

## Step 3: Managing Static Assets

Testing it, I realized that, being a configuration tool, it used various static files as configuration templates, and those static files were not included inside my executable.

Fortunately, while researching how to work around this problem, I came across two useful resources:

- [Node.js Documentation: Single Executable Applications Assets](https://nodejs.org/api/single-executable-applications.html#assets)
- [Node.js Blog: Release v21.7.0](https://nodejs.org/en/blog/release/v21.7.0)

With just a few modifications to the configuration file, I included the necessary files in the executable:

> File seaconfig.json

```json
{
	"main": "./dist/cli.js",
	"output": "./dist/cli.blob",
	"assets": {
		"default.txt": "./templates/default.txt",
		"default--ssl.txt": "./templates/default--ssl.txt",
		"next-js.txt": "./templates/next-js.txt",
		"next-js--ssl.txt": "./templates/next-js--ssl.txt",
		"patched.txt": "./templates/patched.txt"
	}
}
```

At this point, the last remaining step was to fix the code (in a very raw and quick way) from:

> File addTemplate.ts

```typescript
import { readFileSync } from "fs";
...
const content = readFileSync(resolve(__dirname, "templates", `${template}${ssl ? "--ssl" : ""}.txt`);
```

to:

> File addTemplate.ts

```typescript
import { getAsset } from "node:sea";
import { readFileSync } from "fs";
...
const content = (() => {
	try {
		return readFileSync( resolve(__dirname, "templates", `${template}${ssl ? "--ssl" : ""}.txt`), "utf-8" );
	} catch (error) {
		return getAsset(`${template}${ssl ? "--ssl" : ""}.txt`, "utf-8");
	}
})();
```

Only note: I also had to add a declaration to ensure that TypeScript didn't complain about the missing module:

> File types.d.ts

```typescript
declare module 'node:sea' {
	export function getAsset(filename: string, encoding: string): string;
}
```

## Final thoughts

By the end of the day, I successfully achieved the desired outcome: My executable functions flawlessly on a pristine VPS without the need to install Node.js in advance.