Standalone executables
This page gives the complete Bun compile and Node.js single executable application (SEA) procedures. See Deploy an OpenTUI application before you choose a format. See Runtime and platform support for the tested matrix.
Bun#
Bun can embed OpenTUI’s native library, parser worker, default grammars, and Tree-sitter WASM in
bun build --compile executables. These executables do not normally need asset extraction or OTUI_ASSET_ROOT.
bun build --compile ./app.ts --outfile appLinux libc#
Linux uses the glibc native package by default. Define process.env.OPENTUI_LIBC at build time. This lets Bun remove
the unused native-package branch and embed only the target libc.
await Bun.build({
entrypoints: ["./app.ts"],
compile: {
target: "bun-linux-x64-musl",
outfile: "./app-linux-x64-musl",
},
define: {
"process.env.OPENTUI_LIBC": JSON.stringify("musl"),
},
})Use "glibc" for a glibc target. Without a build-time definition, Bun retains both runtime selection branches. The
build can then require both native packages for the target architecture.
Make sure every target native package is installed before compiling. Multi-platform release builds can install optional packages for all supported OS and CPU combinations:
bun install --os="*" --cpu="*" @opentui/core@<version>On Alpine, Bun’s Linux musl executable can require the standard C++ runtime libraries:
apk add --no-cache libstdc++ libgccNode.js SEA#
Use exactly Node.js 26.4.0 for this procedure. The tested SEA is ESM and enables experimental FFI. It does not support
OpenTUI’s Bun runtime-plugin system or @opentui/three.
Node stores SEA assets as bytes inside the executable. The OpenTUI native library and worker need filesystem paths. The application must extract every OpenTUI asset before the bundled OpenTUI module body executes.
The build flow is:
- Bundle the application as one Node-targeted ESM file.
- Call
getNodeAssets()at build time for the target platform, architecture, and Linux libc. - Add every returned
{ key, source }entry to the SEAassetsmap without changing its key. - Prepend startup code that extracts all assets to an absolute directory.
- Set
OTUI_ASSET_ROOTto that directory before the bundled OpenTUI code executes. - Build the SEA with
--experimental-ffiinexecArgv.
getNodeAssets() is an ESM build-time manifest API. Do not call it from the finished SEA. It resolves installed
packages and verifies source files on disk.
import { spawnSync } from "node:child_process"
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { resolve } from "node:path"
import { getNodeAssets } from "@opentui/core/node-assets"
const buildDir = resolve("build")
mkdirSync(buildDir, { recursive: true })
const libc = process.platform === "linux" && process.env.OPENTUI_LIBC === "musl" ? "musl" : "glibc"
const assets = getNodeAssets({
platform: process.platform,
arch: process.arch,
...(process.platform === "linux" ? { libc } : {}),
})
run("bun", ["build", "./app.ts", "--target=node", `--outfile=${resolve(buildDir, "bundle.mjs")}`])
const prelude = `
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { getRawAsset, isSea } from "node:sea"
if (!isSea()) throw new Error("Expected a Node SEA executable")
const assetRoot = mkdtempSync(join(tmpdir(), "opentui-assets-"))
try {
for (const key of ${JSON.stringify(assets.map(({ key }) => key))}) {
const target = join(assetRoot, key)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, new Uint8Array(getRawAsset(key)))
}
} catch (error) {
rmSync(assetRoot, { recursive: true, force: true })
throw error
}
process.env.OTUI_ASSET_ROOT = assetRoot
${process.platform === "linux" ? `process.env.OPENTUI_LIBC = ${JSON.stringify(libc)}` : ""}
`
const seaMain = resolve(buildDir, "sea-main.mjs")
writeFileSync(seaMain, prelude + readFileSync(resolve(buildDir, "bundle.mjs"), "utf8"))
const output = resolve(buildDir, process.platform === "win32" ? "app.exe" : "app")
const config = {
main: seaMain,
mainFormat: "module",
executable: process.execPath,
output,
disableExperimentalSEAWarning: true,
useSnapshot: false,
useCodeCache: false,
execArgv: ["--experimental-ffi", "--no-warnings"],
execArgvExtension: "none",
assets: Object.fromEntries(assets.map(({ key, source }) => [key, source])),
}
const configPath = resolve(buildDir, "sea-config.json")
writeFileSync(configPath, JSON.stringify(config, null, 2))
run(process.execPath, ["--build-sea", configPath])
function run(command, args) {
const result = spawnSync(command, args, { stdio: "inherit" })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} failed with status ${result.status}`)
}Run this build file with Node.js 26.4.0. The example builds for the host platform and architecture. It uses Bun only as the ESM bundler. The matching optional native package must be installed.
The application uses normal OpenTUI imports:
import { TextRenderable, createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer()
renderer.root.add(new TextRenderable(renderer, { content: "Hello from Node SEA" }))Node asset manifest#
type NodeAssetTarget = {
platform: "darwin" | "linux" | "win32"
arch: "arm64" | "x64"
libc?: "glibc" | "musl"
}
type NodeAsset = {
readonly key: string
readonly source: string
}source is an absolute path to an existing build-time file. The manifest includes the selected native library, parser
worker, current default grammar and query assets, and Tree-sitter WASM. Keys are validated, unique, sorted, and
relocatable. Do not depend on a fixed asset count.
libc is valid only for Linux and defaults to glibc when omitted. Manifest generation throws for unsupported targets,
invalid libc combinations, missing native packages, and missing files.
Extraction ownership#
OTUI_ASSET_ROOTmust be absolute. Set it before the bundled OpenTUI module body executes.- Extract every manifest entry beneath that root with its exact key. A missing requested file throws. OpenTUI does not fall back to installed package paths when the root is set.
- The extraction destination must be writable. The example uses a private directory for each process and removes it after an extraction error. It keeps a successful directory for the process lifetime because the native library and worker continue to use those files. The application owns stale-directory cleanup after the process ends.
- If an application adds a shared cache, publish each complete file with an atomic rename. Verify cached content before reuse, and coordinate concurrent writers. The application owns cache cleanup, permissions, and integrity policy.
- Keep
mainFormat: "module",useSnapshot: false,useCodeCache: false, andexecArgvExtension: "none". - Keep
--experimental-ffiinexecArgv. The example also uses--no-warnings. - The tested settings depend on ESM and runtime
import()support. - Cross-target builds require a matching target Node executable and native package.
- Repository CI runs the Node SEA acceptance path on Linux x64.
- Node SEA and Node FFI are experimental Node.js features. The application owns code signing and platform distribution.
See Environment variables for the exact asset-root timing and fallback rules.