Recently, I landed a change that moves the Single Executable Application (SEA) build process directly into Node.js core - a hobby project I’d been tinkering with for some time.

It took some effort to understand the existing implementation, and the PR waited weeks for review because many earlier contributors had moved on while most current Node.js contributors were unfamiliar with SEA internals. While I may not be the most knowledgeable person on this subject, sharing my journey might help future contributors, so here’s a blog post about it.

What is SEA and what Changed?

Support for building Single Executable Applications (SEA) in Node.js has been in development for a few years, first shipping in v19.7.0, with several major updates in later releases. This feature helps developers package their applications into a single executable, simplifying the distribution process especially in environments where the end users may not have Node.js installed.

Previously, part of the SEA build process was delegated to an external tool, with nodejs/postject serving as a reference implementation. This workflow exposed low-level details that could bewilder newcomers:

1
2
3
4
5
6
7
echo 'console.log(`Hello, ${process.argv[2]}!`);' > hello.js 
echo '{ "main": "hello.js", "output": "sea-prep.blob" }' > sea-config.json
cp $(command -v node) hello
node --experimental-sea-config sea-config.json
npx postject hello NODE_SEA_BLOB sea-prep.blob \
--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
./hello world # Prints "Hello, world!"

The new workflow moves the injection step into Node.js core and makes it accessible via the --build-sea flag, simplifying the build process. Users now no longer need to install an external tool or know the details of the SEA layout:

1
2
3
4
echo 'console.log(`Hello, ${process.argv[2]}!`);' > hello.js 
echo '{ "main": "hello.js", "output": "hello" }' > sea-config.json
node --build-sea sea-config.json
./hello world # Prints "Hello, world!"

Archaeology of SEA in Node.js

To understand how we got here, let’s look at the history. I wasn’t involved in the initial development, so I pieced this context together by excavating past discussions.

Before native support began under the Node.js project umbrella, plenty of ecosystem tools already existed for packaging Node.js applications. They often faced challenges requiring support from core, and this came up during the 2021 Node.js Next-10 Summit, where attendees brainstormed what could be implemented in Node.js core to better support this use case. Later, SEA was added to the list of technical priorities as part of the research carried out by the Next-10 initiative - another volunteer-led project investigating community needs.

In early 2022, Michael Dawson started prototyping SEA support on Linux, and several contributors provided feedback on injection processes and binary layouts. Over the following months, a group of contributors pushed it forward - gathering requirements, designing platform-specific layouts, and implementing support. You can read more in this issue, this blog post, and these notes from the 2022 Collaboration Summit. These efforts led to the introduction of nodejs/postject for resource injection and the initial built-in support for loading those resources by Darshan Sen in Node.js v19.7.0. Several ecosystem tools have since adopted this capability and built upon it.

Slowdown of SEA Development

As with many areas in Node.js, contributors come and go. After the initial burst of progress, SEA development slowed. Remaining contributors tried to call for sponsorship from corporate users, but unfortunately, none of those efforts bore fruit.

As I noted in another post: Node.js is a community project powered by volunteers, not a product developed by a company that owns it. Even when a volunteer-led investigation identifies a technical priority for the community’s benefit, that does not translate into funded work, and development can still stall forever if no one steps up to push it forward.

Occasionally, a feature aligns well enough with a company’s interests that their engineers can contribute during work hours, leading to acceleration for a while. But a specific company’s use case doesn’t always overlap with the broader ecosystem’s needs, and work beyond what’s useful to a sponsor isn’t always sustainable. When sponsorship ends, the feature returns to relying on volunteers’ patchy spare time, and progress becomes sporadic again. This is a recurring pattern in Node.js development, and SEA was just another example.

Moving SEA Building into Core

After the initial release in v19.7.0, development of Node.js SEA support gradually branched into different areas:

  1. Packaging application code, assets, and runtime configurations into a binary format.
  2. Injecting these packaged resources into the Node.js binary.
  3. Discovering, deserializing and loading these resources at runtime in Node.js core.
  4. Virtual file system to access these resources from application code.
    • This largely remained unfinished, though there was a lot of groundwork laid out in this document and now ongoing work in this PR

