The Filesystem API

If you can use node:fs/promises, you already know this one.

createFs(store) gives you a filesystem with the same method names, the same signatures, and the same error shapes as node:fs/promises. Underneath, one named ref ("head" by default) points at the current root. Every change writes a new snapshot and moves that ref forward, so the tree you had before is still there.

#What you get

  • ReadingreadFile, readdir (including recursive), stat, lstat, access, realpath, readlink
  • WritingwriteFile, appendFile, truncate
  • Structuremkdir, rmdir, rm, unlink, rename, copyFile, cp, symlink, link, mknod, mkfifo
  • Metadatachmod, chown, lchown, utimes, lutimes
  • Livewatch, and open for file handles

The l-prefixed calls act on a symlink itself rather than following it, the same way lstat differs from stat.

stat and lstat resolve to a Stats, and readdir(path, { withFileTypes: true }) to Dirent[]. Both match node's shapes. constants carries the access flags (F_OK, R_OK, W_OK, X_OK) along with the S_IFMT mode bits you need to read Stats.mode.

Two methods have no node:fs counterpart. fs.head() returns the Ref the tracked ref currently points at, or undefined if nothing has been written yet. And mknod creates the special files node can't — see below.

#Special files

A tree can hold character and block devices, fifos and sockets, and mknod is how you put one there:

import { constants } from "unfs";

await fs.mknod("/dev/null", constants.S_IFCHR | 0o666, [1, 3]);
await fs.mkfifo("/run/pipe", 0o600);

const s = await fs.lstat("/dev/null");
s.isCharacterDevice(); // true
[s.major, s.minor]; // [1, 3]

mode carries the type in its S_IFMT bits, the way the syscall does — S_IFCHR, S_IFBLK, S_IFIFO or S_IFSOCK, plus permission bits. The three types with a call of their own (S_IFREG, S_IFDIR, S_IFLNK) are rejected with EINVAL; use writeFile, mkdir and symlink.

The device number is required for S_IFCHR and S_IFBLK. Pass [major, minor] and it is stored exactly. Pass a single number and it's read as a Linux dev_t — the packing node:fs reports on Stats.rdev, so a value you got from a real stat() on Linux round-trips. Either way Stats.major and Stats.minor give you back the exact pair, and Stats.rdev the packed form.

unfs records a special file; it doesn't implement one. Nothing is enforced and nothing is opened, so readFile or open on a device or fifo fails with EINVAL, exactly as it does on a directory-shaped mismatch. Everything type-agnostic — readdir, rename, cp, chmod, unlink, packs, gc — carries it through untouched. If you go on to materialize a tree onto a real disk, that's where a device node becomes a live kernel interface and where you should vet it; the security considerations cover it.

#Where it differs from node

Four differences, all deliberate:

  • You get Uint8Array, not Buffer. There are no node: imports anywhere inside unfs, so it runs anywhere, and it hands you the standard type.
  • No numeric file descriptors. open returns a FileHandle object, and there's no openSync.
  • Permissions are recorded, not enforced. Modes and owners are kept faithfully, but nothing checks them.
  • Writes are deterministic. Identical trees come out byte-identical, and mtimes are only stamped if you ask for them with createFs(store, { now: Date.now }).

#When something goes wrong

Every failure throws an FsError. It's an Error subclass carrying code (an FsErrorCode, such as "ENOENT") along with errno, syscall, and path. Two-path operations like rename and copyFile add dest.

import { FsError } from "unfs";

try {
  await fs.readFile("/missing.txt");
} catch (error) {
  if (error instanceof FsError && error.code === "ENOENT") {
    // handle missing file
  }
}

FsErrorCode is importable, and covers every code the API can throw.

#Multiple writers

A Unfs instance serializes its own mutations, and UnfsSync doesn't need to — each of its calls is one uninterruptible synchronous run. Neither says anything about a second writer: another process over the same on-disk store, or a second createFs over the same Store. There, every mutation is a read-build-commit against one shared ref, and without help the loser's whole commit is dropped — silently, files and all.

So the facade commits with compare-and-set. The ref moves only if it still holds the value the mutation started from, and a mutation that lost re-runs itself — re-resolving the path and rebuilding against the winner's tree. Nothing to configure:

// Two processes over one .data/store directory, no coordination between them.
const fs = createFs(await openStore(fsStore(".data/store")));
await fs.writeFile("/notes/mine.txt", "safe");

It's on wherever the store can do it. unfs checks the driver for a compare-and-set and uses it if it's there: memoryStore(), fsStore, sqliteStore and lmdbStore all have one. Layered stores, the HTTP store and the unstorage adapter don't, so a filesystem over those keeps the old last-write-wins behaviour rather than failing.

createFs(store, { atomic: false }); // force the plain write, single writer only
createFs(store, { atomic: true }); // require it — ENOSYS if the driver has none
createFs(store, { retries: 100 }); // more re-runs before giving up (default 32)

Five things to know:

  • Losing repeatedly is EAGAIN. The retry budget is a bound on how long you wait, not a promise of success. 32 held for eight processes appending to one file twenty times each, and started to show a tail at thirty; raise it or catch EAGAIN if yours is hotter.
  • Retries don't back off. They re-run immediately, so the writer with the most work to redo is the one that keeps losing. That's a fairness limit, not a throughput one — it's why the budget is a bound on waiting rather than a guarantee.
  • EAGAIN is not EBUSY. EBUSY comes from a driver that couldn't take a lock it needed, so nothing was compared — often a lock a crashed writer left behind. That one is never retried automatically.
  • batch reports instead of retrying. Re-running a batch means re-running your callback, and its side effects aren't ours to repeat. A batch that loses the race throws EAGAIN with its work discarded; call it again if the callback is safe to repeat.
  • Your own read-modify-write is still yours. Two processes that readFile, add a line and writeFile will still lose a line — the read happened outside the mutation, so re-running the mutation can't redo it. What's protected is the tree: neither process loses the other's other files. Where the whole read-modify-write has to be atomic, appendFile does its read inside the mutation (so a re-run appends to the winner's bytes), and anything more involved wants your own lock.

#Watching for changes

for await (const { eventType, filename } of fs.watch("/", { recursive: true })) {
  console.log(eventType, filename); // "rename" | "change", path relative to "/"
}

These aren't OS notifications. They come from diffing the root tree every time the ref moves, which has some useful consequences: a batch reports one coherent set of events when it commits, and changes made through a second handle on the same store show up too.

  • Watching a file reports that file.
  • Watching a directory reports its direct children, or every descendant with { recursive: true }.
  • Abort options.signal to stop iterating. It throws an AbortError, same as node.

Two things watching can't do. There's no synchronous version, and polling a shared on-disk store from another process produces no events at all.

#The synchronous version

createFsSync gives you the node:fs-style *Sync surface (readFileSync, writeFileSync, mkdirSync, and the rest) over any store whose driver answers synchronously. That means memoryStore(), fsStoreSync() for the same thing on disk, sqliteStore() for it in one file, or lmdbStoreSync() for it in LMDB. Point it at an async driver and calls fail with ENOSYS.

Both views can share one store:

import { createFsSync } from "unfs";

const fsSync = createFsSync(store); // same store, same "head" ref
fsSync.writeFileSync("/docs/note.txt", "written sync\n");
await fs.readFile("/docs/note.txt", "utf8"); // the async view sees it

unfs  A filesystem you can put in any key–value store.