Skip to content

SEA builder — single-file Node binaries

Package: @celestial/sea-builder (packages/sea-builder/) Pipeline: build-sea-binary in starflow.yaml ADR: 051 — scribe architecture (first consumer)

SEA stands for Single Executable Application — a Node.js feature that lets you bake your code into a copy of the node binary so the result is one self-contained file with no npm install step at the target host. @celestial/sea-builder is the recipe wrapper around the Node SEA toolchain. You give it a CJS entry point; it gives you back a 70–130 MB binary that runs anywhere the build platform’s Node binary runs.

Reach for a SEA binary when:

  • The target host can’t (or shouldn’t) run a pnpm install — single- binary drops, customer-facing CLIs, daemons on minimal VMs.
  • You want the process to show up in ps/top as your tool’s name (scribe), not node /path/to/cli.js.
  • You need Node-version pinning to be automatic — the bundled Node travels with the binary.

Do not use it when:

  • You have native modules that need per-host npm rebuild — those must ship alongside the SEA as separate .node files (or be inlined, which is rarely viable).
  • You need a single artifact that runs on multiple platforms. SEA builds are current-platform-only; cross-compile is not supported today.
  • Your code is ESM-only and you can’t tolerate the esbuild → CJS bundle step.
your CJS entrypoint
▼ esbuild (must be CJS — SEAs don't load ESM yet)
bundle.cjs
▼ node --experimental-sea-config sea-config.json
sea-prep.blob
▼ cp $(which node) ./out/<name>
▼ codesign --remove-signature (macOS only)
▼ postject ./out/<name> NODE_SEA_BLOB sea-prep.blob
▼ codesign --sign - --force (macOS ad-hoc re-sign)
▼ ./out/<name> --version (smoke)
./out/<name> — 70–130 MB single file

Each arrow corresponds to one step inside buildSea() in packages/sea-builder/src/index.ts. The CLI wrapper at packages/sea-builder/src/cli.ts exposes the same recipe as sea-build --entry=… --out=… [--no-sign] --json.

StepWhy
esbuild bundle to CJS--experimental-sea-config doesn’t read ESM today
sea-config.jsonTells node what to embed and how to expose require()
node --experimental-sea-configProduces the binary blob that gets injected
Copy $(which node)The SEA host is just a regular node binary, plus a resource section
Strip + re-sign on macOSPostject mutates the binary; macOS rejects unsigned ad-hoc binaries
postjectSplices the blob into the NODE_SEA_BLOB resource section
Smoke --versionCatches “the bundle loads but can’t reach its entry” before you scp it
  • CJS only. Until the Node SEA loader supports ESM, your entry point must compile to a single CommonJS bundle. Most TS projects already emit CJS or can target it via esbuild.
  • Current-platform-only. The cp $(which node) step means the output matches the build host’s OS and architecture. A macOS-arm64 CI box can’t produce a linux-x64 SEA. Use a matching CI runner per platform.
  • Native modules. better-sqlite3, tree-sitter, anything else with .node files can’t be bundled by esbuild. Either ship them alongside as files (the SEA’s require() will resolve them relative to the binary), drop the dep, or pre-npm rebuild and bundle the prebuilt .node next to the binary.
  • Size. Carrying Node inflates output 70–130 MB. Fine for daemons and infrastructure CLIs; not fine for browser bundles.
  • Updates need a redeploy. A SEA doesn’t auto-update. Pair it with a check-and-update pattern (see the deployment topology runbook) or a self-update subcommand.

Two surfaces ship with @celestial/sea-builder:

import { buildSea } from "@celestial/sea-builder";
await buildSea({
packageName: "@celestial/scribe",
entryPath: "packages/scribe/dist/cli.js", // must already be CJS
outputName: "scribe",
outputDir: "./dist-sea",
sign: process.platform === "darwin", // ignored on linux
});

Useful for ad-hoc local builds or one-off release jobs.

2. Wire it into the build-sea-binary pipeline

Section titled “2. Wire it into the build-sea-binary pipeline”

The Starflow pipeline build-sea-binary runs the recipe in a worker and uploads the result. Trigger it manually or wire it after a product’s release tag. It takes three inputs:

build-sea-binary:
with:
package_name: "@celestial/scribe"
entry_path: "packages/scribe/dist/cli.js"
output_name: "scribe"

The pipeline orchestrates:

  1. ensure-sea-builder — rebuild @celestial/sea-builder if its dist/ is missing
  2. build-target-packagepnpm --filter <package_name> build
  3. build-sea — invokes node packages/sea-builder/dist/cli.js --entry=… --out=… --json
  4. smoke — runs ./dist-sea/<output_name> --version to confirm the bundle loads

On linux runners the --no-sign flag is implied. On macOS runners the binary is ad-hoc re-signed (codesign --sign -); production distribution will eventually want a real Developer ID identity, but ad-hoc is fine for internal use.

  • scribe — the first real consumer, shipped via ADR-051 (tasks #293–295). Used so a Hetzner box can drop a single scribe binary in /usr/local/bin/ without a Node install.

Planned (chip task_28ad79fe, ADR-059 when written):

  • cel — distributable bundle of ss + sf + scribe + space + geodesic-local as one binary. The vision is that any developer can curl … | sh to get the full celestial CLI surface without cloning the monorepo.