DEV Community

Cover image for Building Single-Executable Node.js Apps (Without the Pain)
Sk
Sk

Posted on

Building Single-Executable Node.js Apps (Without the Pain)

Node.js was never meant to ship like it does currently.

By the end of this article, you’ll have a running command-line executable.

No Node install.

No npm install.

No “works on my machine”.

Just a binary.

cli example

And not just CLI, a real GUI executable, shipped the same way.

tessera.js

Node.js is an unbelievable C++ runtime if you know how to command it properly. Sadly, the tooling story, and the endless CommonJS vs ESM compatibility mess, can be a real pain.

So, in the spirit of bringing better tooling to Node.js, inspired by the most beautiful bundler I’ve ever seen, the Zig bundler

No external tools. The same language you build with is the one doing the bundling.

So I decided to lean into Node’s most underused feature:

Single Executable Applications (SEA).

And then I built tooling that actually makes it usable.

// build.js
import { release } from "sea-builder";

const production = {
  entry: 'app/index.js',
  name: 'myapp',
  platforms: ['linux-x64', 'win-x64'],
  outDir: './dist',
  assets: {
    'README.md': './README.md'
  }
};

release(production)
  .then(res => {
    console.log(res);
  })
  .catch(err => console.log(err));
Enter fullscreen mode Exit fullscreen mode

That’s it. You get an executable.

Why SEA actually matters

  • Your app, runtime, and assets ship as one file
  • Startup is fast (especially with code cache) < - you also have low level utility like snapshots etc.
  • Native modules are predictable
  • The user experience matches real software, not a dev environment

This is how Go ships.

This is how Rust ships.

This is how Zig ships.

Node can do this too, it just never had good tooling around it.

That’s what sea-builder is.


Example 1: Simple Script

Note: You’ll need Node.js 20+

Check your version:

node -v
Enter fullscreen mode Exit fullscreen mode

Start a project:

npm init -y && npm i sea-builder
Enter fullscreen mode Exit fullscreen mode

Folder structure

app/
  index.js
  README.md
cross-build.js
Enter fullscreen mode Exit fullscreen mode

index.js

const { getAsset, isSea } = require('node:sea');

console.log(`Hello, ${process.argv[2] || 'World'}!`);
console.log(`Running from: ${process.execPath}`);
console.log(`Platform: ${process.platform}`);
console.log('Running as SEA!\n');

const readme = getAsset('README.md', 'utf8');
console.log('readme:', readme);

// binary asset (returns ArrayBuffer)
const configBinary = getAsset('README.md');
console.log('Binary size:', configBinary.byteLength, 'bytes');
Enter fullscreen mode Exit fullscreen mode

Put anything you want in README.md. Plain text is fine.


cross-build.js

const { release } =  require("sea-builder");

const production = {
  entry: 'app/index.js',
  name: 'myapp',
  platforms: ['linux-x64', 'win-x64'],
  outDir: './dist',
  assets: {
    'README.md': './README.md'
  }
};

release(production)
  .then(res => {
    console.log(res);
  })
  .catch(err => console.log(err));
Enter fullscreen mode Exit fullscreen mode

Run the build:

node cross-build.js
Enter fullscreen mode Exit fullscreen mode

sea-builder handles downloading the correct Node binary and patching it.

Easy, no?


But let’s do something more fun, a windowed app.


Example 2: tessera.js

tessera.js is a Node.js renderer I built. You can read more about it here How I Built a Graphics Renderer for Node.js, but since this article is about SEA, I’ve prepped a repo for you.

Clone it:

git clone https://github.com/sklyt/tessera-seaexample.git
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

npm i
Enter fullscreen mode Exit fullscreen mode

Run the build:

node .\tesseraexample\build.js
Enter fullscreen mode Exit fullscreen mode

Exe

.\dist\img.exe
Enter fullscreen mode Exit fullscreen mode

The key difference here is that we compile everything down to CommonJS first, because ESM is the enemy of everything good, including SEA.

// inside build.js

