Auto

This subpath provides one mount() function that selects a transport. It is separate from the root export, so an import of driver types does not load three protocol stacks.

import { mount, probeTransports, liveMounts, unmountAll } from "mountx/auto";

#mount(driver, mountpoint, options?)

function mount(
  driver: FsDriver,
  mountpoint: string,
  options?: AutoMountOptions,
): Promise<AutoMount>;

This function serves driver at mountpoint with a usable host transport.

It resolves only when the mount point is usable.

The result is the transport's mount object with an added transport property.

Auto adds a tag but does not wrap the object. Therefore, await using, unmount(), and all transport-specific members work as they do with a direct import.

await using mounted = await mount(createMemoryDriver(), "/mnt/point");
mounted.transport; // "fuse" | "9p" | "nfs"

If no transport is usable, the function throws an error that lists the missing requirement for each transport.

#AutoMountOptions

This type is intentionally not a union of the three transport option types. Some transport options have the same name but different shapes. For example, onError receives a FUSE request, an NFS RPC call, or a 9P message header. A merged type would be inaccurate or unusable.

Put options with shared meanings at the top level. Put transport-specific options in fuse: {…}, "9p": {…}, or nfs: {…}. mountx applies transport-specific values after shared values, so they take priority.

interface AutoMountOptions {
  /** "auto" (default), or a name — which skips the probe entirely. */
  transport?: "auto" | "fuse" | "9p" | "nfs";
  /** Mount read-only. */
  readOnly?: boolean;
  /** Unmount on SIGINT/SIGTERM. Default true. */
  signals?: boolean;
  /** Milliseconds an unmount may spend before it is forced. Default 10_000. */
  unmountTimeout?: number;
  /** Report the driver's own ino values instead of synthesising them. */
  useDriverIno?: boolean;
  /** Called for errors raised while answering a request. */
  onError?: (error: unknown) => void;
  /** Called for transport-level failures, and for a forced teardown. */
  onTransportError?: (error: unknown) => void;
  /** FUSE-only options. Applied after the shared ones. */
  fuse?: MountOptions;
  /** 9P-only options. Applied after the shared ones. Quoted: `9p` is not an identifier. */
  "9p"?: MountP9Options;
  /** NFS-only options. Applied after the shared ones. */
  nfs?: MountNfsOptions;
}

Auto ignores option blocks for transports that it did not select. This lets one call work on different hosts.

See MountOptions, MountP9Options and MountNfsOptions for the three nested blocks.

#AutoMount

type AutoMount =
  | (Mount & { readonly transport: "fuse" })
  | (P9Mount & { readonly transport: "9p" })
  | (NfsMount & { readonly transport: "nfs" });

This is a discriminated union. Check the tag to access the complete transport type without a cast:

if (mounted.transport === "fuse") {
  mounted.session; // FuseSession
  mounted.fd; // the /dev/fuse descriptor
  mounted.closed; // Promise<void>, never rejects
  mounted.notifyInvalInode(2n);
  mounted.notifyInvalEntry(1n, "hello.txt");
} else if (mounted.transport === "9p") {
  mounted.server; // P9Server
  mounted.connection; // P9Connection — the kernel's, and the only one this mount cares about
  mounted.trans; // "unix" | "tcp"
} else {
  mounted.server; // NfsServer
  mounted.port; // number
}

All three share mountpoint, source, active, unmount() and [Symbol.asyncDispose].

#probeTransports(platform?)

function probeTransports(platform?: NodeJS.Platform): Promise<AutoProbe>;
interface AutoProbe {
  platform: NodeJS.Platform;
  chosen: Transport | undefined; // what mount() would use
  preference: readonly Transport[]; // the list `chosen` was picked from
  fuse: TransportProbe; // { usable: boolean; reason: string | undefined }
  "9p": TransportProbe; // quoted: `9p` is not an identifier
  nfs: TransportProbe;
  reason: string | undefined; // when nothing can mount, naming all three
}

Call this small probe before you offer a mount.

Its result contains enough detail to show to a user.

It runs all three transport probes and checks the least expensive facts first. It starts with the platform and then checks FUSE. The FUSE check reads /dev/fuse. For an unprivileged process, it also checks fusermount3 and the native addon.

Next, p9ClientProbe() checks root access and 9p in /proc/filesystems. Finally, nfsClientProbe() checks the client binaries and root access on Linux.

Every one of them reads node:fs and nothing else, so it loads no protocol codec on any branch.

The preference order is ["fuse", "9p", "nfs"] on Linux and ["nfs", "fuse", "9p"] elsewhere.

Linux is the only host where more than one transport can work. Therefore, the order matters only on Linux. On other platforms, only NFS can be usable. Other kernels have no v9fs client, and macFUSE uses a protocol that mountx does not implement.

Every transport appears in every preference list regardless of platform: a name missing from it would read as one that was never considered.

Note

Tests can override platform to check Darwin and Windows results from any host. Leave it alone otherwise.

