@git.zone/tssea
Builds Node.js single executable applications (SEAs) for linux, macOS and Windows from one Linux or macOS host. It is the successor of @git.zone/tsdeno.
From a bundle that @git.zone/tsbundle wrote for Node.js, tssea:
- Downloads the official Node.js executable of every target and checks it against a SHA-256 that tssea pins for the release.
- Selects the target's prebuilt native addons and embeds them, together with your asset trees and sidecar executables, through the
@push.rocks/smartseaasset layout. - Strips Linux executables before the injection.
- Runs
node --build-seawith the pinned Node.js of the host. The build uses an ESM main,execArgvExtension: "none", and code cache and snapshot off on every target. - Signs macOS executables ad hoc and removes the stale Authenticode certificate table of Windows executables.
- Checks the result: a size floor and budget, a smoke run, and optionally a byte-identical rebuild. It writes third-party notices next to every executable.
Supported: builds run on linux-x64, linux-arm64, darwin-x64 and darwin-arm64 hosts with Node.js 22 or later. The build itself always uses the pinned Node.js release, never the Node.js that runs tssea. Deno is not supported and not tested.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
pnpm add --save-dev @git.zone/tssea @git.zone/tsbundle
pnpm add @push.rocks/smartsea # the in-executable runtime your bundle imports
Quick start
.smartconfig.json:
{
"@git.zone/tsbundle": {
"bundles": [
{ "from": "./ts/cli.ts", "to": "./dist_node/main.mjs", "bundler": "esbuild", "platform": "node", "sea": true, "production": true }
]
},
"@git.zone/tssea": {
"nodeVersion": "26.11.1",
"bundle": "./dist_node/main.mjs",
"outDir": "./dist/binaries",
"targets": [
{ "target": "linux-x64", "name": "myapp-linux-x64" },
{ "target": "linux-arm64", "name": "myapp-linux-arm64" },
{ "target": "darwin-arm64", "name": "myapp-macos-arm64" },
{ "target": "win32-x64", "name": "myapp-windows-x64.exe" }
],
"execArgv": ["--use-system-ca"],
"smoke": { "args": ["--version"], "expectStdoutPattern": "^myapp \\d+\\.\\d+\\.\\d+$", "cleanEnv": true }
}
}
tsbundle && tssea build
The sea: true bundle mode needs tsbundle 3.4 or later. It rewrites native addon loads to smartsea's loader and writes <bundle>.smartsea.json. tssea refuses a bundle without that file (REFERENCES_MISSING) or one whose file describes other bytes (REFERENCES_STALE). <bundle>.smartsea.json is a build intermediate that holds absolute paths of the build host; keep it out of a package's files.
Configuration
"@git.zone/tssea" in .smartconfig.json. Unknown keys are refused (CONFIG_INVALID).
| Key | Type | Required | Meaning |
|---|---|---|---|
nodeVersion |
string |
yes | Exact Node.js release, x.y.z, 26 or later; this tssea must carry its pin (see Node.js releases) |
bundle |
string |
yes | The bundle: one ESM file from tsbundle (platform: "node", sea: true) |
outDir |
string |
yes | Directory of the executables and their notices |
targets |
{ target, name }[] |
yes | Targets to build. name is the output file name, used exactly as given |
appName |
string |
no | Names the runtime cache directory of the application. The default comes from package.json: @serve.zone/dcrouter becomes serve.zone-dcrouter |
execArgv |
string[] |
no | Node.js options for every start of the executable, e.g. --use-system-ca, --max-old-space-size=512. NODE_OPTIONS is ignored at run time |
trees |
Record<string, { from, path?, targets? }> |
no | Embedded asset trees (see below) |
sidecars |
Record<string, { tree, path }> |
no | Embedded executables, keyed by the name the application looks up |
minSize, maxSize |
size, or { <target>: size } |
no | Floor and budget, e.g. "110MiB" (SIZE_BELOW_FLOOR, SIZE_BUDGET_EXCEEDED) |
smoke |
object | no | Run the executable once after building it (see below) |
thirdPartyNotices |
{ enabled?, supplements?, include? } |
no | Notices; on by default (see below) |
Targets
| Target | Node.js download | Notes |
|---|---|---|
linux-x64, linux-arm64 |
node-v<version>-linux-<arch>.tar.gz |
glibc ≥ 2.28, libstdc++ and libatomic on the host that runs it; Alpine/musl is not supported. Native addons are selected for glibc |
darwin-x64, darwin-arm64 |
node-v<version>-darwin-<arch>.tar.gz |
Signed ad hoc by tssea |
win32-x64 |
win-x64/node.exe |
Built and checked structurally, not smoke-tested on Windows |
Trees and sidecars
"trees": {
"webroot": { "from": "./dist_serve" },
"bin": { "from": { "linux-x64": "./dist_rust/proxy_linux_amd64", "linux-arm64": "./dist_rust/proxy_linux_arm64" }, "path": "proxy", "targets": ["linux-x64", "linux-arm64"] }
},
"sidecars": { "proxy": { "tree": "bin", "path": "proxy" } }
fromis a directory (every file below it, at its path below the directory) or a single file (atpath, default its file name). Give one source for all targets, or one per target (an object keyed by target, covering every target the tree is embedded for).targetslimits a tree, and the sidecars in it, to some targets.- Files: symbolic links are refused (
TREE_SYMLINK). A file's mode comes from its executable bit, and sidecars are always embedded executable. The namesaddonsandaddons-*are tssea's. - Sidecar names: a sidecar's key is the name the application asks for. smartrust looks embedded binaries up with
getSidecarPath(binaryName), so name a smartrust sidecar after itsbinaryName. - At run time: the application reads a tree with smartsea:
extractTree(name),readTreeFile(name, path)orgetSidecarPath(name).
Native addons
The bundle's references name every addon load by a target-independent id (see smartsea's addon ids). For each target, tssea resolves each reference to one .node file:
node-gyp-build: the prebuild node-gyp-build itself would load on the target. tssea uses node-gyp-build 4.8.4's own parsers (prebuilds/<platform>-<arch>[+…], the fewest architectures first, file name tags) and evaluates them for the target: runtimenode, the pinned release's ABI and libuv,armv8on arm64, and glibc.build/Releaseandbuild/Debugare host builds and never used.PREBUILDS_ONLYand<NAME>_PREBUILDaffect node-gyp-build at run time only.bindings: the locations thebindingspackage tries, evaluated for the target. A file in a target-independent build directory (build/Release, …) must still fit the target's header check, and its ABI must match the pinned Node.js (an N-API addon, or one built with the same release).file: the.nodefile in the owner package.package: the specifier resolved from the owner package with Node.js's resolution, to a.nodefile. napi-rs per-platform packages, which need a different candidate per target, are not supported yet.
Every selected file's object header must fit the target: ELF e_machine and a Linux OS/ABI, Mach-O CPU type (including universal binaries), or PE machine. A reference without a file for a configured target fails the build with ADDON_PREBUILD_MISSING, ADDON_FILE_MISSING or ADDON_TARGET_MISMATCH, which name what was searched; nothing is skipped. For example, argon2 0.45.1 ships no darwin-x64 prebuild, so a darwin-x64 build of an argon2 application fails. Remove the target, or use a package version that ships a prebuild for it.
Addons are embedded in trees that mirror node_modules/<package>/…. Two versions of one package go into separate trees. Mach-O addons and sidecars without a code signature are signed ad hoc for darwin targets, and the build result lists them in signedFiles.
Smoke check
"smoke": { "args": ["--version"], "expectExitCode": 0, "expectStdoutPattern": "^myapp ", "timeoutMs": 30000, "cleanEnv": true, "env": { "QEMU_LD_PREFIX": "/usr/aarch64-linux-gnu" }, "emulate": true }
- Where it runs: the executable of the host's target runs once, without a shell, in the project directory. Other targets are skipped with a notice.
emulate: true: also runs the other Linux architecture (linux-arm64 on a linux-x64 host, and the reverse) through the kernel'sbinfmt_miscemulation. Without an enabled handler the build fails withSMOKE_EMULATION_UNAVAILABLE; it is never skipped silently. qemu-user needs the target's libraries: on Debian and Ubuntu, install the cross packages and setQEMU_LD_PREFIXinenv.- Environment:
cleanEnv: trueruns withenvonly. Recommended in CI, because a failure quotes the end of stdout and stderr. - Failure: any mismatch is
SMOKE_FAILED. On POSIX the executable runs in its own process group, and whatever is left in that group is killed when the check ends.
Third-party notices
On by default. Every executable gets <name>.third-party-notices.txt and .json, covering:
- The Node.js runtime: its
LICENSE, and the pinned source tarball (node-v<version>.tar.gzwith its SHA-256) as the corresponding source. - The bundle: its npm packages, from tsbundle's
<bundle>.third-party-notices.json. tsbundle writes it forproductionbundles; without it, the build fails withNOTICES_BUNDLE_MISSING. - Embedded native addons: the owner package of every addon, plus every package its
binding.gyp(with its relativeincludes) names throughrequire('<name>')orrequire.resolve('<name>')in a<!(…)or<!@(…)command expansion, such as node-addon-api for its headers, applied recursively to those packages;package.jsondependencies never count, and a name that does not resolve isNOTICES_PACKAGE_UNAVAILABLE. Their license and notice files are collected at every depth of each package (node_modulesexcluded, symbolic links to directories not followed), e.g. argon2'sargon2/LICENSEfor the reference C sources. A symbolic link to a license file is read, even when its target lies outside the package. include: further notices files in tsbundle's format, e.g. those of a web bundle embedded as a tree.
A package without license text needs a supplement (supplements, same format as tsbundle's), or the build fails. Turn notices off with "thirdPartyNotices": { "enabled": false }.
Node.js releases and the cache
- Pins: tssea carries the SHA-256 of every download it uses for each supported Node.js release (
ts/tssea.nodereleases.ts; 26.11.1 for now). Every download is compared with it (NODE_CHECKSUM_MISMATCH), and SHASUMS256.txt is never fetched at build time. A release this tssea does not carry fails withNODE_VERSION_UNPINNED: upgrade@git.zone/tssea. - Cache: downloads are cached as code assets in
$TSSEA_CACHE_DIR, else$XDG_CACHE_HOME/tssea, else~/.cache/tssea(macOS:~/Library/Caches/tssea). - Integrity and concurrency: each entry records the SHA-256 of its files and is checked on every use. A damaged entry is fetched again. Concurrent first uses are safe without locks: each process stages its copy, and one rename publishes it.
- The building Node.js: the build runs the pinned Node.js of the host, because the Node.js that builds a SEA must be the release it injects into. Code cache and snapshot are off, since they cannot cross platforms.
Reproducible builds
The same bundle, assets and Node.js release produce the same bytes. tssea passes the main by its file name relative to the build directory, because Node.js embeds the main's path, and it sorts every embedded list. tssea build --reproducible-check (API: reproducibleCheck: true) builds every target a second time in another directory and fails with NOT_REPRODUCIBLE unless both builds are byte-identical. Builds run one target after another; each --build-sea takes about 630 MB of memory.
CLI
tssea build [--target <target>]... [--no-smoke] [--reproducible-check] [--keep-work]
tssea fetch [--target <target>]...
tssea help | --help | -h
tssea --version | -v
build: builds every configured target, or only the--targetones.- It writes each executable through a work directory (
<outDir>/.tssea-work-*, removed afterwards unless--keep-work). The executable is renamed into place only once all its checks passed. - The old executable and notices of a target are removed when its build starts.
- It writes each executable through a work directory (
fetch: downloads and verifies the Node.js executables of the targets and of the host into the cache, e.g. to warm a CI cache.- Exit codes:
0: success.1: a build failure, printed astssea: <message> [CODE].2: a usage error, followed by the help text.
API
import { TsSea } from '@git.zone/tssea';
const result = await new TsSea(process.cwd(), { reproducibleCheck: true }).build();
for (const target of result.targets) {
console.log(target.target, target.executablePath, target.size, target.sha256, target.smoke);
}
ITsseaOptions: config (instead of .smartconfig.json), targets, smoke, reproducibleCheck, keepWork, cacheRoot, nodeDistributionUrl, log. TsSea.fetch() fills the cache. Every deliberate failure is a TsseaError with a stable code. FILE_UNREADABLE means a file exists but cannot be read (for example EACCES); missing files keep their specific codes, such as BUNDLE_MISSING. Layout errors keep their SmartSeaError (e.g. LAYOUT_INCOMPATIBLE when the bundle's smartsea major differs from tssea's).
Platform status
- linux-x64 and linux-arm64: built and run in tssea's own tests (arm64 under qemu-user emulation).
- darwin-arm64 and darwin-x64: built and signed in the tests. The signature is verified structurally, page hash by page hash; runs on a Mac are part of the release gate.
- win32-x64: built in the tests; the certificate table is removed and the PE checksum is valid. Not smoke-tested on Windows.
Migrating from tsdeno
tssea builds from a bundle instead of compiling TypeScript itself:
- Add a tsbundle bundle with
"platform": "node"and"sea": true. - Move
compileTargetstotargets:
| tsdeno | tssea |
|---|---|
compileTargets[].target x86_64-unknown-linux-gnu |
targets[].target linux-x64 |
aarch64-unknown-linux-gnu |
linux-arm64 |
x86_64-apple-darwin |
darwin-x64 |
aarch64-apple-darwin |
darwin-arm64 |
x86_64-pc-windows-msvc |
win32-x64 |
compileTargets[].name |
targets[].name, the same file name. Deno appended .exe for Windows; tssea does not, so write it |
compileTargets[].outDir |
outDir, one for all targets |
compileTargets[].entryPoint |
bundle, the tsbundle output |
denoVersion |
nodeVersion (exact) |
v8Flags |
execArgv |
minSize, maxSize, smoke |
same names; sizes may be per target. smoke.emulate/env are new |
thirdPartyNotices.{enabled, supplements} |
same; now on by default |
compileTargets[].thirdPartyNoticesInclude |
thirdPartyNotices.include |
denoExecutable |
dropped: tssea downloads and verifies the pinned Node.js |
dependencyAge |
dropped: nothing is resolved at build time |
permissions, noCheck |
dropped: Node.js has no Deno permissions; type checking belongs to tsbuild |
selfExtracting, keepStaging |
dropped: smartsea extracts trees lazily into its cache and prunes old generations |
excludeUnusedNpm, include, pruneNatives, pruneExclude, pruneKeep |
dropped: the bundle holds only reachable code, and only the target's addon files are embedded. Extra files go into trees |
DENO_TLS_CA_STORE=system |
"execArgv": ["--use-system-ca"] |
Linux executables now need libatomic on the host that runs them.
Third-party code
The ad-hoc Mach-O signer is ported from Go's cmd/internal/codesign (BSD-3-Clause); see third-party-notices.md.
License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the license.md file within this repository.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
Company Information
Task Venture Capital GmbH Registered at District court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.