@push.rocks/smartsea
The runtime that a Node.js single executable application (SEA) needs inside the executable:
isSea().- Atomic extraction of embedded asset trees (Rust sidecars,
.nodeaddons, web roots) into a per-user cache. The cache is keyed by a content hash of the assets, and superseded generations are pruned. - A synchronous
.nodeaddon loader built onprocess.dlopen.
The package has no dependencies; it uses Node.js built-ins only. Build tools such as @git.zone/tssea embed assets through the asset layout contract in @push.rocks/smartsea/layout; applications and libraries (smartrust's sidecar discovery, bundled addon loads) use the runtime in @push.rocks/smartsea.
Supported runtimes: Node.js 22.3 or later. Bun imports the runtime and the layout cleanly; a Bun process is never a SEA, so isSea() returns false there and every other runtime function throws NOT_IN_SEA. 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 @push.rocks/smartsea
Node.js 22.3 or later: the runtime obtains node:sea through process.getBuiltinModule, which arrived in 22.3.0. Building a SEA with an ESM main and node --build-sea needs Node.js 25.7 or later.
Usage
import * as smartsea from '@push.rocks/smartsea';
if (smartsea.isSea()) {
const webRoot = await smartsea.extractTree('webroot'); // <cache>/<generation>/webroot
const proxyPath = await smartsea.getSidecarPath('rustproxy'); // executable, ready to spawn
const argon2 = smartsea.loadAddon('argon2@0.45.1:node-gyp-build');
}
Outside a SEA, isSea() returns false and every other runtime function throws a SmartSeaError with code NOT_IN_SEA. Nothing falls back to the file system. A bundle whose native addon loads were rewritten to loadAddon therefore runs only inside a SEA; run the unbundled code, or a bundle without that rewrite, under plain Node.js.
API
@push.rocks/smartsea
| Export | Signature | Notes |
|---|---|---|
isSea |
(): boolean |
node:sea isSea(); false on a runtime without node:sea (Bun). |
getManifest |
(): ISeaManifest |
Parsed and validated once. |
getCacheRoot |
(): string |
The application's cache directory (see below). |
resolveCacheRoot |
(input: ICacheRootInput): string |
Pure form of the cache location rules. |
extractTreeSync |
(treeName: string): string |
Extracts the tree unless a complete copy exists; returns its directory. |
extractTree |
(treeName: string): Promise<string> |
Asynchronous form; concurrent calls for one tree share one extraction. |
readTreeFile |
(treeName: string, relPath: string): Buffer |
One file, copied out of the executable, without extraction. |
getSidecarPath |
(name: string): Promise<string> |
Path of an embedded executable; extracts its tree. |
resolveAddonPathSync |
(id: TAddonId): string |
Path of an embedded addon; extracts its tree. |
loadAddon |
(id: TAddonId, options?: ILoadAddonOptions): unknown |
process.dlopen with RTLD_NOW by default; exports cached per id. |
prune |
(options?: IPruneOptions): Promise<IPruneResult> |
Removes superseded generations unused for graceMs (default 24 h). |
SmartSeaError |
class extends Error { code: TSmartSeaErrorCode } |
Every deliberate failure. |
Error codes (TSmartSeaErrorCode): NOT_IN_SEA, MANIFEST_MISSING, MANIFEST_INVALID, LAYOUT_INCOMPATIBLE, ADDON_ID_INVALID, REFERENCES_INVALID, TARGET_MISMATCH, TREE_NOT_EMBEDDED, FILE_NOT_EMBEDDED, SIDECAR_NOT_EMBEDDED, ADDON_NOT_EMBEDDED, ADDON_LOAD_FAILED, CACHE_DIR_UNAVAILABLE, CACHE_DIR_UNSAFE, EXTRACTION_FAILED.
Process warnings: SMARTSEA_CACHE_REPAIRED (a damaged tree was discarded and extracted again) and SMARTSEA_PRUNE_FAILED (the background prune could not remove something).
@push.rocks/smartsea/layout
Pure functions for bundlers and SEA build tools, plus the contract types: ISeaManifest, ISeaTree, ISeaFileEntry, ISeaEntryRef, ISeaTarget, TSeaPlatform, TSeaArch, TSeaFileMode, TAddonId, TAddonIdParts (IAddonNodeGypBuildParts, IAddonBindingsParts, IAddonFileParts, IAddonPackageParts), IAddonOwner, TAddonMechanism, ISeaBundleReferences, ISeaBundleReferencesInput, ISeaAddonReference, TSeaAddonReferenceInput and ISeaBundleFile.
| Export | Signature |
|---|---|
manifestAssetKey |
'smartsea/manifest.json' |
treeFileAssetKey |
(tree: string, relPath: string): string |
createManifest |
(input: TSeaManifestInput): ISeaManifest |
computeGeneration |
(input: Pick<ISeaManifest, 'target' | 'trees'>): string |
serializeManifest |
(manifest: ISeaManifest): string |
parseManifest |
(json: string): ISeaManifest |
assertAppName |
(appName: string): void |
layoutVersion |
string: this package's version, from its commitinfo |
isLayoutCompatible |
(version: string): boolean |
addonMechanisms |
['node-gyp-build', 'bindings', 'file', 'package'] |
formatAddonId |
(parts: TAddonIdParts): TAddonId |
parseAddonId |
(id: TAddonId): TAddonIdParts |
bundleReferencesSuffix |
'.smartsea.json' |
bundleReferencesPath |
(bundlePath: string): string |
createBundleReferences |
(input: ISeaBundleReferencesInput): ISeaBundleReferences |
serializeBundleReferences |
(references: ISeaBundleReferences): string |
parseBundleReferences |
(json: string): ISeaBundleReferences |
SmartSeaError |
class extends Error { code: TSmartSeaErrorCode } |
Asset layout contract
A SEA built for smartsea embeds, through the assets field of its SEA configuration:
smartsea/manifest.json: the manifest, as written byserializeManifest.smartsea/t/<tree>/<relPath>: one asset per file of every tree.
The manifest:
{
"format": "smartsea-assets",
"layoutVersion": "1.0.0",
"builtWith": "@git.zone/tssea@1.0.0",
"appName": "serve.zone-dcrouter",
"nodeVersion": "v26.11.1",
"target": { "platform": "linux", "arch": "x64" },
"generation": "<32 hex digits>",
"trees": {
"addons": { "files": [{ "path": "node_modules/argon2/prebuilds/linux-x64/argon2.glibc.node", "size": 60208, "sha256": "<64 hex>", "mode": 420 }] },
"bin": { "files": [{ "path": "rustproxy", "size": 1, "sha256": "<64 hex>", "mode": 493 }] }
},
"sidecars": { "rustproxy": { "tree": "bin", "path": "rustproxy" } },
"addons": { "argon2@0.45.1:node-gyp-build": { "tree": "addons", "path": "node_modules/argon2/prebuilds/linux-x64/argon2.glibc.node" } }
}
Rules, enforced by parseManifest with MANIFEST_INVALID:
formatandlayoutVersionare checked first; an incompatiblelayoutVersionthrowsLAYOUT_INCOMPATIBLE(see Layout versions).- Every field is required, and unknown fields are rejected. The error names
builtWith. appNameand tree names use 1–100 (trees: 1–64) characters ofA-Z a-z 0-9 . _ -. They start with a letter or digit and do not end with a dot.- Paths:
- They are POSIX and relative.
- They contain no empty,
.or..segment, no control character and none of< > : " \ | ? *. No segment ends with a dot or a space. - Windows device names (
con,nul,com1, …) are refused for win32 targets. - A path appears once per tree; on darwin and win32 targets, case-insensitively. It is not both a file and a directory.
modeis0o644(420) or0o755(493). Sidecars must point at a0o755file, and addons at a.nodefile. Every reference must name a file of a tree.- Files are regular files; a build tool dereferences or refuses symlinks.
generationis the first 32 hex digits of the SHA-256 over the canonical JSON oftargetandtrees, with keys sorted and files sorted by path. It depends on contents, paths and modes only. A rebuild that leaves the assets unchanged therefore reuses the extracted cache;appName,builtWithandnodeVersionare diagnostic.- At runtime the manifest's target must equal
process.platform/process.arch(TARGET_MISMATCH). The sizes and SHA-256 of embedded files are checked on extraction and onreadTreeFile. addonskeys are canonical addon ids. A bundler rewrites addon loads toloadAddon(id), and the build tool maps each id per target to one file.
Selecting files per target, such as prebuilds for the target's platform, CPU and libc, is the build tool's job (see Guidance for build tools). A SEA carries the files of its own target only.
Addon ids
An addon id names one load site independently of the target: <owner>:<mechanism>[:<argument>]. The owner is <name>@<version> from the package.json of the package directory the load resolves from; a first-party .node file is owned by the project's own package.
| Original load | Id |
|---|---|
require('node-gyp-build')(<argon2 root>) |
argon2@0.45.1:node-gyp-build |
require('node-gyp-build')(<foo root>/lib/native) |
foo@2.0.0:node-gyp-build:lib/native |
require('bindings')('better_sqlite3') |
better-sqlite3@11.8.1:bindings:better_sqlite3.node |
require('./skia.linux-x64-gnu.node') in @napi-rs/canvas |
@napi-rs/canvas@0.1.100:file:skia.linux-x64-gnu.node |
require('@napi-rs/canvas-linux-x64-gnu') in @napi-rs/canvas |
@napi-rs/canvas@0.1.100:package:@napi-rs/canvas-linux-x64-gnu |
require('./native/foo.node') in the application |
@serve.zone/dcrouter@32.8.0:file:native/foo.node |
- The name is an npm package name, optionally scoped. The version is
MAJOR.MINOR.PATCHwith optional-prereleaseand+build; it contains no:,/, whitespace or control character. - The arguments are POSIX paths relative to the owner's root, under the tree path rules above: the
node-gyp-builddirectory (omitted for the package root), thefilepath (ends in.node) and thebindingsfile name (one segment, always with.node, asbindings()appends it). Apackagespecifier is a package name orname/subpath, resolved from the owner's directory; the per-platform package's own version is not part of the id. formatAddonIdwrites the canonical form;parseAddonIdaccepts only that form (no./, no empty or.directory), otherwiseADDON_ID_INVALID. Ids are at most 512 characters.- Loads the bundler cannot resolve statically (
require(variable), template literals) get no id. - At runtime the lookup is exact:
loadAddondoes not parse ids, and any other spelling isADDON_NOT_EMBEDDED.
Bundle references
A bundler that rewrites addon loads (tsbundle's SEA mode) writes bundleReferencesPath(bundle), the bundle's path plus .smartsea.json (dist/main.mjs.smartsea.json), for every bundle, even with no addons. It is a build intermediate for the SEA build tool and is never embedded.
{
"format": "smartsea-bundle-references",
"layoutVersion": "1.0.0",
"runtimes": ["1.0.0"],
"bundle": { "file": "main.mjs", "sha256": "<64 hex>" },
"addons": [
{
"id": "argon2@0.45.1:node-gyp-build",
"parts": { "owner": { "name": "argon2", "version": "0.45.1" }, "mechanism": "node-gyp-build" },
"packageDirectory": "/work/app/node_modules/.pnpm/argon2@0.45.1/node_modules/argon2",
"sites": ["node_modules/.pnpm/argon2@0.45.1/node_modules/argon2/argon2.cjs"]
}
]
}
Rules, enforced by parseBundleReferences with REFERENCES_INVALID:
formatandlayoutVersionare checked first, then the shape: every field is required and unknown fields are rejected.runtimeslists the version of every@push.rocks/smartseacopy in the bundle; every one must be compatible (LAYOUT_INCOMPATIBLE). It is empty only when the bundle contains no copy, and thenaddonsis empty too.bundle.fileis the bundle's file name andbundle.sha256its lowercase hex SHA-256; the build tool refuses references of another bundle.- One entry per id;
idequalsformatAddonId(parts).sitesnames at least one bundle input holding the load. packageDirectoryis the owner's real path on the build host at bundle time, absolute and normalized:/work/app/...on POSIX,C:\work\app\...on Windows (a drive letter, backslashes only). It has no empty,.or..segment, no trailing separator except a bare root (/,C:\), and no separator of the other kind. UNC paths (\\server\share,//server/share) are refused. The path describes the build host, not the SEA target: a win32 SEA built on Linux has POSIX directories here.- Canonical form only:
runtimes,sitesandaddons(by id) are sorted in code-unit order without duplicates, and the text is exactly whatserializeBundleReferenceswrites (keys sorted, two-space indentation, a final newline). SoserializeBundleReferences(parseBundleReferences(json)) === jsonfor every accepted file. createBundleReferencesderivesformat,layoutVersionand the ids, merges duplicate runtimes and sites, and sorts the lists;serializeBundleReferencesrefuses a value out of canonical order instead of reordering it.
Layout versions
The manifest and the references file carry layoutVersion: the version of the @push.rocks/smartsea package that wrote them, from its commitinfo. A reader accepts a file whose version has its own major version, and below 1.0.0 also its own minor version (isLayoutCompatible). This check runs before any other field, so a file of another major fails with LAYOUT_INCOMPATIBLE instead of a shape error.
The bundler (writing the references), the @push.rocks/smartsea runtime copies in the bundle (runtimes) and the SEA build tool (reading the references, writing the manifest) must therefore share one major version, and below 1.0.0 one major.minor version (0.3.x). The LAYOUT_INCOMPATIBLE message names the range the reader accepts:
- At build time,
parseBundleReferencesin the build tool refuses an incompatible writer or runtime. - At run time,
getManifestrefuses a manifest of another major, a backstop for SEAs built without that check.
Guidance for build tools
The contract @git.zone/tssea implements; smartsea itself selects nothing.
- Read
bundleReferencesPath(bundle)withparseBundleReferences. Refuse it unlessbundle.fileandbundle.sha256match the bundle being embedded. - Per target, resolve every reference to at most one
.nodefile:node-gyp-build(as node-gyp-build 4.8 resolves at run time, evaluated for the target):- Directory: of the
prebuilds/<platform>-<arch>[+<arch>...]directories below<packageDirectory>/<directory>whose platform is the target's and whose arch list holds the target's, take the one with the fewest arches. Only that directory is searched. - Tags: a file name is split at
.; the last part must benode. The partsnode/electron/node-webkit(runtime),napi,abi<N>,uv<N>,armv<N>,glibc/muslare tags; other parts (the name) are ignored. - A file matches unless a tag it carries contradicts the target: a runtime other than
node;abi<N>other than the target Node.js'sprocess.versions.modules, unless the file also hasnapi;uv<N>other than the major of the target'sprocess.versions.uv; anyarmv<N>on x64, or other thanarmv8on arm64;musl(the official Node.js builds are glibc, and node-gyp-build treats every platform except Alpine as glibc, so aglibctag also matches on darwin and win32). An untagged file matches. - Of the matches take, in order: one tagged runtime
nodeover one without a runtime tag, one with anabitag over one without, then the one with more tags. - None: the build fails. node-gyp-build itself prefers
build/Releaseandbuild/Debugto prebuilds; whether a build tool uses them (they are built for the build host only) is its decision.
- Directory: of the
bindings: the locations thebindingspackage searches below the owner's root.file:<packageDirectory>/<path>, if it exists.package: resolve the specifier frompackageDirectorywith Node.js's resolution. A package that declaresos,cpuorlibcbelongs to the targets that match them. It must be installed for each of those targets; pnpm'ssupportedArchitecturesinstalls other targets' packages.
- Required and candidate references:
node-gyp-buildandbindingsreferences must resolve on every target.fileandpackagereferences are candidates fromtry/catchloader chains: each owner needs at least one resolving candidate per target. Excluding references is the build tool's configuration, never a silent skip. - Check every selected file's object header against the target: ELF (
e_machine62 for x64, 183 for arm64) for linux; Mach-O 64-bit (cputype0x01000007for x64,0x0100000cfor arm64) or a universal binary holding that slice for darwin; PE (machine0x8664for x64,0xaa64for arm64) for win32. - Embed the files in trees that mirror the
node_modules/layout, so$ORIGINrun paths to companion libraries in sibling packages resolve, and put two versions of one package into separate trees. Map each id to its file inaddons, and write the manifest withcreateManifest.
Cache location
| Platform | Directory |
|---|---|
| any | $SMARTSEA_CACHE_DIR/<appName> (absolute path required) |
| linux and other POSIX | $CACHE_DIRECTORY (first entry) /smartsea/<appName>, else $XDG_CACHE_HOME/smartsea/<appName> (absolute values only), else ~/.cache/smartsea/<appName> |
| darwin | ~/Library/Caches/smartsea/<appName> |
| win32 | %LOCALAPPDATA%\smartsea\<appName>, else <home>\AppData\Local\smartsea\<appName> |
- Empty variables count as unset.
CACHE_DIRECTORYis what systemd sets for a unit withCacheDirectory=. It is absolute and colon-separated when the unit names several directories (systemd.exec(5), systemd ≥ 244).- A relative
SMARTSEA_CACHE_DIR,CACHE_DIRECTORYorLOCALAPPDATAthrowsCACHE_DIR_UNAVAILABLE. So does a missing home directory when the location needs one; the home directory comes fromos.homedir(), which on POSIX falls back to the user database whenHOMEis unset. - There is no fallback to the temporary directory. It may be mounted
noexec, and it is shared.
Inside the application directory:
<appRoot>/.smartsea-cache.json ownership marker; pruning only touches marked directories
<appRoot>/<generation>/<tree>/... extracted trees
<appRoot>/<generation>/.lastuse its mtime is the generation's lease, refreshed on every extraction call
<appRoot>/.tmp-<generation>-<pid>-<random>/ staging of an extraction in progress
<appRoot>/.trash-<random>/ directories being removed
Safety. The application directory is created with mode 0o700. On POSIX it must be a real directory (not a symlink) owned by the process's user and not writable by group or others; otherwise CACHE_DIR_UNSAFE. A directory that holds entries but no marker is not smartsea's and is refused.
Atomic extraction, no locks:
- The process writes a tree into its own staging directory, checks every file's size and SHA-256, applies the modes and fsyncs every file.
- It moves the tree into place with one
rename. - A process whose rename fails because the tree is already there checks the winner's copy and uses it.
Concurrent first runs therefore never see a partial tree; at worst they duplicate the work. On reuse, every file's type, size and executable bit are checked. A damaged tree is renamed into the trash and extracted again, with warning SMARTSEA_CACHE_REPAIRED.
Pruning. After a process creates a new generation, prune() runs in the background. It also runs on demand:
- It removes generations other than the current one whose lease is older than
graceMs(default 24 h). - It removes staging and trash directories older than an hour.
- Generations are renamed into the trash before they are removed. A process that still uses a pruned generation extracts it again on its next call.
- A long-running process that keeps a resolved path (to spawn a sidecar again, for example) resolves it again before each use: the call refreshes the generation's lease and extracts a pruned tree again. A path held longer than
graceMswithout such a call may have been pruned by a newer executable. - Only directories with smartsea's names in a marked directory are removed. Symlinks in the application directory are skipped, never followed.
Native addons
loadAddon(id) is synchronous, like require():
- It extracts the addon's tree, calls
process.dlopen({ exports: {} }, path, flags)and caches the exports per id; an addon must not be opened twice. - An id the executable does not embed throws
ADDON_NOT_EMBEDDED. Loaders that try several candidates in atry/catch(as napi-rs packages do) move on to the next one. - A failing
dlopenthrowsADDON_LOAD_FAILEDwith the original error ascause. - Outside a SEA it throws
NOT_IN_SEA, and nothing falls back to the file system. A bundle whose addon loads were rewritten toloadAddonruns only inside a SEA.
Platform caveats
Only Linux is verified so far. The test suite builds and runs real SEAs on linux-x64.
- Windows (not verified):
- The exact error
renamereports when the destination directory already exists is not verified; smartsea treatsEEXIST,ENOTEMPTY,EPERMandEACCESfollowed by a valid destination as a lost race. - Antivirus scanners may hold new files open briefly.
- A loaded
.nodefile or a running sidecar cannot be deleted. Pruning reports such entries as failures (warningSMARTSEA_PRUNE_FAILED) and retries on the next prune.
- The exact error
- macOS (not verified):
- Files written by the process should carry no quarantine attribute, but this is not verified.
- arm64 executables and addons must carry a valid code signature, at least an ad-hoc one, which the extracted bytes keep from the build.
- Neither has been run on macOS yet.
- Node.js: the maximum asset size is not documented. Extraction reads assets with
getRawAsset(no copy) and never writes to them.
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.