#The 9P module refusal

probeTransports() does not simply forward p9ClientProbe().usable for the "9p" entry.

In one case, the probe reports usable 9P but auto refuses it. The host is Linux, the process has root, and /proc/filesystems lists 9p. However, the host has no 9pnet_fd module and no module tree that can load it. 9pnet_fd registers trans=unix, trans=tcp, and trans=fd. This case can occur in a virtio-only guest or a container with an empty /lib/modules.

p9ModuleRefusal() refines only an already-usable probe. On a non-root or non-Linux host, the probe has already refused 9P.

p9ClientProbe() does not refuse when 9pnet_fd is missing because the result is ambiguous. A kernel with the transport built in looks like a kernel that cannot provide it. Also, mount(8) can run modprobe as root before mounting.

That is the right call for a named -t 9p, where the alternative is refusing a mount that would have worked.

That behavior is not safe for auto because auto does not fall back. It selects one transport. An incorrect 9P choice would fail on a host where NFS could mount.

Therefore, auto rules out 9P only when no module is loaded and no module tree can load one. It changes the usable flag for the "9p" entry, not its place in preference. Every platform keeps all transports in that list.

A direct mountx --transport 9p or transport: "9p" request can still try the mount. If it fails, it returns a more specific error.

The pure, exported seam for this is p9ModuleRefusal(probe: P9ClientProbe): string | undefined.

#liveMounts() / unmountAll()

function liveMounts(): Promise<AutoMount[]>;
function unmountAll(): Promise<unknown[]>;

liveMounts() returns every live mount for transports that mountx/auto loaded.

Each item has a transport tag. unmountAll() unmounts them and never rejects.

It returns all failures in an array.

These functions ask only transports that mount() used. They do not load an unused transport. They also skip a transport module that failed to load.

Caution

These are not filtered to the mounts mount() returned, and they cannot be. Each transport has one process-wide registry of live mounts. Its mount() function adds entries, whether mountx/auto or your code called it.

Assume that you import mountx/fuse directly and create a mount. After mountx/auto also uses FUSE, liveMounts() returns the direct mount. unmountAll() also unmounts it.

Do not rely on either one to leave a mount alone.

They cannot see a transport that mountx/auto never loaded. Auto loads only transports that mount() used.

Mount with mountx/9p directly while mountx/auto only ever chose FUSE, and that 9P mount is invisible to both; tear it down with unmountAll9p().

Closing this gap would require each transport to register itself in a shared module during import. That registration is a load-order side effect. A bundler can remove it because the package declares "sideEffects": false. The documented gap is safer than a registry that can silently disappear from a bundle.

#Re-exported types

This subpath exports Transport, TransportProbe, AutoProbe, AutoMountOptions, and AutoMount. It also exports Mount and MountOptions from FUSE. The 9P types are P9Mount and MountP9Options. The NFS types are NfsMount and MountNfsOptions. A caller needs only one import.

#Three things auto deliberately does not do

  • No fallback after a failure. The probe decides once, from host facts. If the selected transport fails to mount, auto returns that error. A silent fallback could return a filesystem with different behavior.
  • No probing when you name a transport. transport: "fuse" (or "9p", or "nfs") calls that transport directly, whose own errors are more specific than anything the chooser could say.
  • No wrapping. The result is the transport's own mount object with a transport property defined on it. The tag provides access to transport-specific members. FUSE has session, fd, and the notifyInval* pair. 9P has server, connection, and trans. NFS has server and port. await using works for every type.

Auto also follows one loading rule: it does not load unused transports. Every transport arrives through await import(), so choosing one never pulls in either other codec.

#Why FUSE, then 9P, then NFS

FUSE is preferred wherever it works, for two reasons that do not overlap.

For an unprivileged Linux process, FUSE is the only usable mount transport. 9P and NFS both need root.

As root, FUSE communicates directly with the kernel through /dev/fuse. There is no socket or RPC layer between the virtual file system (VFS) and the process. FUSE also has the notify_inval_inode and notify_inval_entry invalidation channels. The other mount transports do not have them. A driver can report changes instead of waiting for cached data to expire.

It is also the transport this project has run pjdfstest against.

9P comes next and NFS last, which is a different argument: both need root, so what separates them is semantics. 9P is stateful, while NFSv3 is not. A file deleted while open stays readable instead of returning ESTALE. close() and fsync() reach the driver, which a handle-buffering driver needs. Requests do not need a handle-table lookup.

See the transport comparison.

macOS uses NFS without root because it is the only supported mount client there. macFUSE is a third-party kernel extension with a different protocol. BSD kernels do not have v9fs.

#Next

  • FUSE: the first choice on a Linux host, in detail.
  • 9P2000.L: the second choice for a Linux process with root.
  • NFS: the default on macOS, the NFSv4.1 option, and serving without mounting at all.
  • Mounting: the lifecycle and the unmount rules, in context.

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