Making a single-file executable with node and esbuild

Jan 29, 2024

Node has gained the experimental ability to turn a javascript file into a single-file executable by embedding it within a node binary.

However, they have written rather skimpy instructions which leave a lot to the imagination. I've written this document to try to give an example that includes multiple files and a dependency.

(update: I found this document which gives a much clearer picture of how the single-file executable process works)

I've created a GitHub repository llimllib/node-esbuild-executable to demonstrate the topics discussed here.

(All this is on a mac. Instructions vary for your platform, but will be similar)

sum.js

export function sum(ns) { return ns.reduce((x,y) => x+y, 0) }

index.js

import minimist from "minimist";
import { sum } from "./sum.js";

sum(minimist(process.argv.slice(2))._)

We can test that this simple program works to sum the numbers input into it:

$ node index.js 1 2 3 4
10
{ 
  "main": "index.js", 
  "output": "sea-prep.blob"  
}
npx postject sum NODE_SEA_BLOB sea-prep.blob \
    --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
    --macho-segment-name NODE_SEA
$ ./sum 1 2 3 4 5 (node:39271) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension. (Use `sea-example --trace-warnings ...` to show where the warning was created) /private/tmp/test-sea/sea-example:1 import minimist from "minimist"; ^^^^^^ SyntaxError: Cannot use import statement outside a module at internalCompileFunction (node:internal/vm:73:18) at wrapSafe (node:internal/modules/cjs/loader:1175:20) at embedderRunCjs (node:internal/util/embedding:18:27) at node:internal/main/embedding:18:34 Node.js v20.2.0

bundling it all up

There's (at least) two problems with the binary we built:

The docs say:

The single executable application feature currently only supports running a single embedded script using the CommonJS module system.

We can fix both of these problems by using esbuild to bundle up our code with its dependencies, and convert it into a single cjs module that will work correctly in our binary.

npx postject sum NODE_SEA_BLOB sea-prep.blob \
    --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
    --macho-segment-name NODE_SEA

This time, it works!

$ ./sum 1 2 3 4        
10
(node:44573) ExperimentalWarning: Single executable application is an experimental feature and might change at any time
(Use `sum --trace-warnings ...` to show where the warning was created)

# `sum` is an executable binary:
$ file sum
sum: Mach-O 64-bit executable arm64

# that weighs 82 megabytes:
$ ls -alh sum
-rwxr-xr-x@ 1 llimllib  staff    82M Jan 27 16:11 sum*

One thing you'll notice is that it prints a warning after executing the program. To remove the warning, add "disableExperimentalSEAWarning": true to your sea-config.json.

↑ up