DEV Community

Cover image for Compile a single executable from your Node app with Node.js 20 and ESBuild
Chad R. Stewart
Chad R. Stewart

Posted on

Compile a single executable from your Node app with Node.js 20 and ESBuild

Introduction

Node.js 20 was released very recently. Along with several other features, you can now compile your Node.js project into a single executable that can be run in environments without Node.js installed. It’s important to note that this is still experimental and may not be suitable for use in production.

Node.js has instructions on how to set up these single executables: https://nodejs.org/api/single-executable-applications.html

Unfortunately, when compiling the executable, you will not compile dependencies into your executable. To solve this problem, we will leverage a JavaScript bundler to bundle our dependencies into one file before compiling it into our single executable.

Prerequisites:

  • Node.js 20

Please note: While TypeScript is used in this article, it is not necessary.

Putting Together our Project

First, we need a project that we will build into our executable.

We’ll first define our server.

server.ts

import express from "express";
import https from "https";
import fs from "fs";
import path from "path";

export const app = express();

//Initialize Request Data Type
app.use(express.json({ limit: "10mb" }));

app.get("/", (req, res) => res.send("Hello World!!"));

const port = 3000;
app.listen(port, () => {
    console.log(`Server is live on ${port}`);
});

Enter fullscreen mode Exit fullscreen mode

We define our package.json next:

package.json

{
  "name": "node-executable-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "esbuild server.ts --bundle --platform=node --outfile=server-out.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "esbuild": "0.17.17",
    "express": "^4.18.2"
  },
  "devDependencies": {
    "@types/express": "^4.17.17",
    "@types/node": "^18.15.7",
    "typescript": "^5.0.2"
  }
}
Enter fullscreen mode Exit fullscreen mode

Please note the build script. Esbuild will take our .ts file and bundle it with our dependencies into a single .js file server-out.js. You can actually run this file once it is created using node server-out.js to check if the bundling was done correctly.

We then define our tsconfig.json

tsconfig.json

{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022"],
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}
Enter fullscreen mode Exit fullscreen mode

We now define our sea-config.json file.

This is a configuration file building a blob that can be injected into the single executable application (see Generating single executable preparation blobs for details)

sea-config.json

{
  "main": "server-out.js",
  "output": "sea-prep.blob"
}
Enter fullscreen mode Exit fullscreen mode

Now that we have everything we need, we can begin putting together our single executable.

Creating the Single Binary File

Begin by installing all the dependencies we’ll need by running this command:

npm install
Enter fullscreen mode Exit fullscreen mode

Once npm install is completed, we run the command:

npm run build
Enter fullscreen mode Exit fullscreen mode

This will create our server-out.js which will be our bundled file we will make into an executable.

Note: If you rather, you can follow the instructions from the Node.js guide starting from step 3 as the following steps will be exactly the same, located here: https://nodejs.org/api/single-executable-applications.html

Generate the blob to be injected:

node --experimental-sea-config sea-config.json 
Enter fullscreen mode Exit fullscreen mode

Create a copy of the node executable and name it according to your needs:

cp $(command -v node) server
Enter fullscreen mode Exit fullscreen mode

Note: If you are on a Linux Distro (such as Ubuntu), you can skip the next steps and move straight to running the binary.

Remove the signature of the binary:

On macOS:

codesign --remove-signature server
Enter fullscreen mode Exit fullscreen mode

On Windows (optional):
signtool can be used from the installed Windows SDK. If this step is skipped, ignore any signature-related warning from postject.

signtool remove /s server
Enter fullscreen mode Exit fullscreen mode

Inject the blob into the copied binary by running postject with the following options:

  • server - The name of the copy of the node executable created in step 2.
  • NODE_SEA_BLOB - The name of the resource / note / section in the binary where the contents of the blob will be stored. sea-prep.blob - The name of the blob created in step 1.
  • --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 - The fuse used by the Node.js project to detect if a file has been injected.
  • --macho-segment-name NODE_SEA (only needed on macOS) - The name of the segment in the binary where the contents of the blob will be stored.

To summarize, here is the required command for each platform:

On systems other than macOS:

npx postject server NODE_SEA_BLOB sea-prep.blob \
    --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 
Enter fullscreen mode Exit fullscreen mode

On macOS:

npx postject server NODE_SEA_BLOB sea-prep.blob \
    --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
    --macho-segment-name NODE_SEA 
Enter fullscreen mode Exit fullscreen mode

Sign the binary:

On macOS:

codesign --sign - server 
Enter fullscreen mode Exit fullscreen mode

On Windows (optional):
A certificate needs to be present for this to work. However, the unsigned binary would still be runnable.

signtool sign /fd SHA256 server 
Enter fullscreen mode Exit fullscreen mode

Run the binary:

./server
Enter fullscreen mode Exit fullscreen mode

You should now have a running Node server similar if you just ran node server-out.js

If you wanted to see a completed example, go here: https://github.com/chadstewart/ts-node-executable-article-example

  • If you found this article interesting, please feel free to heart this article!
  • If you’re interested in learning more about Front-End Engineering, follow me here on Dev.to and Twitter
  • If you’re looking for jobs, I’d highly recommend checking out @TechIsHiring on Twitter, LinkedIn or TechIsHiring's website https://www.TechIsHiring.com/ for posted jobs and other resources!
  • Want to check out a curated list of jobs, job seekers and resources from TechIsHiring? Check out TechIsHiring's Newsletter

Top comments (5)

Collapse
 
0vortex profile image
TED Vortex (Teodor Eugen Duțulescu)

Nice well structured read but it is sending users down a rabbit hole that will not get support (potentially ever). SABs are not meant for bundling dependencies, if you want that you can just use npmjs.com/package/pkg - if this is just a demonstration, at least for security purposes, warn people that it will most likely fail in complex scenarios. In your post you mention node modules as a "problem" that can be solved, when it was designed like that due to multiple reasons, one good discussion I can link is github.com/nodejs/single-executabl...

Have you run into any of these limitations bundling a real life application?

Collapse
 
prakis profile image
Kishore • Edited

Great, Can you please share the final single executable app size? (for your example code)

Collapse
 
danneu profile image
danneu

I followed their steps for a simple node cli app I made. the node binary itself was 95MB, my code bundle itself (incl all deps) was 1.5MB, and the final executable was 88MB.

Collapse
 
prakis profile image
Kishore

Thank you, my experience is similar with other applications.

Collapse
 
peatechen profile image
PPChen

thanks share