mountx logomountx

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:

capabilityinferred frommeaning
handlesnever — defaults falseopen() returns real per-open state
atomicRenamenever — defaults falserename() replaces the destination atomically
hardlinkslink existslink() works and nlink is counted
symlinkssymlink + readlink + lstatsymlinks work and lstat differs from stat
permissionschmod existsmode and ownership bits are stored
timesutimes existstimestamps are stored and returned
truncatetruncate existstruncation works
caseSensitivedefaults truenames differing only in case are distinct
statfsstatfs existsstatfs() returns meaningful numbers
readOnlyno unlink, mkdir or renameevery 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 + truncate

It 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

mountx logo

mountx  Write a filesystem in JavaScript, mount it as a real folder.