Over the last two years, I occasionally helped out with SEA support, mostly in areas 1 and 3, so I wasn’t totally unfamiliar with the implementation - though I had not looked into the details of 2 until recently. By earlier designs, 2 had been delegated to external tools. The Node.js documentation explained how the injection should be done, and postject served as a reference implementation, leaving room for alternative injection tools to grow. In the end, however, no one seemed interested in developing alternatives, so postject became the only tool used for this job. Over time, it went unmaintained and issues piled up. Because it was distributed as an external tool via WebAssembly, debugging was harder. The external step wound up adding complexity without much benefit.

At the Node.js collaboration summit in 2025, fellow contributor Marco Ipollito asked if SEA building could be moved into Node.js core to improve its UX. Darshan Sen mentioned that this had been discussed before and there were concerns about binary size increases, since postject relied on the LIEF library, which was quite large. That made me curious - I figured a specialized library dedicated to twiddling bytes can’t be that big after compilation, and the linker could shed even more weight. While this seemed like an interesting nerd-sniping quest, other participants at the summit already showed vague interest in investigating, so this just slipped my mind.

Picking up the Experiment

Months later, while janitoring the Node.js issue tracker, I bumped into a question about addons in SEA and noticed there wasn’t documentation on how to use addons in SEA. I opened a PR on a whim to add more tests and documentation, but hit a mysterious failure on ARM64 Linux. Looking into it, I found that debugging postject through a WebAssembly build added quite a bit of friction. Since I wasn’t really committed to fixing edge cases for SEA in my free time, I shelved the PR.

One day, the discussions about moving the SEA building process into Node.js core from the summit somehow popped back into my mind. It seemed like a good way out of the WebAssembly complexities and could potentially speed up injection (which turned out to be true). The question of how much binary size would increase from linking LIEF statically was also intriguing. While it would require quite a bit of commitment - probably why there had not been any sign of actual progress months after the summit - the idea seemed too nerd-sniping to ignore, so I decided to take a stab at it to satisfy my curiosity.

How SEA Works - Binary Surgery

Now might be a good time to explain how SEA works under the hood, while my memory is still fresh.

Early packaging solutions for Node.js applications often required compiling Node.js from source to embed code. Postject avoided this by acting as a specialized “binary surgeon”: it opens the binary, finds the right spot to insert resources, performs the insertion, adjusts headers/offsets to account for the new data, and stitches everything back together into a valid executable. This also enables better cross-platform support (e.g., modifying a Windows PE executable on Linux).

(An alternative approach, used by pkg and some other tools, is to append resources at the end of the binary and do some minimal fixups. From reading past discussions, the injection approach was chosen for better platform conformance and signing support.)

Being a binary surgeon is delicate work. Different operating systems have different executable formats (e.g., ELF for Linux, PE for Windows, Mach-O for macOS), each with its own structure and conventions. The surgeon needs to be well-versed in the anatomy of these executables to avoid corrupting the binary. This is where LIEF comes in - it abstracts away the complexities of different executable formats, providing a high-level API for manipulating binaries. Think of it as a set of modern surgical instruments that help the surgeon perform operations more precisely and safely.

As designed, SEA resources are injected into the Node.js binary in a platform-specific manner. According to the SEA documentation, the resources are stored in:

  • a resource named NODE_SEA_BLOB if the node binary is a PE file
  • a section named NODE_SEA_BLOB in the NODE_SEA segment if the node binary is a Mach-O file
  • a note named NODE_SEA_BLOB if the node binary is an ELF file

In Node.js, a fuse NODE_SEA_FUSE_$HASH:0 is defined as a string literal, typically placed in a data section of the binary. The injection process flips the last character to '1', signaling Node.js to load the SEA resources at runtime (this “fuse” technique is also used by Electron).

Take the ELF format as an example, before injection, the binary layout looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|   Content                           |   Description
|-------------------------------------|---------------------------
| [ ELF Header ] |
| |
| [ Program Headers (Phdr) ] |
| - PT_LOAD (R-X) | Maps Node.js core code (.text)
| - PT_LOAD (R--) | Maps Node.js core data (.rodata)
| - PT_DYNAMIC |
| |
| [ Section: .text ] | Node.js core code
| |
| [ Section: .rodata ] | Read-only data containing string literals
| ... |
| "NODE_SEA_FUSE_...:0" | <--- FUSE IS '0'
| ... |
| |
| [ Section Headers (Shdr) ] |