await build({
  entryPoints: [entryJs],
  bundle: true,
  platform: 'node',
  target: 'node22', // adjust to your minimum Node target
  format: 'cjs',
  outfile: bundlePath,

  // Native binaries stay external
  external: ['*.node', '*.dll'],

  sourcemap: false,
  logLevel: 'info',

  define: {
    require: 'require'
  }
});
Enter fullscreen mode Exit fullscreen mode

Once the code is bundled to CommonJS, we call sea-builder as usual.

const bundlerOptions = {
  entry: bundlePath, // <-- bundled entry, your bundled script
  name: appName,
  platforms: targets,
  outDir: outDir,
  assets: {
    'win-x64': {
      'prebuilds/win32-x64/renderer.node': join(__dirname, '/prebuilds/win32-x64/renderer.node'),
      'prebuilds/win32-x64/glfw3.dll': join(__dirname, '/prebuilds/win32-x64/glfw3.dll'),
      'prebuilds/win32-x64/raylib.dll': join(__dirname, '/prebuilds/win32-x64/raylib.dll')
    },
    'linux-x64': {
      'prebuilds/linux-x64/renderer.node': join(__dirname, '/prebuilds/linux-x64/renderer.node')
    }
  },

  // optional flags (code cache works only on the current platform not cross compile use ci workflows for that)
  useCodeCache: false, 
  useSnapshot: false
};

console.log('Calling sea() with bundled entry and platform assets...');
await sea(bundlerOptions);

console.log('sea() finished. Outputs are in', path.resolve(outDir));
Enter fullscreen mode Exit fullscreen mode

tessera.js is configured to load assets from an SEA via this helper in img.js:

const getter = (name) => {
  try {
    return getAsset(name); // string or ArrayBuffer
  } catch {
    return null;
  }
};

const { Renderer, FULLSCREEN, RESIZABLE } =
  loadRendererSea({ assetGetterSync: getter });
Enter fullscreen mode Exit fullscreen mode

And that’s it, you now have a sharable executable.

In an upcoming version of sea-builder, the terminal won’t launch alongside GUI executables. Still figuring that out. Worst case, I’ll patch the binary headers directly.

But honestly?

It already works.


Common Build Profiles

module.exports = {
  // Development build - fast iteration
  dev: {
    entry: 'src/index.js',
    name: 'myapp-dev',
    platforms: 'current',
    outDir: './build',
    useCodeCache: true
  },

  // Production build - all platforms
  production: {
    entry: 'src/index.js',
    name: 'myapp',
    platforms: 'all',
    outDir: './dist',
    clean: true,
    assets: {
      'config.json': './config/production.json',
      'README.md': './README.md',
      'LICENSE': './LICENSE'
    }
  },

  // Server build - Linux only
  server: {
    entry: 'src/server.js',
    name: 'myapp-server',
    platforms: ['linux-x64', 'linux-arm64'],
    outDir: './dist/server',
    useCodeCache: true,
    assets: {
      'config.json': './config/server.json'
    }
  },

  // CLI tool - small and portable
  cli: {
    entry: 'src/cli.js',
    name: 'myapp-cli',
    platforms: 'all',
    outDir: './dist/cli'
  }
};
Enter fullscreen mode Exit fullscreen mode

Note: useCodeCache: true only works on the current platform since it’s platform-specific. Use CI to build cached binaries per target.


Profile usage

// build.js
const { sea } = require('sea-builder');
const config = require('./sea.config');

// node build.js production
const profile = process.argv[2] || 'dev';

if (!config[profile]) {
  console.error(`Unknown profile: ${profile}`);
  console.log('Available profiles:', Object.keys(config).join(', '));
  process.exit(1);
}

console.log(`Building with profile: ${profile}`);

sea(config[profile]).then(results => {
  console.log(`\nBuilt ${results.length} executable(s)`);
});
Enter fullscreen mode Exit fullscreen mode

More from me:

Visualizing Evolutionary Algorithms in Node.js

tessera.js repo

Thanks for reading!

Find me here:

Top comments (0)