NFSv3
A loopback NFS server: no /dev/fuse, no native code, and the only way to mount on macOS.
mountx/nfs implements NFSv3 and MOUNTv3 from the RFCs — 1813 for the protocol, 5531 for ONC RPC, 4506 for XDR — over a plain TCP socket. Every struct is encoded and decoded, so the whole protocol is testable by a JavaScript client built from the same codecs.
import { mountNfs } from "mountx/nfs";
import { createMemoryDriver } from "mountx/drivers/memory";
await using mounted = await mountNfs(createMemoryDriver(), "/mnt/point");
mounted.port; // the port both programs are onThrough mountx/auto this is what macOS gets automatically, and its options move under nfs: {…}.
#Where it mounts
| host | root? | notes |
|---|---|---|
| macOS | no | you must own the mountpoint |
| Linux | yes | mount(8) has no setuid helper for NFS |
| Windows | — | no NFS client worth the name, and no mount(8) |
Root is a Linux requirement, not an NFS one. /sbin/mount_nfs is not setuid and holds no entitlement: macOS is a BSD, and a BSD lets an ordinary user mount onto a directory that user owns. So an unprivileged process there uses the same mount(8) spawn root uses, and the kernel simply forces MNT_NOSUID|MNT_NODEV on the result.
That leaves one precondition the host probe cannot answer — ownership of the mountpoint, which is a fact about a path nobody has passed yet. mountNfs() checks it at mount time and refuses with a message naming the owner.
On Linux there is no equivalent: an unprivileged mount(8) would need an fstab entry marked user, which is not something a library can arrange.
#nfsClientProbe(platform?)
function nfsClientProbe(platform?: NodeJS.Platform): NfsClientProbe;
interface NfsClientProbe {
usable: boolean; // can this host mount NFS at all?
platform: "linux" | "darwin" | undefined;
helper: string | undefined; // path of mount.nfs / mount_nfs, if found
kernel: boolean; // does the kernel have an NFS client?
root: boolean; // are we root — required on Linux only
reason: string | undefined; // everything missing, in a sentence
}Synchronous and cheap — it imports node:fs and nothing else, which is why mountx/auto can ask it before deciding what to load, a test can gate itself on it, and a mount can fail with a message naming the missing piece instead of mount: wrong fs type. nfsPlatform(platform) is the narrowing on its own.
Note
The kernel check is deliberately weak. On Linux, nfs appears in /proc/filesystems only once the module is loaded, and mount.nfs loads it on demand — so a host with the helper and a loadable module reports kernel: false and is still usable. What is not usable is a host with neither.
#What it costs
NFSv3 is stateless. There is no open/release, so every request carries a file handle built from the driver's (dev, ino) identity rather than per-open state.
In practice that costs exactly one behaviour: a file deleted while still open stays readable over FUSE and answers ESTALE here. Everything else — reads, writes, directories, symlinks, permissions, statfs — behaves the same, which is what the shared conformance suite exists to demonstrate.
File handles also carry a boot verifier, random by default, so a server restart invalidates outstanding handles rather than answering for a tree that no longer exists.
#mountNfs(driver, mountpoint, options?)
function mountNfs(
driver: FsDriver,
mountpoint: string,
options?: MountNfsOptions,
): Promise<NfsMount>;Serves driver over NFSv3 and puts a kernel NFS client in front of it with mount(8). Linux (root) and macOS (no root, but you must own the mountpoint). Options sit at the top level here — it is mountx/auto that nests them under nfs: {…}.
interface NfsMount extends AsyncDisposable {
readonly mountpoint: string;
readonly server: NfsServer;
readonly port: number; // the port both programs are on
readonly source: string; // e.g. "127.0.0.1:/"
readonly active: boolean;
unmount(): Promise<void>;
}unmount() is idempotent, concurrency-safe and retryable after a failure. It always settles within unmountTimeout.
#MountNfsOptions
Extends NfsServerOptions (and through it NfsSessionOptions), so everything on this page is settable in one object.
| option | default | |
|---|---|---|
server | — | serve an existing NfsServer; its listen() is still called |
exportPath | "/" | directory of the driver to export |
readOnly | false | mount -o ro |
hard | false | retry forever instead of soft — see below |
timeo | 50 | tenths of a second before a request is retried |
retrans | 2 | retries before a soft mount gives up with EIO |
nobrowse | true | macOS only: -o nobrowse, hiding the volume from the GUI |
mountOptions | none | extra -o options, appended verbatim |
signals | true | unmount on SIGINT/SIGTERM |
unmountTimeout | 10_000 | ms teardown may spend; 0/Infinity waits forever |
onTransportError | none | (error, peer) for transport-level failures and forced teardown |
Note
nobrowse on by default. A visible volume is one Finder and Spotlight will crawl — for a JavaScript driver that means a burst of traffic and a scattering of .DS_Store writes nobody asked for. Set false if the point of the mount is for someone to open it in Finder.
#nfsMountOptions(port, options?, platform?)
function nfsMountOptions(port: number, options?: MountNfsOptions, platform?: NfsPlatform): string;The exact -o string mountNfs() would pass, so you can see it, log it, or hand it to mount(8) yourself. Pure, and platform-parameterised — which is how the Linux/macOS spelling differences (nolock vs nolocks, no hard option on macOS, nobrowse) are tested from either host.
#liveNfsMounts() / unmountAllNfs()
Every NFS mount this process has up, in creation order; and unmount them all, never rejecting.
#How it mounts
Three decisions, all of them deliberate:
- No portmapper. Both programs sit on one port and the mount command is told so with
port=andmountport=, sorpcbindis never contacted. - No locking.
nolock(Linux) /nolocks(macOS) keepslockd/rpc.statdout of it — NFSv3 file locking is a separate protocol (NLM) this server does not implement, and a client that tried to use it would hang waiting for a service that is not there. macOS'slocallocks, which locks in the client's VFS layer instead, is a reasonable thing to add throughmountOptionsif something on the mount needsflockto succeed. softby default. A hard mount retries forever, which is right when the server is a machine that may reboot and wrong when it is a JavaScript object in the process doing the mounting: a bug in your driver would produce unkillableD-state processes instead of anEIO. Sethard: trueif you want the other trade.
#Serving without mounting
Serving needs no privileges at all, and no mount(8). You can start just the server and put whatever NFSv3 client you like in front of it — including one on another machine, if you mean to:
import { createNfsServer } from "mountx/nfs";
import { createMemoryDriver } from "mountx/drivers/memory";
await using server = createNfsServer(createMemoryDriver());
await server.listen();interface NfsServer extends AsyncDisposable {
readonly session: NfsSession; // stats, handles, mount list
readonly port: number; // 0 before listen(); ephemeral unless you set one
readonly host: string; // "127.0.0.1" by default
readonly connections: number;
listen(): Promise<NfsServer>; // idempotent; resolves once bound
close(): Promise<void>; // idempotent
}createNfsServer() is the only file here that opens a socket, and it runs anywhere Node does, including Windows — only putting a client in front of it is platform-specific.
Then mount it yourself:
# Linux
sudo mount -t nfs -o vers=3,tcp,port=<p>,mountport=<p>,nolock 127.0.0.1:/ /mnt
# macOS — `nolocks`, and no `hard` option (hard is the default there)
mount -t nfs -o vers=3,tcp,port=<p>,mountport=<p>,nolocks,nobrowse 127.0.0.1:/ /mnt<p> is server.port, or mounted.port if you used mountNfs(); nfsMountOptions() above returns the exact -o string mountx would use, if you want to see it.
#NfsServerOptions
| option | default | |
|---|---|---|
port | 0 | 0 means an ephemeral port, which port then reports |
host | "127.0.0.1" | address to bind |
allowRemote | false | accept connections from non-loopback addresses |
maxRecord | 8 MiB | largest RPC record accepted |
onTransportError | none | (error, peer) — a bad record, a socket error |
Caution
NFSv3 has no authentication worth the name. The server binds 127.0.0.1 and refuses non-loopback connections by default. Reaching it from another machine takes both host and allowRemote: true, and that exports your driver to anything that can reach the port.
A record larger than maxRecord cannot be a mistake — the biggest legal one is a WRITE of wtmax plus headers — so the connection is closed rather than the record skipped: there is no way to resynchronize a record-marked stream once a length is not to be believed.
#NfsSession
new NfsSession(driver: FsDriver, options?: NfsSessionOptions)Bytes in, bytes out, with no socket anywhere: handleCall(bytes) → Promise<Uint8Array | null>, answering both the MOUNT and NFS programs. It never rejects — every call needing a reply gets exactly one.
session.driver; // the Loopback wrapping your driver
session.handles; // the FileHandleTable
session.stats; // { requests, replies, errors, dropped, procedures }stats.procedures is a Map keyed "NFS:LOOKUP" / "MOUNT:MNT".
#NfsSessionOptions
| option | default | |
|---|---|---|
useDriverIno | true | identify files by the driver's (dev, ino), so hardlinks share a handle |
verifier | random | boot verifier for file handles, so restarts invalidate them |
rtmax / wtmax | — | largest READ answered / WRITE accepted |
snapshotCache | 64 | directory snapshots kept for readdir cookies |
claimOwnership | true | chown new entries to the request's AUTH_SYS uid/gid |
onError | none | called for every request that ends in an error status |
claimOwnership solves the same problem the FUSE session solves the same way: the driver creates everything as the server process, while requests arrive from whoever mounted the share. It is quiet when the driver has no lchown, or when the server is not privileged enough to hand ownership away — a driver with no concept of ownership is not thereby broken.
#The macOS consent gate
macOS puts network volumes behind a sandbox approval that is never prompted for a command-line process. Without it, umount blocks and umount -f answers EPERM — with no dialog, ever.
mountx names that case specifically (isConsentDenial()/consentAdvice()) instead of blaming your driver, and says plainly that the mount survived. There is no escalation past it and nothing the library can paper over: the fix is a grant made in advance — Full Disk Access for whatever app the process is attributed to (the terminal, your IDE, the CI agent).
The escalation ladder differs for the same reason: macOS has umount -f but no umount -l, so the lazy step, and the advice to run it, are Linux-only.
#Teardown
The same discipline as FUSE: umount(8) first, the mount table as the truth rather than an exit status, forcing down on the deadline, idempotent and retryable throughout.
One difference worth knowing: macOS has no /proc/self/mounts, so the table comes from spawning mount(8) — which is why reading it is asynchronous, and why "is it mounted" is a tri-state. A table that could not be read is undefined, never false. Forcing down a mount that turns out to be gone is harmless; reporting a successful unmount on a guess shuts the server down under a live one.
server.close() does not wait for clients to go away politely: a mounted NFS client keeps its connection open forever, so a close that waited would never return.
#The layers below
Everything except server.ts and mount.ts runs on any OS with no privileges — which is what makes the protocol testable by a JS client built from the same codecs:
| module | |
|---|---|
xdr.ts | XDR (RFC 4506): bounds-checked reader/writer, 64-bit fields as bigint |
rpc.ts | ONC RPC v2 (RFC 5531): call/reply, AUTH_NONE/AUTH_SYS, record marking |
constants.ts + protocol.ts | RFC 1813, every struct both directions |
handles.ts | the file handle table and the readdir cookie scheme |
session.ts | NfsSession(driver, options) — bytes in, bytes out, no socket |
server.ts | the socket. mount.ts — mount(8) |
mountx/nfs re-exports the whole protocol layer by name:
xdr.ts—XdrReader/XdrWriter: bounds-checked, big-endian, 64-bit fields asbigint, and they only ever throwXdrError.rpc.ts— call and reply,AUTH_NONE/AUTH_SYS, TCP record marking.constants.ts+protocol.ts— RFC 1813 transcribed; every NFSv3 and MOUNTv3 struct encoded and decoded, plusfattrOf,nfsStatusOf,errnoOfStatus,statusNameand the time conversions.handles.ts—FileHandleTableand the readdir cookie scheme: the state a stateless protocol still needs.
The sub-struct helpers the procedure codecs are built from (readFattr/writeFattr, readSattr/writeSattr, the post_op_* and wcc_data pairs) are deliberately not on the public surface — they are pieces, not something a consumer composes with.
#Next
- FUSE — the other transport.
- Troubleshooting —
ESTALE,EPERM, and hangs on macOS. mountx/autoreference — the chooser that calls this.