· 6 min read ·

The Proof Node.js Already Has: Why SEA Doesn't Use the fs Interception It Built

Source: hackernews

Node.js Single Executable Applications became stable in Node.js 22. The feature lets you bundle a JavaScript application into a self-contained binary, which is something the ecosystem has wanted for years. Tools like pkg and nexe existed for that purpose, but both required maintaining patched Node.js builds and both are now effectively abandoned. SEA was supposed to close that chapter.

It didn’t, because it left out the one piece that made pkg actually work: transparent file system access for embedded assets.

What Breaks and Why

Consider any npm package that ships its own data files. ajv reads JSON Schema meta-schemas at runtime. sharp loads precompiled WASM modules for image processing. Template engines read .html files. Config validators read .json schemas. Every one of these packages has code that looks roughly like this:

// somewhere inside a dependency
const schema = JSON.parse(
  fs.readFileSync(path.join(__dirname, 'schemas/draft-07.json'), 'utf8')
);

In a normal Node.js process, this works. In a SEA binary, it silently fails or throws ENOENT, because the file doesn’t exist on the real filesystem. It’s embedded in the binary blob, but fs.readFileSync never looks there.

The SEA asset API requires that you use sea.getAsset(key) to retrieve embedded files. That means rewriting every piece of code that reads embedded assets — including code inside your dependencies — to use a completely different API. Libraries would need conditional branches checking process.isSEA(). The whole ecosystem of packages that bundle their own data files would need to be rewritten, and they’d need to stay compatible with normal Node.js execution at the same time.

This is not a minor inconvenience. It makes SEA unusable for any application with real dependencies.

How pkg Solved It

The reason pkg worked for most applications without source changes is that it patched Node.js at the C level. Specifically, it modified the libuv filesystem call paths — uv_fs_open, uv_fs_stat, uv_fs_read — to intercept any path that matched a virtual prefix (typically /snapshot/ on Linux and macOS, C:\snapshot\ on Windows). If the path matched, it served content from an in-memory buffer embedded in the binary. Otherwise, it fell through to the real operating system.

This worked because every fs operation in Node.js, regardless of how it’s called from JavaScript, eventually lands on a uv_fs_* function. There is no path from JavaScript to the disk that bypasses libuv. Intercept at that level and you intercept everything: fs.readFileSync, fs.createReadStream, require(), fs.promises.readFile, and native addons calling into libuv directly.

The cost was maintenance. Every new Node.js version required updating the patch and rebuilding the target binaries. When Vercel archived pkg in 2023, the last supported target was Node 18. The approach worked, but it was architecturally unsustainable.

The Part Node.js Already Built

Here is the thing that the Platformatic post raises but doesn’t quite make explicit: Node.js core already contains code that does C-level fs interception. It ships in every Node.js 20+ install. It’s called the experimental permission model.

When you run Node.js with --experimental-permission --allow-fs-read=/specific/path, the runtime restricts filesystem access to that path. That restriction is implemented by wrapping the same uv_fs_* calls that pkg patched, inside Node.js’s C++ layer (node_file.cc and the permission system in src/permission/). Before any file operation proceeds, Node.js checks the path against the permission manifest and either allows or denies it.

The architecture for interception exists. Node.js core already walks the path from JavaScript → C++ binding → permission check → libuv. The permission model is a read-only gating mechanism sitting at exactly the right layer. A VFS hook would be the same thing, but instead of blocking access, it would redirect the call to a registered handler.

This is not a theoretical claim. The hard architectural work — finding the right insertion point, making it work across all fs entry points, threading it through the binding layer — is done. It just isn’t exposed as a general-purpose hook.

What Go Did Instead

Go’s approach to this problem is worth understanding, because it shows what a clean design looks like from the start rather than retrofitted later.

Go 1.16 introduced the io/fs package, which defines a FS interface that anything can implement:

type FS interface {
    Open(name string) (File, error)
}

The standard library’s http.FileServer, html/template parsing, and most other file-consuming APIs accept an fs.FS rather than operating on the real filesystem directly. Go 1.16 also introduced //go:embed, which lets you embed files into a binary at compile time and access them via embed.FS, which implements fs.FS.

The result: any code written against fs.FS works transparently with real files, embedded files, zip archives, or any custom implementation. The interface is the seam. You don’t need to patch anything.

Node.js’s fs module has no equivalent abstraction. It’s not an interface that different implementations can satisfy; it’s a concrete binding over libuv. There is no seam between the JavaScript API and the C++ implementation that would let you swap in a different backing store. That’s why pkg had to go below the seam entirely, and why SEA bypassed it with a separate API.

What a Proper Node.js VFS Would Need

A minimal viable VFS for Node.js would require two things.

First, a registration mechanism in the fs module (or a new node:vfs module) that lets you register a handler for a path prefix:

import { registerFileSystem } from 'node:vfs';
import { getAsset } from 'node:sea';

registerFileSystem('/snapshot/', {
  stat(path, callback) { /* ... */ },
  open(path, flags, mode, callback) { /* ... */ },
  read(fd, buffer, offset, length, position, callback) { /* ... */ },
});

Second, this registration would need to wire into the same interception point that the permission model uses — the C++ layer between the JS binding and libuv — so that native addons and internal Node.js code see the virtual paths as well, not just JavaScript code that happens to import fs.

The implementation complexity is real. fs has roughly 80 distinct functions across synchronous, callback, and promise variants. A handler interface that covers all of them without gaps would be substantial. But the Node.js team has already navigated this surface area for the permission model. The file that would need to grow, node_file.cc, is already structured around the idea of interceptable fs calls.

The Current Workarounds

In practice, projects using SEA today either abandon the feature for anything with real dependencies, or they pre-bundle everything with esbuild or rollup and then rewrite asset access patterns throughout the application. The pre-bundling step inlines all modules, which means require and import work. The asset rewriting step is manual and fragile.

Libraries like memfs provide in-memory filesystem implementations that satisfy the fs module’s API shape, but they only intercept JavaScript code that imports the patched fs reference. They don’t intercept C++ code, native addons, or anything that holds a reference to fs before the patch is applied. Their scope is testing, not production VFS.

Where This Leaves the Ecosystem

Deno’s deno compile and Bun’s bun build --compile both provide transparent VFS in their standalone binaries, because both runtimes own their full filesystem implementation. Deno’s deno_fs crate defines a FileSystem trait in Rust; compiled binaries use an EmbeddedFileSystem that implements that trait. Bun reimplements the Node.js-compatible fs module in Zig with a VFS layer built in from the start. Both runtimes were designed knowing that single-binary compilation was a goal, so the abstraction layer was put in place before the implementation was written.

Node.js’s fs module predates its use in production servers, let alone standalone binaries. It grew organically over fifteen years with no interface boundary because there was no reason to add one. Retrofitting one now requires coordinating changes across the C++ binding layer, the libuv integration, and the JavaScript API surface — all without breaking the vast existing ecosystem that depends on the current behavior.

That’s the actual engineering challenge. The mechanism is already present in the codebase, courtesy of the permission model. What’s missing is the decision to expose it as a first-class VFS hook that SEA and other tooling can use. The Platformatic post is right that Node.js needs this. The more specific version of the argument is that the Node.js core team has already done the hard architectural work — they built a C-level fs interceptor and shipped it in v20. The remaining work is making it general-purpose rather than security-only, which is a policy and API design problem more than a systems programming one.

Was this interesting?