Writing a Driver
Three methods — stat, readdir, open — and you have a filesystem. Everything past those is optional.
There is nothing to extend, register or implement beyond writing the methods, and no base class to inherit: an object literal is a driver. Check the built-in drivers first — memory, node-fs and unstorage may already be what you need, or the thing you wrap. What follows is for when they are not.
#The minimum
Three methods are required. Everything else is optional, and a missing method means the capability is absent — the mount answers ENOSYS/ENOTSUP rather than pretending.
interface FsDriver {
stat(path: string): Promise<StatsLike>;
readdir(path: string, options: { withFileTypes: true }): Promise<DirentLike[]>;
open(path: string, flags?: string | number, mode?: number): Promise<FileHandleLike>;
}Every path your driver receives is absolute, POSIX-style and already normalized — the harness and the session layer guarantee that, and .. clamps at the root, so you never have to defend against /foo/../../etc/passwd.
The optional methods are the rest of fs/promises: lstat, statfs, mkdir, rmdir, unlink, rename, link, symlink, readlink, chmod, chown, lchown, truncate, utimes, lutimes. Full signatures in the reference.
#A complete filesystem
A read-only filesystem with one file in it:
import { createLoopback, fsError, S_IFDIR, S_IFMT, S_IFREG } from "mountx";
import type { DirentLike, FsDriver, StatsLike } from "mountx";
const content = new TextEncoder().encode("hello from JavaScript\n");
// `stat` returns the same fields as `fs.Stats`, so a helper keeps it short.
function stats(mode: number, size: number, ino: number): StatsLike {
const now = Date.now();
const is = (type: number) => () => (mode & S_IFMT) === type;
return {
dev: 1,
ino,
mode,
nlink: 1,
uid: 0,
gid: 0,
rdev: 0,
size,
blksize: 4096,
blocks: Math.ceil(size / 512),
atimeMs: now,
mtimeMs: now,
ctimeMs: now,
birthtimeMs: now,
isFile: is(S_IFREG),
isDirectory: is(S_IFDIR),
isSymbolicLink: () => false,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isFIFO: () => false,
isSocket: () => false,
};
}
// A directory entry is a name plus the same set of type questions.
const dirent = (name: string, mode: number): DirentLike => ({ name, ...stats(mode, 0, 0) });
const driver: FsDriver = {
capabilities: { readOnly: true },
async stat(path) {
if (path === "/") return stats(S_IFDIR | 0o555, 0, 1);
if (path === "/hello.txt") return stats(S_IFREG | 0o444, content.length, 2);
throw fsError("ENOENT", { syscall: "stat", path });
},
async readdir(path) {
if (path !== "/") throw fsError("ENOTDIR", { syscall: "scandir", path });
return [dirent("hello.txt", S_IFREG | 0o444)];
},
async open(path) {
if (path !== "/hello.txt") throw fsError("ENOENT", { syscall: "open", path });
return {
async read(buffer, offset, length, position) {
const start = position ?? 0;
const slice = content.subarray(start, start + (length ?? buffer.length));
buffer.set(slice, offset ?? 0);
return { bytesRead: slice.length, buffer };
},
async stat() {
return stats(S_IFREG | 0o444, content.length, 2);
},
// Read-only, so the two writing methods just say so.
async write(): Promise<never> {
throw fsError("EROFS", { syscall: "write", path });
},
async truncate(): Promise<never> {
throw fsError("EROFS", { syscall: "ftruncate", path });
},
async close() {},
};
},
};
const fs = createLoopback(driver);
console.log(new TextDecoder().decode(await fs.readFile("/hello.txt")));
// "hello from JavaScript"Swap createLoopback(driver) for mount(driver, "/mnt/point") and the same code is a folder you can cat.
Note
StatsLike, DirentLike and FileHandleLike are the smallest structural shapes the transports need. Node's own Stats, Dirent and FileHandle satisfy them unmodified — so if you are wrapping real fs calls, you can return what fs gave you.
#File handles
open() returns a FileHandleLike: read, write, stat, truncate, close, and optionally sync/datasync. Both read and write take (buffer, offset, length, position) and resolve to { bytesRead | bytesWritten, buffer }, exactly as fs/promises does.
Whether that handle is real state is the handles capability, and it cannot be inferred:
handles: true— your handle survives the path it came from. A file deleted while open stays readable, and the session keeps the handle for the lifetime of the open.- unset (the default) — the session re-opens from the path for each operation. Correct, just chattier, and a deleted-but-open file is gone.
#Errors
Throw what node:fs throws: an error carrying a POSIX code and a negative libuv errno. fsError() builds one byte-for-byte identical to Node's, and it travels to the kernel untranslated:
import { fsError, isFsError } from "mountx";
throw fsError("EACCES", { syscall: "open", path });If you are forwarding an error from a real fs call, just let it through — it is already the right shape. isFsError(error, "ENOENT") is the type guard for the other direction, and errnoOf(error) is what the transports use to turn anything thrown into a wire errno (unknown values become EIO).
The one hard rule the transports keep for you: exactly one reply per request. Whatever you throw, the kernel gets an answer.
Note
ERRNO_CODES in mountx is the Linux errno table, transcribed once. It is the numbers on the wire — not necessarily your host's, which is why fsError is worth using rather than hand-building an error.
#Capabilities
mountx works out what your driver supports from which methods exist. Two things cannot be guessed from shape, so declare them if they are true:
const driver = {
capabilities: {
handles: true, // open() returns real state that survives unlink
atomicRename: true, // rename() replaces the target in one step
},
// ...
};The full set, and how each is decided when you do not declare it:
| capability | inferred from | meaning |
|---|---|---|
handles | never — defaults false | open() returns real per-open state |
atomicRename | never — defaults false | rename() replaces the destination atomically |
hardlinks | link exists | link() works and nlink is counted |
symlinks | symlink + readlink + lstat | symlinks work and lstat differs from stat |
permissions | chmod exists | mode and ownership bits are stored |
times | utimes exists | timestamps are stored and returned |
truncate | truncate exists | truncation works |
caseSensitive | defaults true | names differing only in case are distinct |
statfs | statfs exists | statfs() returns meaningful numbers |
readOnly | no unlink, mkdir or rename | every mutating operation answers EROFS |
Unset means infer it, never false. And nothing is ever faked: an unmet capability answers ENOSYS/ENOTSUP, because a filesystem that silently pretends is one that passes ls and corrupts data under git.
#Extensions
driver.mountx is a small namespace for what node:fs genuinely does not cover — utimens (nanosecond timestamps), mknod, and the four xattr calls. It is types only for now: no transport implements them yet, and transports probe for each member and degrade without it. Declare capabilities.extensions if you want to be explicit; otherwise the keys of driver.mountx are the answer.
#Developing without mounting
createLoopback(driver) is the harness the whole project tests against:
import { createLoopback } from "mountx";
const fs = createLoopback(driver);
fs.capabilities; // every capability resolved
fs.driver; // the driver you passed
await fs.readFile("/a.txt"); // whole-file convenience, no handle dance
await fs.writeFile("/a.txt", "text"); // create + truncateIt normalizes every path before your driver sees it, fills in missing methods with ENOSYS, resolves capabilities, and adds readFile/writeFile on top of your open(). No mount, no privileges, every platform.
Anything that works through createLoopback() works through a mount — that is the design rule the conformance suite exists to hold. So the loopback is not a mock: it is the same normalization and capability layer a real session applies, minus the kernel.
#Testing your driver
test/conformance.ts in the repository is one suite written against the driver interface, and it is run three ways: through the loopback harness, through a real FUSE mount, and through a real NFSv3 socket. So a test that passes in one column and fails in another is a transport bug by construction, not a driver bug.
Point it at your driver, or write your own checks the same way. The current per-transport tables are in .agents/conformance-matrix.md.
The FUSE transport was also run against pjdfstest, the POSIX filesystem test suite, over a real mount: 59.1% passing (5179/8770 assertions). Every one of the 45 remaining failing files tests mkfifo/mknod/UNIX-socket creation — which the driver interface has no way to express, since node:fs/promises cannot create special files either. Breakdown: .agents/pjdfstest-results.md.
#Next
- Mounting — turning the driver into a folder.
- Driver interface — every type on this page in full, plus capabilities and errors.
- Built-in drivers — memory, node-fs and unstorage, and what they are good for.