A Node.js Single Executable Application (SEA) is a standalone binary that bundles JavaScript source code inside a stock Node.js executable. At startup, the Node.js runtime detects the embedded payload and runs it instead of entering the normal REPL or script-execution path.
nodesea is a pure Rust tool that creates these SEA binaries without requiring
Node.js at build time. The official Node.js workflow (node --experimental-sea-config)
needs a working Node.js installation to generate the SEA blob. nodesea replaces that
entire step: it serializes the blob itself, injects it into a copy of the Node binary,
flips the activation fuse, and re-signs on macOS -- all from a single native executable.
The SEA blob is the binary payload that the Node.js runtime deserializes at startup
via its internal BlobDeserializer. Two format versions exist, selected based on the
target Node.js version.
| Version | Node.js range | Header size | Layout |
|---|---|---|---|
| V1 | 22.0 -- 22.19, 23.x -- 24.5 | 8 bytes | magic(u32 LE) + flags(u32 LE) |
| V2 | 22.20+, 24.6+ | 9 bytes | magic(u32 LE) + flags(u32 LE) + exec_argv_extension(u8) |
The magic number is 0x143da20 (little-endian u32), defined in node_sea.h in the
Node.js source tree.
The flags field is a u32 bitfield:
| Bit | Name | Meaning |
|---|---|---|
| 0 | DISABLE_EXPERIMENTAL_SEA_WARNING |
Suppress the "ExperimentalWarning" message |
| 1 | USE_SNAPSHOT |
Main payload is a V8 heap snapshot, not JS source |
| 2 | USE_CODE_CACHE |
A V8 code cache follows the main payload |
| 3 | INCLUDE_ASSETS |
An assets map is present (Node 21+) |
| 4 | INCLUDE_EXEC_ARGV |
An exec_argv list is present (V2 only, Node 24.6+) |
The ninth header byte in V2 controls how exec argv is extended at runtime:
| Value | Name | Meaning |
|---|---|---|
| 0 | None | No extension |
| 1 | Env | Extend via environment variable |
| 2 | Cli | Extend via CLI arguments |
After the header, the body consists of length-prefixed fields. Each field uses Node's "StringView" encoding: a u64 little-endian length prefix followed by that many raw bytes.
Fields appear in this fixed order (some are conditional on flags):
- code_path -- Virtual path for the embedded script (e.g.
/sea/main.js). - main_code -- JavaScript source bytes (or V8 snapshot if
USE_SNAPSHOT). - code_cache -- V8 code cache bytes. Only present when
USE_CODE_CACHEis set. - assets -- Asset map. Only present when
INCLUDE_ASSETSis set. Encoded as a u64 LE count followed by that many (key, value) pairs, each length-prefixed. - exec_argv -- Argument list. V2 only, present when
INCLUDE_EXEC_ARGVis set. Encoded as a u64 LE count followed by that many length-prefixed strings.
Every Node.js binary contains an embedded sentinel string:
NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2:0
The trailing :0 means "no SEA payload -- run as normal Node.js". After blob
injection, nodesea scans the binary for this sentinel and flips the last byte
from 0 to 1:
NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2:1
This tells the Node.js runtime at startup to look for the injected SEA blob instead of entering normal execution. The fuse is a one-way switch -- once flipped, the binary is committed to SEA mode.
The scanner (fuse.rs) performs a brute-force byte search for the sentinel
prefix and validates the state byte is either 0 or 1.
The SEA blob must be placed where the Node.js runtime knows to look for it. Each executable format uses a different mechanism.
The blob is injected as a new section in a new load command:
- Segment name:
NODE_SEA - Section name:
__NODE_SEA_BLOB - Load command type:
LC_SEGMENT_64 - Alignment: Page-aligned (16384 bytes on arm64, 4096 on x86_64)
Mach-O injection is the most involved of the three formats because macOS
codesign enforces strict layout constraints. The algorithm:
Original layout: After injection:
┌──────────────┐ ┌──────────────┐
│ mach_header │ │ mach_header │ ncmds += 1, sizeofcmds += 152
│ + load cmds │ │ + load cmds │
│ │ │ + NODE_SEA │ ← inserted BEFORE __LINKEDIT
│ │ │ + __LINKEDIT│ ← shifted forward
├──────────────┤ ├──────────────┤
│ __TEXT data │ │ __TEXT data │ unchanged
├──────────────┤ ├──────────────┤
│ __LINKEDIT │ │ NODE_SEA │ ← blob data (page-aligned)
│ (symtab, │ │ blob data │
│ exports, │ ├──────────────┤
│ codesig) │ │ __LINKEDIT │ ← relocated after blob
└──────────────┘ │ (symtab, │
│ exports) │ codesig removed
└──────────────┘
Step-by-step:
-
Remove
LC_CODE_SIGNATURE— zero out the load command, shift subsequent commands down, decrementncmds/sizeofcmds. Truncate the file at the code signature data offset so the signature bytes are removed. -
Shrink
__LINKEDIT— update itsfilesizeandvmsizeto reflect the removal of the signature data. -
Save and relocate
__LINKEDIT— copy the__LINKEDITdata out of the binary, truncate the file to just before where__LINKEDITstarted, then:- Page-align and append the blob data.
- Page-align and re-append
__LINKEDITdata after the blob.
-
Update
__LINKEDITfields — set newfileoff,filesize,vmsize, andvmaddrto reflect its new position. -
Fix up offset references — any load command that stores absolute file offsets pointing into
__LINKEDITmust be adjusted by the relocation delta. This includesLC_SYMTAB(symoff,stroff),LC_DYSYMTAB(six offset fields),LC_DYLD_INFO/LC_DYLD_INFO_ONLY(five offset fields),LC_FUNCTION_STARTS,LC_DATA_IN_CODE,LC_DYLD_EXPORTS_TRIE, andLC_DYLD_CHAINED_FIXUPS. -
Check header space — the new
LC_SEGMENT_64+Section64is 152 bytes. The injector scans all sections to find the earliest data offset in the file and verifies there is room between the end of the current load commands and that first section. -
Insert
NODE_SEAload command — the new segment is written before__LINKEDIT's load command (not appended at the end).__LINKEDITand all subsequent load commands are shifted forward by 152 bytes. This ensures__LINKEDITremains the lastLC_SEGMENT_64in the load command table — a hard requirement forcodesign. -
Update
mach_header_64— incrementncmdsandsizeofcmds.
Why __LINKEDIT must be last:
macOS code signing appends the code signature as the final data in
__LINKEDIT. The codesign tool expects __LINKEDIT to be the last
segment in both the load command table and the file layout. If any segment
data follows __LINKEDIT, signing fails with "main executable failed strict
validation". This constraint drives the entire relocation strategy above.
The blob is injected as a PT_NOTE program header entry:
- Note name:
NODE_SEA_BLOB - Note type:
0(Node.js matches by name, not type)
At runtime, Node.js uses postject's dl_iterate_phdr-based lookup, which
accesses note data via dlpi_addr + p_vaddr. This means the note must be
within a PT_LOAD segment so it's mapped into virtual memory.
The injection algorithm:
- Build ELF note — standard note format with name
NODE_SEA_BLOB\0. - Find max virtual address — scan all
PT_LOADsegments to find the highestvaddr + memsz, then page-align upward. - Append a new segment — pad the file to a page-aligned offset, then
append:
[note data] [combined phdr table]. The combined table contains all original program headers plus two new entries (PT_LOAD+PT_NOTE). - Create
PT_LOAD— maps the appended region at the chosen virtual address withPF_R. Both file offset and vaddr are page-aligned to satisfyp_offset % p_align == p_vaddr % p_align. - Create
PT_NOTE— points to the note data within the newPT_LOAD. - Update
PT_PHDR— if present in the combined table, repoint it to the new table location so thatdl_iterate_phdrsees all entries. - Update ELF header — set
e_phoffto the combined table and incremente_phnum.
Existing program headers and binary data are never modified (only e_phoff
and e_phnum in the 64-byte ELF header change). This avoids corrupting
BSS regions or breaking dynamic linker initialization.
The blob is injected as a Win32 resource:
- Resource type:
RT_RCDATA - Resource name:
NODE_SEA_BLOB
This uses the Windows resource table mechanism to embed arbitrary binary data
that the Node.js runtime reads via FindResource/LoadResource at startup.
The end-to-end build process follows this sequence:
nodesea app.js (or --config sea-config.json)
|
v
1. Config resolve If a positional script is given, synthesize a
config with sensible defaults (output name from
file stem, warnings suppressed). Otherwise parse
the JSON config file.
|
v
2. Version detect Run `node --version` on the target binary, parse
the semver output, and select V1 or V2 blob format.
|
v
3. Bundle / read By default, bundle the entry point and all its
imports into a single CJS file using rolldown
(in-process, no Node.js needed). Node.js built-in
modules are kept as external. With --no-bundle,
the script is read as-is.
|
v
4. Read assets Load any files declared in the config's `assets`
map into memory.
|
v
5. Blob serialize Build the binary blob: write header (magic, flags,
and exec_argv_extension for V2), then body fields
in order with u64 LE length prefixes.
|
v
6. Copy binary Copy the Node.js binary to the output path. All
subsequent mutations happen on the copy.
|
v
7. Inject Inject the blob into the copied binary using the
platform-appropriate method (Mach-O / ELF / PE).
On Mach-O this includes removing the existing code
signature and relocating __LINKEDIT.
|
v
8. Fuse flip Scan the binary for the fuse sentinel and flip
the state byte from :0 to :1.
|
v
9. Codesign On macOS, run `codesign --sign - --force` for
ad-hoc re-signing. Required on Apple Silicon
because the original signature was removed during
injection.
When bundling is enabled (the default), nodesea uses rolldown
as an in-process Rust library — no Node.js subprocess is spawned. The bundler is
configured with platform: node and format: cjs:
- Local imports (
./lib/utils.js,../shared.js) are resolved and inlined. node_modulesdependencies are resolved and inlined.- Node.js built-in modules (
fs,path,http,crypto, etc.) are kept external — they are provided by the Node.js runtime at execution time. - The output is a single self-contained CommonJS file suitable for embedding.
This means nodesea app.js handles a project with a complex module graph the
same way as a single file — no separate bundling step is needed.
src/
lib.rs -- Public API, module re-exports
main.rs -- CLI entry point (clap)
error.rs -- Error types (thiserror)
config.rs -- sea-config.json parsing and validation
version.rs -- Node.js version detection, V1/V2 selection
bundle.rs -- JavaScript bundling via rolldown
fuse.rs -- Fuse sentinel scanner and flipper
codesign.rs -- macOS ad-hoc code signing
blob/
mod.rs -- Blob types, flags, serialize() dispatcher
v1.rs -- V1 serializer (Node 22.0--22.19, 23.x--24.5)
v2.rs -- V2 serializer (Node 22.20+, 24.6+)
inject/
mod.rs -- Format detection, Injector trait, dispatcher
macho.rs -- Mach-O injection with __LINKEDIT relocation
elf.rs -- ELF PT_NOTE injection
pe.rs -- PE RCDATA injection (planned)