After injection, it looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|   Content                           |   Description
|-------------------------------------|---------------------------
| [ ELF Header ] |
| |
| [ Program Headers (Phdr) ] |
| - PT_LOAD (R-X) |
| - PT_LOAD (R--) |
| - PT_DYNAMIC |
| - PT_NOTE (NEW) ----------------+ | <--- 1. NEW HEADER ADDED
| | | (Points to blob below)
| | |
| [ Section: .text ] | |
| | |
| [ Section: .rodata ] | |
| ... | |
| "NODE_SEA_FUSE_...:1" | | <--- 2. FUSE FLIPPED
| ... | | ('0' -> '1')
| | |
| [ Section: .note.node.sea ] <---+ | <--- 3. PAYLOAD INJECTED
| Name: "NODE_SEA_BLOB" | (Contains user application code,
| Desc: [ ...Binary Data... ] | assets, configurations)
| |
| ... |
| [ Section Headers (Shdr) ] |

When Node.js runs - whether the original or the SEA version with injected resources - it can quickly check the last character of the string literal. If the mark is flipped, it locates the SEA blob from the designated locations, deserializes the application code and assets, and bootstraps from there.

Self-Operating with a Doppelgänger

While a human surgeon may have trouble operating on themselves without a supernatural doppelgänger, it’s no big deal for a program to operate on a copy of itself. The new workflow can just read the Node.js binary (or a specified executable for a different platform) into memory, performs the surgery, and writes the modified binary to disk. We just needed to port the injection logic from postject into core - effectively teaching Node.js to be its own surgeon.

Since postject relied on LIEF for cross-platform injection, to maintain backport compatibility, the first step towards porting the injection into core would be adding LIEF as a dependency.

Integrating LIEF into the Node.js Build System

To get an idea of the binary size increase from adding LIEF, I first tried building examples from LIEF to see how big they were. They turned out to be only around 4 MB even without stripping symbols. That seemed promising, so I started integrating LIEF into the Node.js build system.

For unfamiliar readers: Node.js uses a fork of gyp - a legacy build system left by older versions of V8. LIEF itself uses CMake, but adding CMake as another build dependency would have too many implications for the CI and downstream distributions, which could derail the effort. So I’d need to port the CMake-based build process into one that gyp can handle.

While LIEF has a bunch of optional features and dependencies, since this won’t be exposed to users anyway, I decided to just hard-code what’s necessary for SEA in the gyp build. I first downloaded the latest release of LIEF and poked around its CMake configurations to see what was necessary for SEA’s use case:

1
2
cmake -G Ninja -S . -B build
cmake -LAH -N build # List all cached variables and their values

Then I generated the files for building LIEF as a static library, dumped the generated build commands into a file, and converted them back into the corresponding gyp configurations that would generate similar commands:

1
2
3
cmake -G Ninja -DLIEF_ELF=ON <....> -S . -B build
ninja -n --verbose -C build LIB_LIEF > build_commands.txt
# Write a GYP configuration that emits the same commands

Here comes the tricky part: tweaking the gyp configuration so that it actually worked across platforms with Node.js’s build toolchain. It took a few weeks of on-and-off spare time to get it working (e.g., realizing it must be built with C++17 and with the DEBUG macro undefined to avoid identifier conflicts).

Another challenge was that when building LIEF using CMake, some source files are generated during the configuration process, and for the Node.js build this needed to happen without CMake’s help. Initially I tried porting the preparation process to a script run as an action during gyp configuration, but later I concluded that this would be better done before the source code was checked into deps/LIEF, so it would be easier to spot issues when updating LIEF. I then put together two scripts:

  • prepare_lief.py: prepares a LIEF release into a form ready to be checked into deps/LIEF and built by gyp. It unzips the resource code, unpacks dependencies, expands CMake macros, and moves files around to expected places (so that e.g. third-party headers can be found in the include path).
  • update-lief.sh: pulls down the latest LIEF source zip archive, runs prepare_lief.py to prepare the source files, and patches the deps/LIEF directory with the updated files.

Together with lief.gyp, I managed to get LIEF built as a static library that could be linked into Node.js. The next step was to port the injection logic from postject into Node.js core and see how much the binary size would increase after linking in the needed parts of LIEF.

