Node.js Gained Single Executable Support and Left Its Dependencies Behind
Source: hackernews
The desire to ship a Node.js application as a single executable binary is not new. For years, tools like pkg and nexe filled that gap. They were fragile, required separate pre-built binaries for each Node.js version, and broke on nearly every major release. Node.js 22 finally made Single Executable Applications (SEA) a stable feature, which should have ended that era. It hasn’t, because SEA shipped without the one piece that made pkg actually work.
How pkg Worked, and Why That Was the Problem
pkg (originally from Vercel, now maintained as @yao-pkg/pkg after Vercel archived it in 2023) bundled your application by patching process.binding('fs') at the C++ binding layer before any user code ran. All files were serialized into a snapshot dictionary embedded in the binary, accessible under a virtual root like /snapshot/project/... on Unix or C:\snapshot\project\... on Windows. __dirname and __filename were rewritten at build time to point into that snapshot.
Because the interception happened below lib/fs.js, every call that reached the binding layer went through it: readFileSync, readFile, stat, readdir, createReadStream, and require(). Dependencies did not need to be aware of the bundle. A call like this worked identically whether the file was on disk or embedded:
const data = fs.readFileSync('./config/schema.json', 'utf8');
The problem was process.binding. It is an internal API with no stability guarantees. It changed between Node.js majors without notice. Maintaining pkg meant maintaining separate patched Node.js binaries for every version and platform combination. Vercel ran that treadmill for years before deciding SEA was the right answer and stepping away.
What SEA Actually Gives You
SEA, stable since Node.js 21.7 and Node.js 22, works differently. You write a sea-config.json that lists your main entry point and any assets you want to embed:
{
"main": "app.js",
"output": "sea-prep.blob",
"assets": {
"schema.json": "./config/schema.json",
"index.html": "./public/index.html"
}
}
You run node --experimental-sea-config sea-config.json to produce a blob, then inject it into a copy of the Node.js binary using postject. The embedded assets are accessible at runtime through the node:sea module:
import { getAsset } from 'node:sea';
const schema = JSON.parse(getAsset('schema.json', 'utf8'));
This is a clean, stable API. It does not touch internals. It will not break on the next major release. But it has a constraint that Vercel’s approach did not: fs.readFile('./config/schema.json') returns an error, because the fs module has no knowledge of the asset store. The two systems are completely separate.
Every dependency that calls fs.readFile, fs.stat, createReadStream, or anything similar will fail on embedded paths. That includes most template engines, most schema validators, most ORM configuration loaders, and most anything that reads from a path rather than from a buffer you hand it explicitly. You cannot embed a Prisma schema. You cannot embed Sharp’s libvips binaries. You cannot even embed a simple JSON config file and have it read transparently.
The Platformatic article that sparked the current HN discussion frames this precisely: SEA gave you a storage mechanism, not a filesystem integration. Those are different things.
The Architecture of the Gap
The fs call chain in Node.js looks like this:
JavaScript → lib/fs.js → src/node_file.cc → libuv → OS
Module loading has hooks, introduced stably in Node.js 22 via module.register() (backported to 20.6.0 and 18.19.0). These hooks intercept at the JavaScript layer and handle resolve and load phases. They are sufficient for TypeScript transpilation, mocking, and custom import protocols.
fs has no equivalent. You can monkey-patch require('fs'), but that misses native addons calling libuv directly, does not compose when multiple libraries do it, and fails for code using the node:fs prefix (standard since Node.js 14.18). You can patch process.binding('fs'), which is what pkg did, but you are back to the maintenance trap. You cannot safely insert anything between node_file.cc and libuv without modifying Node.js itself.
The one insertion point that would work, the C++ binding layer with an official API, does not exist yet.
Electron Proved the Fix Twelve Years Ago
Electron ships ASAR, a sequential archive format that has been in production use since 2014. VS Code, Slack, and Discord all ship their application code this way. ASAR patches Node.js at the C++ binding layer, below lib/fs.js, intercepting all fs calls: reads, stats, readdir, streams, and synchronous variants. Native addons that need a real filesystem path are transparently redirected to an app.asar.unpacked/ sidecar directory.
The result is that application code, and more importantly dependency code, requires zero changes. The problem pkg solved with an internal API, ASAR solved with a fork of Node.js’s C++ layer.
ASAR has never been upstreamed to Node.js core. The reason is not technical infeasibility; it is the lack of an agreed-upon API design and the governance overhead of proposing it. Electron maintains a Node.js fork specifically to keep this working across versions, which is itself a significant cost. But the existence of ASAR proves that interception at the C++ layer is the correct architectural location. The --experimental-permission flag in Node.js 20+ further proves this: it intercepts fs calls in node_file.cc before they reach libuv, for the purpose of permission checks. The infrastructure to do this kind of interception is already in the codebase.
Why Deno and Bun Do Not Have This Problem
Deno and Bun both offer compiled single-binary outputs that work transparently with embedded assets, and both have had this feature for longer than Node.js has had SEA.
Deno’s deno compile (with --include since Deno 1.33) routes file reads through its Rust op system. Before falling through to the OS, the Rust layer checks an embedded file map. Since the Rust layer owns the entire I/O path from JavaScript to OS, inserting this check is a routing decision in one codebase, not a patch against an external runtime.
Bun’s bun build --compile (with --assets since Bun 1.1.0) does the same thing in Zig. Because interception lives below the node:fs compatibility shim, native addons get the same virtualization as JavaScript code, which is the one thing pkg could never reliably guarantee.
This is not a feature they built because they are more clever. It is a consequence of owning their runtime stack end-to-end. Node.js is a JavaScript runtime built on V8 and libuv, which are developed separately. Adding an official VFS layer requires coordination across that stack and agreement on what the API surface should look like.
What Other Ecosystems Did
The comparison to other languages is instructive. Go added io/fs.FS in Go 1.16, a minimal single-method interface that the standard library threads through http.FileServer, html/template, and text/template. The //go:embed directive produces an embed.FS at compile time that satisfies this interface. Dependencies written against fs.FS work identically from disk or from an embedded archive.
Python’s importlib.resources (PEP 302, stabilized in 3.9) allows packages to declare data files readable via resources.open_binary('mypackage', 'data.bin'), which works from disk or from a zip archive. zipimport has been in the standard library since Python 2.1.
Java’s ClassLoader.getResourceAsStream() has worked identically from disk or from a JAR since Java 1.0. Spring Boot fat JARs nest JARs three levels deep and Spring’s LaunchedURLClassLoader handles all of it transparently.
In every case, the solution is an abstraction above the OS call, not interception below it. The abstraction is either an interface that dependencies code against explicitly, or a hook that the runtime applies universally at the right layer.
What the Fix Should Look Like
The Platformatic article proposes a registerFsProvider API modeled on the existing module hooks pattern:
import { registerFsProvider } from 'node:fs';
registerFsProvider({
prefix: '/virtual/',
readFile(path, options, callback) {
const key = path.replace('/virtual/', '');
callback(null, embeddedAssets[key]);
},
stat(path, options, callback) { /* ... */ },
readdir(path, options, callback) { /* ... */ }
});
The constraints are real. Synchronous variants (readFileSync, statSync) cannot call async providers, so providers must be able to respond synchronously. Native addons calling dlopen still require a real path, which means extracted sidecars remain necessary for binary modules. Worker threads require separate provider registration.
None of these are insurmountable. The harder part is getting the proposal through the Node.js technical steering committee, designing an API that composes when multiple providers are registered, and deciding where in node_file.cc the dispatch happens without adding latency to every fs call in production.
SEA is a step forward. The asset store works, the API is stable, and the injection tooling through postject is solid. But right now it is a storage format with a bespoke access API, not a filesystem abstraction. Every dependency that reads from a path will not see it. For most real applications, that means SEA’s practical utility is limited to applications written specifically to use getAsset() throughout, which is not how most Node.js applications are structured.
The ecosystem built pkg because it needed something that worked. pkg died because it could not be maintained. SEA arrived but did not close the gap. The next step is the one that should have come first: a stable, official seam in the fs module that lets providers intercept path-based reads at the right layer.