Built-in Drivers

mountx includes three drivers. Two implement every optional method in FullFsDriver. The third adapts a key-value store, which cannot support every filesystem operation.

Use these drivers directly or wrap them. A wrapper can add caching, logging, filtering, or data transformation.

#Memory

import { createMemoryDriver } from "mountx/drivers/memory";

const driver = createMemoryDriver();

This driver provides a complete in-memory filesystem. It supports files, directories, symbolic links, hard links, permissions, timestamps, and statfs. It does not save data after the process ends.

This driver also implements mountx.mknod. Therefore, first-in, first-out (FIFO) files, UNIX sockets, and device nodes are normal in-memory objects. The next section explains special files.

function createMemoryDriver(options?: MemoryDriverOptions): FullFsDriver;

interface MemoryDriverOptions {
  /** Owner of everything created. Defaults to the current process. */
  uid?: number;
  gid?: number;
  /** Bits cleared from every mkdir/open mode. Default 0 — see below. */
  umask?: number;
  /** Mode of the root directory. */
  rootMode?: number;
}

#Why umask defaults to none

A user file-creation mode mask (umask) belongs to a process.

A driver is not a process.

For a mount, the kernel applies the calling process's umask before the mode reaches FUSE_MKDIR or FUSE_CREATE.

If the driver applies a second umask, it uses the server process's value and can create the wrong mode.

For example, create f 04777 can arrive as 04755. pjdfstest found this error.

Set umask explicitly for a loopback filesystem that needs node:fs behavior.

#Special files

import { S_IFIFO } from "mountx";

const driver = createMemoryDriver();
await driver.mountx.mknod("/pipe", S_IFIFO | 0o644, 0);

mknod supports the four types that node:fs/promises cannot create: FIFO, socket, character device, and block device. It also supports a regular file for clients that use mknod to create an empty file. The mode contains the type. dev is the device number. The driver ignores dev for a FIFO or socket.

The node stores the type and device number. The client's virtual file system (VFS) supplies pipe, socket, and device behavior. Therefore, a FIFO created in this driver and exposed through a mount is a real FIFO. mkfifo works on the mount, and a program can open both ends.

A special-file node does not store bytes. This has two intended results:

  • open() and truncate() on one answer ENXIO and EINVAL. Clients handle these types locally and do not send the open across a mount. Therefore, only a loopback caller can reach these methods. A byte buffer would provide behavior that no mount has.
  • Device nodes are not device access. fusermount3 mounts nodev, so a device node made over an unprivileged FUSE mount stats correctly and cannot be opened. That is the mount's rule, not the driver's. FIFOs and sockets are unaffected.

#Node-fs

import { createNodeFsDriver } from "mountx/drivers/node-fs";

const driver = createNodeFsDriver("/home/me/data");

This driver passes operations to a real directory on the host.

function createNodeFsDriver(root: string, options?: NodeFsDriverOptions): FullFsDriver;

interface NodeFsDriverOptions {
  /** Report the driver as read-only (does not itself enforce it). */
  readOnly?: boolean;
}

The driver resolves each path component itself. It does not pass a joined path to node:fs. A request cannot reach data outside the root through .. or through a symbolic link that points outside. Symbolic-link resolution stops after 40 levels, like kernel resolution.

#Unstorage

import { createStorage } from "unstorage";
import s3Driver from "unstorage/drivers/s3";
import { createUnstorageDriver } from "mountx/drivers/unstorage";

const storage = createStorage({ driver: s3Driver({/* … */}) });
const driver = createUnstorageDriver(storage);

This adapter makes any unstorage driver mountable. Examples include S3, Redis, Cloudflare KV, GitHub, an HTTP endpoint, a filesystem, or a Storage that combines several stores in one key space.

function createUnstorageDriver(storage: Storage, options?: UnstorageDriverOptions): FsDriver;

interface UnstorageDriverOptions {
  /** Owner of everything in the tree. Defaults to the current process. */
  uid?: number;
  gid?: number;
  /** Permission bits reported for a file with no chmod applied. Default 0o644. */
  fileMode?: number;
  /** Permission bits reported for a directory with no chmod applied. Default 0o755. */
  dirMode?: number;
  /** Answer EROFS to everything that would write. */
  readOnly?: boolean;
}

unstorage is an optional peer dependency. mountx imports only its types. Install it separately when you use this driver. If you do not install it, mountx does not add it to the runtime import graph.

#How the tree maps onto keys

