The Platformatic article arguing for a virtual file system in Node.js is making the rounds, and most of the discussion has focused on what pkg had that Single Executable Applications don’t: a transparent VFS layer that intercepts fs calls so embedded files look real to any code that reads them. That framing is correct, but it undersells what makes this problem architecturally interesting.
Node.js actually solved an analogous problem for module loading. It took years and several API generations to get there, but today you can register a custom module hook that intercepts every import() and require() call, transforms source code, resolves custom URL schemes, or serves modules from anywhere you want. That hook system works. It’s stable. It composes cleanly with the rest of the runtime.
The fs module has nothing equivalent. That asymmetry is the real story.
How Module Hooks Evolved
The module extensibility story in Node.js started messy. The original mechanism was require.extensions, a mutable object where you could register handlers keyed by file extension:
require.extensions['.ts'] = function(module, filename) {
const source = transpileTypeScript(fs.readFileSync(filename, 'utf8'));
module._compile(source, filename);
};
This was always unofficial and fragile. It only covered CommonJS, it required you to actually read the file yourself, and it composed poorly when multiple packages tried to register the same extension.
The --experimental-loader flag, introduced in Node.js v12, offered a proper ESM equivalent. You could write a loader module that exported resolve, load, and transform hooks, and Node.js would call them at each stage of module loading. But the loader ran in a separate thread and couldn’t share state easily with the main application, which caused persistent frustration.
Node.js v20.6.0 finally landed module.register(), the current approach. It lets you register a module customization hook that handles both CJS and ESM, runs in the same context as your application (via a SharedArrayBuffer-based communication channel with the loader thread), and composes cleanly when multiple hooks are registered:
import { register } from 'node:module';
register('./my-loader.js', import.meta.url);
// my-loader.js
export async function resolve(specifier, context, nextResolve) {
if (specifier.startsWith('virtual:')) {
return { shortCircuit: true, url: specifier };
}
return nextResolve(specifier, context);
}
export async function load(url, context, nextLoad) {
if (url.startsWith('virtual:')) {
return {
shortCircuit: true,
format: 'module',
source: generateModuleSource(url)
};
}
return nextLoad(url, context);
}
This is a clean, composable API. Multiple loaders chain together. You can intercept, transform, or short-circuit at any stage. Libraries like tsx and ts-node use it to make TypeScript work without a separate compilation step.
Why fs Has Nothing Comparable
The module hooks API works because module loading is ultimately a JavaScript operation. Even when it hits the filesystem, it goes through a JavaScript dispatch layer that Node.js controls. Inserting a hook means inserting a function call in a place Node.js already controls.
fs calls travel much further down the stack. When your code calls fs.readFile('./config.json', callback), the path is roughly:
- JavaScript:
fs.readFilevalidates arguments and calls into the binding layer - C++ binding (
node_file.cc): constructs auv_fs_trequest and dispatches it - libuv: queues the request on the thread pool (for most operations) and calls OS-level functions
- OS: the actual syscall
To intercept that transparently, without code changes in calling libraries, you need to insert yourself somewhere in that chain. Each insertion point has a different tradeoff.
At the JavaScript layer: You can replace fs.readFile with a wrapper that checks a registry before forwarding to the real function. This is what simple test mocking libraries do. But it only catches calls that go through node:fs in JavaScript. Native addons that call uv_fs_read directly bypass it entirely. So does any code that uses node:worker_threads with a fresh module scope.
At the process.binding layer: This is what pkg and nexe did. They patched process.binding('fs') to intercept calls before they reached C++. It worked, but process.binding is an internal API that the Node.js team explicitly does not consider stable. The binding interface changed across major versions, which is why both pkg and nexe had to ship patched Node.js binaries for each supported version rather than working with whatever Node.js was installed. It was the right idea in the wrong place.
At the libuv level: libuv does not have a plugin or interception model. Its uv_fs_* functions are direct wrappers around OS calls. You would have to fork or patch libuv to add a hook layer, which is a substantial maintenance burden.
At the C++ binding layer, with official support: This is the only option that could work long-term. Node.js would need to add an official hook point in node_file.cc that checks a registered provider before dispatching to libuv. Something like:
// Hypothetical node_file.cc change
if (auto provider = GetVirtualFSProvider(path)) {
provider->Read(path, req);
return;
}
// Fall through to libuv
uv_fs_read(loop, req, ...);
The JavaScript-facing side of this might look like:
import { registerFileSystemProvider } from 'node:fs';
registerFileSystemProvider({
prefix: '/app/',
readFile(path, callback) {
const asset = getEmbeddedAsset(path.slice('/app/'.length));
if (asset) callback(null, asset);
else callback(new Error(`ENOENT: ${path}`));
},
stat(path, callback) {
const asset = getEmbeddedAsset(path.slice('/app/'.length));
if (asset) callback(null, makeStat(asset));
else callback(new Error(`ENOENT: ${path}`));
},
readdir(path, callback) { ... }
});
The surface area for a complete implementation is larger than it looks. A real VFS needs to handle readFile, readFileSync, stat, statSync, readdir, readdirSync, open, read, close, createReadStream, and the promises variants of all of these. It also needs to deal with Windows path normalization, where C:\app\config.json and /app/config.json are different representations of what might be the same virtual path.
The SEA Asset API Is the Wrong Abstraction
The current node:sea API, added in Node.js v21.7.0, gives you getAsset(), getAssetAsBlob(), and getRawAsset(). These are useful if you control all the code in your binary. They are useless for the broader dependency graph.
Consider a Fastify application that uses @fastify/static to serve files. The plugin internally calls fs.createReadStream on the files it serves. If those files are embedded via the SEA assets config, @fastify/static cannot find them through fs. You would need to fork the plugin, or serve the files manually using getAsset() before handing them to Fastify. Neither option is sustainable at scale.
The same problem applies to any library that reads configuration files, templates, locale strings, JSON schemas, or SQL migrations. The npm ecosystem was built on fs. Offering an alternative API that the ecosystem will never adopt does not solve the distribution problem.
This is exactly the lesson from Go’s //go:embed and embed.FS. The design that made it work was that embed.FS implements io/fs.FS, the same interface that http.FileServer, template.ParseFS, and hundreds of other standard library functions accept. The embedding mechanism composes because it speaks the language the rest of the ecosystem already uses. A node:sea asset that only speaks getAsset() does not compose at all.
What This Would Actually Unlock
A proper fs provider API would have uses well beyond SEA. Test frameworks could register in-memory file system providers instead of mocking fs methods individually, which is fragile and breaks when libraries cache fs references at import time. Edge runtimes that run Node.js-compatible code in environments with no real filesystem could register a provider backed by whatever storage they have. Development tools could intercept file reads to serve transformed versions without creating temporary files.
The module.register() API took years to stabilize because getting the threading model right for module loader hooks required careful design. An fs provider API would face similar challenges: the sync fs variants (readFileSync, statSync) cannot go async, which constrains what a provider can do internally. Implementing a provider that backs reads with a network request would be impractical for sync calls. These are real constraints, not showstoppers.
Node.js already knows how to build extensibility APIs that compose well. The module hook system is evidence of that. The file system is the missing piece, and the SEA feature is the clearest demonstration of why it matters.