jkunz 5b44ce2a89
Default (tags) / test (push) Successful in 39s
v1.0.1
2026-10-14 20:24:57 +00:00
…
…
…
…

@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, .node addons, 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 .node addon loader built on process.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 by serializeManifest.
  • 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:

  • format and layoutVersion are checked first; an incompatible layoutVersion throws LAYOUT_INCOMPATIBLE (see Layout versions).
  • Every field is required, and unknown fields are rejected. The error names builtWith.
  • appName and tree names use 1–100 (trees: 1–64) characters of A-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.
  • mode is 0o644 (420) or 0o755 (493). Sidecars must point at a 0o755 file, and addons at a .node file. Every reference must name a file of a tree.
  • Files are regular files; a build tool dereferences or refuses symlinks.
  • generation is the first 32 hex digits of the SHA-256 over the canonical JSON of target and trees, 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, builtWith and nodeVersion are 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 on readTreeFile.
  • addons keys are canonical addon ids. A bundler rewrites addon loads to loadAddon(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.PATCH with optional -prerelease and +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-build directory (omitted for the package root), the file path (ends in .node) and the bindings file name (one segment, always with .node, as bindings() appends it). A package specifier is a package name or name/subpath, resolved from the owner's directory; the per-platform package's own version is not part of the id.
  • formatAddonId writes the canonical form; parseAddonId accepts only that form (no ./, no empty or . directory), otherwise ADDON_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: loadAddon does not parse ids, and any other spelling is ADDON_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:

  • format and layoutVersion are checked first, then the shape: every field is required and unknown fields are rejected.
  • runtimes lists the version of every @push.rocks/smartsea copy in the bundle; every one must be compatible (LAYOUT_INCOMPATIBLE). It is empty only when the bundle contains no copy, and then addons is empty too.
  • bundle.file is the bundle's file name and bundle.sha256 its lowercase hex SHA-256; the build tool refuses references of another bundle.
  • One entry per id; id equals formatAddonId(parts). sites names at least one bundle input holding the load.
  • packageDirectory is 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, sites and addons (by id) are sorted in code-unit order without duplicates, and the text is exactly what serializeBundleReferences writes (keys sorted, two-space indentation, a final newline). So serializeBundleReferences(parseBundleReferences(json)) === json for every accepted file.
  • createBundleReferences derives format, layoutVersion and the ids, merges duplicate runtimes and sites, and sorts the lists; serializeBundleReferences refuses 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, parseBundleReferences in the build tool refuses an incompatible writer or runtime.
  • At run time, getManifest refuses 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.

  1. Read bundleReferencesPath(bundle) with parseBundleReferences. Refuse it unless bundle.file and bundle.sha256 match the bundle being embedded.
  2. Per target, resolve every reference to at most one .node file:
    • 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 be node. The parts node/electron/node-webkit (runtime), napi, abi<N>, uv<N>, armv<N>, glibc/musl are 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's process.versions.modules, unless the file also has napi; uv<N> other than the major of the target's process.versions.uv; any armv<N> on x64, or other than armv8 on arm64; musl (the official Node.js builds are glibc, and node-gyp-build treats every platform except Alpine as glibc, so a glibc tag also matches on darwin and win32). An untagged file matches.
      • Of the matches take, in order: one tagged runtime node over one without a runtime tag, one with an abi tag over one without, then the one with more tags.
      • None: the build fails. node-gyp-build itself prefers build/Release and build/Debug to prebuilds; whether a build tool uses them (they are built for the build host only) is its decision.
    • bindings: the locations the bindings package searches below the owner's root.
    • file: <packageDirectory>/<path>, if it exists.
    • package: resolve the specifier from packageDirectory with Node.js's resolution. A package that declares os, cpu or libc belongs to the targets that match them. It must be installed for each of those targets; pnpm's supportedArchitectures installs other targets' packages.
  3. Required and candidate references: node-gyp-build and bindings references must resolve on every target. file and package references are candidates from try/catch loader chains: each owner needs at least one resolving candidate per target. Excluding references is the build tool's configuration, never a silent skip.
  4. Check every selected file's object header against the target: ELF (e_machine 62 for x64, 183 for arm64) for linux; Mach-O 64-bit (cputype 0x01000007 for x64, 0x0100000c for arm64) or a universal binary holding that slice for darwin; PE (machine 0x8664 for x64, 0xaa64 for arm64) for win32.
  5. Embed the files in trees that mirror the node_modules/ layout, so $ORIGIN run paths to companion libraries in sibling packages resolve, and put two versions of one package into separate trees. Map each id to its file in addons, and write the manifest with createManifest.

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_DIRECTORY is what systemd sets for a unit with CacheDirectory=. It is absolute and colon-separated when the unit names several directories (systemd.exec(5), systemd ≥ 244).
  • A relative SMARTSEA_CACHE_DIR, CACHE_DIRECTORY or LOCALAPPDATA throws CACHE_DIR_UNAVAILABLE. So does a missing home directory when the location needs one; the home directory comes from os.homedir(), which on POSIX falls back to the user database when HOME is 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:

  1. 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.
  2. It moves the tree into place with one rename.
  3. 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 graceMs without 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 a try/catch (as napi-rs packages do) move on to the next one.
  • A failing dlopen throws ADDON_LOAD_FAILED with the original error as cause.
  • Outside a SEA it throws NOT_IN_SEA, and nothing falls back to the file system. A bundle whose addon loads were rewritten to loadAddon runs 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 rename reports when the destination directory already exists is not verified; smartsea treats EEXIST, ENOTEMPTY, EPERM and EACCES followed by a valid destination as a lost race.
    • Antivirus scanners may hold new files open briefly.
    • A loaded .node file or a running sidecar cannot be deleted. Pruning reports such entries as failures (warning SMARTSEA_PRUNE_FAILED) and retries on the next prune.
  • 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.

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
The in-executable runtime for Node.js single executable applications: SEA detection, atomic extraction of embedded asset trees into a cache keyed by content hash, and a native addon loader.
Readme
263 KiB
Languages
TypeScript 100%