· 5 min read ·

Why Bun and Deno Embed Files Transparently While Node.js Needs a Separate API

Source: hackernews

When Platformatic published their argument for a Node.js virtual filesystem, the immediate comparison point was pkg versus Single Executable Applications. That comparison is fair, but it understates the gap. The better comparison is with Deno and Bun, both of which ship transparent VFS in their compilation toolchains, without ceremony and without patching anything.

Since Deno 1.22, deno compile accepts an --include flag:

deno compile --include ./config.json --include ./public app.ts

Inside the compiled binary, Deno.readTextFile('./config.json') returns the embedded content. Deno resolves relative paths against the module URL, checks whether the resolved path matches an embedded resource, and falls through to the OS only if no match exists. This check is invisible to application code. You do not call a different API. You do not change your dependencies.

Bun 1.1 added embedded assets to bun build --compile:

bun build --compile --assets ./public ./src/index.ts

Bun’s node:fs compatibility implementation is written in Zig. When a compiled binary calls fs.readFile('./public/index.html'), Bun checks an asset map keyed by path before making any OS call. Dependencies that use node:fs read embedded files transparently. Libraries like Prisma or Sharp, which locate their own native components at runtime via filesystem paths, work correctly inside the binary without upstream changes.

Compare this to what Node.js SEA provides:

const { getAsset } = require('node:sea');
const config = JSON.parse(getAsset('config.json', 'utf8'));

getAsset requires you to know at the call site that you are running inside a SEA binary. Dependencies that read their own config files via fs.readFile receive errors. You cannot package most real applications with SEA without rewriting file I/O throughout your dependency tree, much of which you do not own.

Why Deno and Bun Could Do This Without Fighting the Architecture

Both runtimes own their entire filesystem implementation. Deno implements all I/O through an “op” system: a table of capability-gated operations that the JavaScript runtime dispatches into the Rust core. The file-read operation sits behind this table. Adding VFS support meant inserting a path check before the OS dispatch, written by the same team that wrote the dispatch:

// Simplified: real code handles more cases
fn op_read_file(state: &State, path: &str) -> Result<Vec<u8>> {
    if let Some(asset) = state.vfs.get(path) {
        return Ok(asset.to_vec());
    }
    std::fs::read(path).map_err(Into::into)
}

The dispatch table owns the routing decision, and the team that owns the table can modify it. Bun’s situation is the same: the readFile function calls into Bun’s own I/O layer, which the Bun team wrote. Inserting an asset check before the system call is a few lines added to code they already maintain. Neither runtime had to negotiate with a separate C library, patch a foreign binding layer, or decide how to intercept calls that flow through code they do not own. The transparent VFS was straightforward precisely because they built the system being virtualized.

The libuv Constraint

Node.js delegates all filesystem operations to libuv. The call chain for fs.readFile() is:

lib/fs.js          (JavaScript)
  → src/node_file.cc   (C++ binding)
  → uv_fs_read()       (libuv)
  → OS syscall

libuv is a separate project, maintained independently, designed as a portable async I/O abstraction. It has no virtual filesystem hook and no per-path dispatch table. uv_fs_open() calls open(). That is what it does.

To add VFS at the correct layer, the interception point needs to be in src/node_file.cc, before the libuv dispatch. When a filesystem operation arrives, node_file.cc would check a registered VFS provider before calling into uv_fs_*. This is exactly what Electron does in its own Node.js fork to implement ASAR archive support. Electron patches node_file.cc so that paths containing .asar are routed to the archive layer instead of libuv. The interception covers readFile, readFileSync, stat, createReadStream, readdir, and every other fs operation, including calls from native addons that reach the binding layer from C++.

Electron has been shipping this in production since around 2015, which confirms the approach is feasible. The cost is maintaining a fork of Node.js to keep the patch applied across every Node.js release. Upstreaming would mean Node.js core accepts a new hook mechanism in node_file.cc as an official, stable API surface.

The Synchronous Problem

Module hooks, stabilized in Node.js 20.6 as module.register(), communicate between the main thread and a loader thread via SharedArrayBuffer. The main thread blocks on Atomics.wait() while the loader resolves or loads a module. The overhead is acceptable because module loading happens at startup and is relatively infrequent.

fs.readFileSync is different. It can be called anywhere in a running application, including in hot code paths. A protocol that requires the main thread to signal a provider thread and wait for a response on every synchronous read would impose synchronization overhead on every readFileSync call in every application, whether or not VFS is in use. The cost would be paid universally for a feature used by few.

The alternatives each carry their own costs. A synchronous-only provider contract, where VFS providers must be pure in-memory structures, would work for embedded asset maps but would exclude any provider backed by an async source. An extraction-at-startup model, where all virtual files are materialized to a temporary directory before the event loop starts, undermines the single-binary goal and does not generalize to dynamic VFS use cases. Go’s approach, where fs.FS is an interface threaded through the standard library so code explicitly accepts a filesystem as a parameter, sidesteps the sync problem entirely by separating the abstraction from the OS implementation. Node.js has no equivalent interface to thread through the standard library, and retrofitting one would require changes across most of the http, net, and related modules.

Two Plausible Forward Paths

Given the constraints, two approaches are realistic. The first is a binding-level hook in node_file.cc with synchronous-only providers: VFS provider functions registered via a new node:fs API would be called synchronously per path, returning data or yielding to libuv, without async operations. In-memory asset maps fit this model. The API design would look roughly like:

import { registerFileSystemProvider } from 'node:fs';

registerFileSystemProvider({
  prefix: '/virtual/',
  readFile(path, options, callback) {
    const data = assetMap.get(path);
    if (data) return callback(null, data);
    callback(new Error('ENOENT'));
  },
  stat(path, options, callback) { /* ... */ },
});

The second is narrower: extend node:sea so embedded assets are accessible via regular fs.readFile on their original relative paths. If a binary was compiled with ./config.json embedded, then fs.readFile('./config.json') inside the binary returns the embedded content without any getAsset() call. This does not require a general VFS hook system and limits the scope of the change to the SEA use case specifically.

The community fork @yao-pkg/pkg continues to ship against recent Node.js releases, patching process.binding across every version upgrade. The maintenance burden is substantial and the approach is fragile, but developers keep funding it because the need is real. Applications with dependency trees that contain libraries which locate their own resources at runtime simply cannot be packaged with SEA. The getAsset API is not a replacement for transparent VFS; it is a lower-cost feature that addresses a different, narrower use case and leaves the harder problem to the community to solve indefinitely.

Was this interesting?