filesystemstore
/a/b/c.txtthe key a:b:c.txt
a directorya key prefix — there is nothing else to be
a file's contentsgetItemRaw / setItemRaw
mtime, sizethe driver's own getMeta, where it has one

The / to : mapping is the unstorage convention. Its normalizeKey already treats both characters as separators. To serve only a subtree, pass prefixStorage(storage, "base") instead of the complete store.

The adapter returns EINVAL for three characters that cannot make a correct round trip. : is the separator. normalizeKey removes text after ?, so a?b becomes a. unstorage reserves a final $ for metadata and removes these keys from getKeys.

Without the check, a file with that suffix would exist but would not appear in a listing.

#What a key–value store cannot do

  • No symlinks, no hardlinks, no statfs, and rename is a copy followed by a delete rather than an atomic operation. All four are declared, so the mount returns ENOSYS or ENOTSUP instead of reporting false support. See capabilities.
  • Empty directories live in the process, not the store. A key-value store has no separate object for an empty directory. The driver remembers mkdir until the directory contains a file. At that point, the directory becomes a prefix and persists like other stored data. Nothing is written to mark it, because a marker key would show up as a file to every other consumer of that store.
  • Permissions and timestamps are an overlay, held in memory for the life of the driver and seeded from getMeta. Therefore, chmod, chown, and utimes work while the driver runs. Commands such as cp -p and tar -x need these methods. However, the adapter cannot make the underlying store persist unsupported metadata.
  • A key that is also a prefix (both a and a:b exist) has no tree that represents it. The key wins: a is a file, and a/b is ENOTDIR.

#Costs worth knowing before mounting something remote

Each store call can require a network round trip. Count the calls that an operation makes, not only the duration of one call.

  • A listing is the only existence test a directory has. A directory is a key prefix and has no key of its own. Therefore, the existence test for /a is getKeys("a"). On S3, this can require a paginated ListObjectsV2 over the complete subtree just to return a Boolean value. readdir is the same call for the same reason: the Storage interface has no shallow listing to ask for. GetKeysOptions.maxDepth does not reduce this work. Only the fs and fs-lite drivers implement it during listing. For every other driver, unstorage fetches the complete listing and then filters it on the client.

The two implementations also define depth differently. Storage.getKeys counts separators in the absolute key. The fs driver counts levels below the base. There is no "stop after one" in the interface at all.

  • Nothing is asked twice within one call. One operation reuses a listing for all questions about the same prefix. For example, readdir uses it to classify and then list a directory. rmdir and rename use it to classify a destination and then check whether it is empty. The reuse ends when the call does: the store is shared, so an answer kept any longer would be a stale one.
  • Resolving /a/b/c costs a point lookup per component, which is what catches a store holding both a and a:b. The driver issues these lookups together instead of one level at a time. Therefore, the walk requires one round trip rather than one per path level. It uses exactly one listing at any depth.
  • stat of a file falls back to fetching the value to measure it, when the underlying driver's getMeta reports no size.
  • O_TRUNC does not fetch what it is about to discard. open(path, "w") and truncate(path, 0) overwrite the object without first reading it. Otherwise, overwriting a 1 GB object would transfer 2 GB. Any other truncation length keeps a prefix of the value and still has to read it.

#Which to use

  • Memory: tests, demos, scratch space, and the base for a driver whose data comes from somewhere else entirely.
  • Node-fs: the wrapping target. Add caching, an access-control layer, a virtual overlay, request logging, or on-the-fly transformation to an ordinary directory, and everything you did not override still works.
  • Unstorage: anything that is already a key–value store, and the shortest route to mounting a remote one.

#Wrapping one

Drivers are plain objects. Use a Proxy or object spread to wrap one:

import { createNodeFsDriver } from "mountx/drivers/node-fs";
import type { FsDriver } from "mountx";

const base = createNodeFsDriver("/home/me/data");

const logged: FsDriver = {
  ...base,
  async stat(path) {
    console.log("stat", path);
    return base.stat(path);
  },
};

Check capabilities when you wrap a driver. Object spread copies the base driver's declarations and methods. This is usually correct. If a wrapper removes a method, update the declarations because mountx infers capabilities from the object shape.

#node:fs/promises itself

node:fs/promises is also a valid driver. It needs no adapter:

import * as fs from "node:fs/promises";
import { createLoopback } from "mountx";

const loopback = createLoopback(fs); // compiles, no cast

This driver is not confined to a root directory. Its paths refer to the host. Use it as a conformance reference, not as a mount. createNodeFsDriver is the confined version.

#Next

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