Porting SEA Injection into Node.js Core

After some initial efforts, I realized that LIEF had gone through several breaking API changes over the years. This presented a problem, since postject had not been updated in the past 3 years, and most of its LIEF usage was outdated. So I faced two options: downgrade LIEF and lose all the bug fixes from recent years, or migrate the injection code to the latest LIEF API - which seemed tricky because the postject code was sprinkled with comments about workarounds that I didn’t understand. Both seemed too complex for a spontaneous hobby, so I shelved the branch again.

During the end-of-year holiday break, I got confined indoors due to bad weather and eventually resorted to reading through the postject code and LIEF documentation when I got bored of books and series. Then I proceeded to:

  • Migrate the injection code to work with the latest version of LIEF.
  • Reuse Node.js internal utilities (logging, error handling, libuv/simdutf integration) in the ported code.
  • Port the sentinel fuse logic from JavaScript to C++ to avoid unnecessary boundary crossing and data marshalling.

The result became src/node_sea_bin.cc.

When I linked everything statically, the binary size only increased by about 5 MB - acceptable for such a useful library that might also be used elsewhere in the future. Besides, two months ago I landed a PR that reduced the Node.js binary by about 8 MB by removing internal symbols, which felt like a license to increase the size back a bit for useful features. ;)

Finishing the PR and Getting it Merged

The hobby project seemed to be coming together - it worked for a smoke test on Linux and macOS - so I opened a PR to test the waters. The response was very positive, and several people expressed excitement about it, which encouraged me to push through the remaining work before the holiday ended.

I only had a MacBook with me on vacation, though I managed to test the ELF injection in a Docker container. I initially planned to wait until I got home to test the PE injection on a Windows machine, but during the next few days I was struck by nostalgia and installed Parallels to play Age of Empires II. So I took the opportunity to try building Node.js in the ARM64 Windows VM. That worked out more smoothly than I expected, and with a few minor tweaks to the build config, I got the PR working on Windows as well.

Next came the tasks I enjoyed the least - writing tests and documentation. Nevertheless, it sparked joy for me to simplify a large chunk of the previous build process in the SEA documentation. Node.js core’s test suite already contained a great deal of SEA tests, so I just updated the helper used by the tests to switch from test/fixtures/postject-copy to --build-sea and checked how feature-complete the implementation was.

While trying to complete test coverage, I noticed there weren’t any existing tests covering re-injection of SEA resources into an already-injected binary. I gave it a try and quickly realized that with the new version of LIEF, overwriting existing resources in an already-injected binary seemed more complicated than before. I decided to drop support for this use case for now and leave a TODO to keep the code simple - this had not been covered by previous tests anyway, and probably wasn’t a very common use case.

I managed to finish the PR just before the holiday ended and sent it out for review. Despite a lot of emotional support from the community and fellow contributors, there were weeks of radio silence on the pull request - from chats with other contributors, I believe this was due to the size of the change and lack of SEA expertise among currently active contributors (which was part of the reason why I wrote this blog post). After a few weeks of waiting, when I started to wonder if this was going to stall, it was approved thanks to reviews from fellow contributors Anna Henningsen and Chengzhong Wu, and finally merged into the main branch. And now it’s available in Node.js v25.5.0, and may be backported to older LTS in the future.

Final Thoughts

The investigation and work done by the earlier SEA contributors laid a good foundation for SEA in Node.js, and remains a great resource to mine for future designs. It would be a pity for that work to stall or for past discussions to go to waste. I hope raising awareness of its state will attract contributors who care more about its progress than a hobbyist like me. The best way to keep things moving is to contribute directly and help out wherever you can, rather than waiting for a “magical Santa” to take on the work.

The Next-10 initiative has served as a great way to investigate community needs and shed light on what contributors can pick up to keep Node.js relevant in the long term. Much recent development in Node.js has been heavily influenced by this initiative, including this work. But as I mentioned in another post, there isn’t a manager in Node.js who does this as a job - whatever project-management-like coordination Node.js gets still comes from volunteers, and like many other volunteer efforts, the Next-10 initiative is facing a shortage of contributor bandwidth. It would be unfortunate to see such initiatives fade away, so please check out the Next-10 repository if you’d like to help keep Node.js moving forward.