Writing a Driver

Implement stat, readdir, and open to create a filesystem. All other methods are optional.

A driver can be an object literal. You do not extend a base class or register the object. First, check the built-in drivers. Memory, node-fs, or unstorage can meet your needs directly or provide a base for a wrapper. Write a custom driver when they do not.

#The minimum

A driver must implement three methods. All other methods are optional. A missing method means that the related capability is absent. The mount returns ENOSYS or ENOTSUP.

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 that your driver receives is absolute, POSIX-style, and normalized. The harness and session layer guarantee this. .. stops at the root, so /foo/../../etc/passwd cannot escape the driver root.

The optional methods are the following fs/promises methods: lstat, statfs, mkdir, rmdir, unlink, rename, link, symlink, readlink, chmod, chown, lchown, truncate, utimes, lutimes. Full signatures in the reference.

#A complete filesystem

This example creates a read-only filesystem with one file:

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"

Replace createLoopback(driver) with mount(driver, "/mnt/point") to expose the same code as a directory.

Note

StatsLike, DirentLike and FileHandleLike are the smallest structural shapes the transports need. Node's Stats, Dirent, and FileHandle satisfy them without changes. If you wrap real fs calls, return the values from fs.

#File handles

open() returns a FileHandleLike. It contains read, write, stat, truncate, and close. It can also contain sync and datasync. Like fs/promises, read and write accept (buffer, offset, length, position). They resolve to { bytesRead | bytesWritten, buffer }.

The handles capability states whether the file handle contains persistent per-open state. mountx cannot infer this capability:

  • 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. This is correct but sends more requests. A deleted open file is no longer available.

#Errors

Throw an error with the same shape as a node:fs error. It must contain a POSIX code and a negative libuv errno. fsError() creates a byte-for-byte identical error. mountx sends it to the kernel without translation:

import { fsError, isFsError } from "mountx";

throw fsError("EACCES", { syscall: "open", path });

If a real fs call throws an error, pass it through without a change. It already has the required shape. Use isFsError(error, "ENOENT") as a type guard when you receive an error. Transports use errnoOf(error) to convert any thrown value to a wire errno. Unknown values become EIO.

Transports enforce one rule: exactly one reply for each request. The kernel receives an answer for every thrown value.

Note

ERRNO_CODES in mountx is the Linux errno table, transcribed once. It contains the wire numbers, which can differ from the host numbers. Use fsError instead of manually creating an error.

#Capabilities

mountx infers support from the methods on the driver. Declare capabilities that cannot be inferred from the object shape:

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
readOnlynever — defaults falseevery mutating operation answers EROFS
durableWritesnever — defaults falsea write is durable once its promise resolves

An unset value means that mountx must infer it.

It does not mean false.

The four values marked never default to false because no object shape proves them. readOnly is a common source of errors. It promises that every mutating operation returns EROFS. A driver without unlink, mkdir, or rename can still open files for writing. It can also truncate files and change their modes.

If your driver is read-only, say capabilities: { readOnly: true }.

Evaluate durableWrites from the driver design instead of copying the declaration.

It states that no data remains buffered after write() resolves. Therefore, the driver has no pending result to report when the writing process closes the file.

A driver does not qualify if it keeps bytes in a queue, batch, or upload after resolution. If such a driver declares this capability, a later write error has no destination.

This declaration lets FUSE stop answering FLUSH, one request per close(2) and the third-largest opcode in an install-shaped workload.

mountx does not simulate an unavailable capability. It returns ENOSYS or ENOTSUP. A false success can pass a simple ls test and later corrupt data during a git operation.

#Extensions

Use driver.mountx for operations that node:fs does not provide: utimens (nanosecond timestamps) and mknod (FIFOs, sockets and device nodes).

Transports probe for each member and degrade without it. All three mount transports use mknod. There are four sessions when NFSv3 and NFSv4.1 are counted separately. Without this extension, each session can create only a regular file.

The S3 gateway does not use mknod. Object storage cannot name a first-in, first-out (FIFO) file or device node. utimens is used by FUSE and 9P, whose wires carry the nanoseconds fs.utimes would round away.

Declare capabilities.extensions if you want to be explicit; otherwise the keys of driver.mountx are the answer.

To implement mknod, return the correct S_IF* bits in the mode from stat. Also return an rdev for character and block devices. This information is all that a client needs. The virtual file system (VFS) supplies the pipe, socket, and device behavior.

The memory driver is a complete example of the required storage.

#Developing without mounting

The project tests drivers through createLoopback(driver):

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 the driver receives it. It replaces missing methods with ENOSYS, resolves capabilities, and adds readFile and writeFile over open(). It creates no mount, needs no privileges, and works on every platform.

Anything that works through createLoopback() works through a mount. The conformance suite enforces this design rule. 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 contains one suite for the driver interface. The repository runs it through six paths:

  • the loopback harness
  • a real FUSE mount
  • a JavaScript 9P client against a real session
  • a real NFSv3 socket
  • a real NFSv4.1 socket
  • the S3 gateway

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 tested with pjdfstest, the POSIX filesystem test suite. The test used a real mount of the memory driver. All 8,770 assertions in 238 files passed.

The 45 files that used to fail were all mkfifo/mknod/UNIX-socket creation, and they pass now that a driver can express special files. See mountx.mknod.

It is a root run, because pjdfstest changes uid to test permission enforcement; an unprivileged mount adds nodev, so device-node opens would fail under one.

Breakdown: .agents/pjdfstest-results.md.

#Next

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