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)
What it is
Section titled “What it is”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.
When to use it
Section titled “When to use it”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/topas your tool’s name (scribe), notnode /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.nodefiles (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.
The recipe
Section titled “The recipe”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 fileEach 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.
Why each step
Section titled “Why each step”| Step | Why |
|---|---|
| esbuild bundle to CJS | --experimental-sea-config doesn’t read ESM today |
sea-config.json | Tells node what to embed and how to expose require() |
node --experimental-sea-config | Produces 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 macOS | Postject mutates the binary; macOS rejects unsigned ad-hoc binaries |
postject | Splices the blob into the NODE_SEA_BLOB resource section |
Smoke --version | Catches “the bundle loads but can’t reach its entry” before you scp it |
Limits to know about
Section titled “Limits to know about”- 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.nodefiles can’t be bundled by esbuild. Either ship them alongside as files (the SEA’srequire()will resolve them relative to the binary), drop the dep, or pre-npm rebuildand bundle the prebuilt.nodenext 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.
Adding a SEA build to your product
Section titled “Adding a SEA build to your product”Two surfaces ship with @celestial/sea-builder:
1. Call it directly from a build script
Section titled “1. Call it directly from a build script”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:
- ensure-sea-builder — rebuild
@celestial/sea-builderif itsdist/is missing - build-target-package —
pnpm --filter <package_name> build - build-sea — invokes
node packages/sea-builder/dist/cli.js --entry=… --out=… --json - smoke — runs
./dist-sea/<output_name> --versionto 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.
Current consumers
Section titled “Current consumers”- scribe — the first real consumer, shipped via ADR-051
(tasks #293–295). Used so a Hetzner box can drop a single
scribebinary in/usr/local/bin/without a Node install.
Planned (chip task_28ad79fe, ADR-059
when written):
cel— distributable bundle ofss + sf + scribe + space + geodesic-localas one binary. The vision is that any developer cancurl … | shto get the full celestial CLI surface without cloning the monorepo.
Related
Section titled “Related”- Runbook: deploy scribe newswatch on Hetzner — the canonical “drop a SEA on a server” walkthrough.
- Backlog #294 — original
build-sea-binarypipeline ticket. - Backlog #295 — first scribe SEA smoke build.