Node Module Loading
Mount a unfs tree into Node itself, so require() and fs.readFileSync serve files straight out of the Merkle DAG.
@platformatic/vfs is a node:fs-compatible virtual filesystem with file descriptors, streams, and the part that makes this worth doing: mount points with module hooks. Mount a subtree and Node's global fs, require(), and import transparently read from it. It reads and writes through a pluggable VirtualProvider, and unfs makes a good one.
Note
Unlike unfs/unstorage and unfs/just-bash, this adapter does not ship with the library. vfs's provider surface is Buffer-shaped, and the only node: imports in src/ are the ones the on-disk store needs. It lives in examples/platformatic-vfs/unfs-provider.ts as a file to copy, around 240 lines with comments.
#How the provider works
vfs drives providers through synchronous primitives, and unfs has a fully synchronous facade, so the mapping is close to 1:1. UnfsProvider holds two facades over the same store and ref:
- A synchronous
UnfsSync(createFsSync) serves every primitive vfs requires:statSync,lstatSync,readdirSync,mkdirSync,rmdirSync,unlinkSync,renameSync,openSync, and the symlink trio. It also overridesreadFileSync,writeFileSync,appendFileSync, andcopyFileSyncdirectly, short-circuiting the base class's route through a file handle. - An async
Unfs(createFs), exposed as.unfs, for the content-addressed extras vfs has no notion of.
Both point at one key–value backend, so each sees the other's writes immediately.
unfs's Stats and Dirent are already node:fs-shaped, so they pass straight through. The withFileTypes branch only casts, because vfs's VirtualDirent is a different nominal class with the same shape. The VirtualProvider base class synthesizes everything else (readFile, exists, access, internalModuleStat, and the rest) from those primitives.
#Loading modules from a DAG
import { createRequire } from "node:module";
import { create } from "@platformatic/vfs";
import { initStore, memoryStore } from "unfs";
import { UnfsProvider } from "./unfs-provider.ts";
const store = await initStore(memoryStore());
const provider = new UnfsProvider(store, { now: () => Date.now() });
const vfs = create(provider); // module hooks are on by default
vfs.mount("/app"); // external /app/x → provider (unfs) path /x
vfs.mkdirSync("/app/src", { recursive: true });
vfs.writeFileSync("/app/package.json", JSON.stringify({ name: "demo", main: "src/index.js" }));
vfs.writeFileSync("/app/src/index.js", "module.exports = (who) => `hello, ${who}!`;\n");
const require = createRequire(import.meta.url);
require("/app")("world"); // "hello, world!" — resolved via package.json main, loaded from the DAG
require("node:fs").readFileSync("/app/src/index.js", "utf8"); // the global fs, served by unfs
vfs.unmount(); // the real fs and require are backThe mount window is external only. Paths reach the provider with the prefix stripped, so the same file is /src/index.js to unfs.
await provider.unfs.head(); // the root CID after those writes
await provider.unfs.exportPackBytes("/"); // the package as one CARv1 archiveThat last line is the point of the combination. A package Node just loaded is also a hash-verified pack you can move to another store, and every write along the way was a snapshot rather than an overwrite.
#Things to know
- The store must be synchronous. vfs has no async-provider path, so the provider is built on
createFsSync, which needs a synchronous KV driver.memoryStore(),fsStoreSync()andsqliteStore()are the three. An async backend, whether a network KV or unstorage, makes the sync facade throwENOSYS, and there's no way around that from this side. - Module hooks patch process globals.
vfs.mount(prefix)patches the livenode:fsmodule andrequire()/importin place, scoped to paths under the mount, and reverts onunmount(). From an ES module, reach them throughcreateRequire(import.meta.url), since the ESM namespace snapshots the originals and animport * as fsbinding won't see the patch. Passcreate(provider, { moduleHooks: false })for the vfs instance API with no global patching. - Writes flush eagerly. The file handle persists to the store on every mutation, because unfs snapshots are cheap, so
statSyncalways reflects the latest bytes andcloseSynchas nothing left to do. copyFileSyncis instant. It maps to unfs's own copy, which shares blocks instead of moving bytes.- Permissions are recorded, not enforced, and mtimes only appear if you pass a
now. Both are properties of unfs rather than of this adapter.
#A runnable example
examples/platformatic-vfs/ builds a small CommonJS package through vfs, require()s it out of the store (both a direct file and a bare directory resolved via package.json "main"), reads the same tree through the async unfs API after unmounting, and exports it as a pack.