jkunz 3410077425
Default (tags) / test (push) Successful in 3m24s
v1.0.0
2026-10-14 22:00:37 +00:00
…
…
…
…
…
…

@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:

  1. Downloads the official Node.js executable of every target and checks it against a SHA-256 that tssea pins for the release.
  2. Selects the target's prebuilt native addons and embeds them, together with your asset trees and sidecar executables, through the @push.rocks/smartsea asset layout.
  3. Strips Linux executables before the injection.
  4. Runs node --build-sea with the pinned Node.js of the host. The build uses an ESM main, execArgvExtension: "none", and code cache and snapshot off on every target.
  5. Signs macOS executables ad hoc and removes the stale Authenticode certificate table of Windows executables.
  6. 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" } }
  • from is a directory (every file below it, at its path below the directory) or a single file (at path, 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).
  • targets limits 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 names addons and addons-* 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 its binaryName.
  • At run time: the application reads a tree with smartsea: extractTree(name), readTreeFile(name, path) or getSidecarPath(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: runtime node, the pinned release's ABI and libuv, armv8 on arm64, and glibc. build/Release and build/Debug are host builds and never used. PREBUILDS_ONLY and <NAME>_PREBUILD affect node-gyp-build at run time only.
  • bindings: the locations the bindings package 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 .node file in the owner package.
  • package: the specifier resolved from the owner package with Node.js's resolution, to a .node file. 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's binfmt_misc emulation. Without an enabled handler the build fails with SMOKE_EMULATION_UNAVAILABLE; it is never skipped silently. qemu-user needs the target's libraries: on Debian and Ubuntu, install the cross packages and set QEMU_LD_PREFIX in env.
  • Environment: cleanEnv: true runs with env only. 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.gz with its SHA-256) as the corresponding source.
  • The bundle: its npm packages, from tsbundle's <bundle>.third-party-notices.json. tsbundle writes it for production bundles; without it, the build fails with NOTICES_BUNDLE_MISSING.
  • Embedded native addons: the owner package of every addon, plus every package its binding.gyp (with its relative includes) names through require('<name>') or require.resolve('<name>') in a <!(…) or <!@(…) command expansion, such as node-addon-api for its headers, applied recursively to those packages; package.json dependencies never count, and a name that does not resolve is NOTICES_PACKAGE_UNAVAILABLE. Their license and notice files are collected at every depth of each package (node_modules excluded, symbolic links to directories not followed), e.g. argon2's argon2/LICENSE for 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 with NODE_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 --target ones.
    • 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.
  • 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 as tssea: <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:

  1. Add a tsbundle bundle with "platform": "node" and "sea": true.
  2. Move compileTargets to targets:
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.

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.

S
Description
Builds Node.js single executable applications for every target platform from a tsbundle Node bundle, with verified official Node binaries, embedded assets and native addons.
Readme
208 KiB
Languages
TypeScript 99.8%
JavaScript 0.2%