Mount a JavaScript filesystem: one driver interface (a subset of node:fs/promises),
multiple transports — FUSE, 9P, NFS (v3 and v4.1) — plus two that serve the same
driver over HTTP instead of mounting it, an S3 gateway (mountx/s3) and a WebDAV
server (mountx/webdav), both deliberately outside mountx/auto.
Conventions: pure JS/TS, zero runtime deps, pure-JS-first. Single package with
subpath exports. Small conventional commits to main, pnpm test green before each
commit. User-facing prose goes in docs/ (https://mountx.vercel.app), not in
README.md, which is the npm/GitHub landing page and links there.
There is exactly one piece of native code (native/), and it exists for one reason:
unprivileged FUSE mounting needs fusermount3 to hand /dev/fuse back over
SCM_RIGHTS, and Node cannot recvmsg a descriptor.
Detail lives in .agents/ — start with .agents/architecture.md before changing
an area you have not touched, and .agents/invariants.md for the reasoning behind
any rule below that looks removable.
| Path | What |
|---|---|
src/types.ts |
FsDriver, FsCapabilities, the mountx.* extension namespace (mknod, utimens) |
src/errors.ts |
ERRNO_CODES, fsError(), errnoOf() — the one errno table |
src/path.ts |
absolute POSIX path helpers; .. clamps at the root |
src/harness.ts |
createLoopback(driver) — what driver authors test against |
src/lock.ts |
PathLock, taken by RENAME on every transport |
src/subtree.ts |
remapSubtree() — the rename rewrite all three handle tables share (internal) |
src/http.ts |
RFC 9110's HTTP-date, Range and ETag quoting — shared by the two HTTP transports |
src/xml.ts |
the bounded XML codec and its namespace resolution — shared by the two HTTP transports |
src/auto.ts |
mountx/auto: probe, then FUSE → 9P → NFS, each via await import() |
src/drivers/ |
memory (the only mountx.mknod implementation), node-fs, unstorage, handle.ts |
src/fuse/ |
mountx/fuse — protocol 7.41, root and fusermount3 mount paths, exec.ts (shared spawn/Deadline) |
src/9p/ |
mountx/9p — 9P2000.L, trans=unix by default, one session per connection |
src/nfs/ |
mountx/nfs — a version router over v3/ (RFC 1813 + MOUNT) and v4/ (NFSv4.1); Linux and macOS |
src/s3/ |
mountx/s3 — SigV4 gateway over HTTP, path-style, one bucket per driver |
src/webdav/ |
mountx/webdav — RFC 4918 classes 1, 2 and 3 over HTTP: every method, write locks, If |
src/cli/ |
the mountx bin — a demo and test bench that mounts this package's README |
native/ |
the Zig Node-API addon and its generated embed (prebuilt.mjs) |
test/ |
Tier 0/1/2 suites and the shared conformance suite — see .agents/testing.md |
bench/ |
generates .agents/benchmarks.md |
docs/ |
the undocs site — a standalone pnpm project, not a workspace member |
Every transport directory follows one shape: constants.ts (transcribed from the
kernel header or RFC named in the file), protocol.ts (every message encoded and
decoded), session.ts (protocol ↔ FsDriver), server.ts/mount.ts, probe.ts,
index.ts.
Each line is the rule; .agents/invariants.md has the reasoning and the traps.
- Zero runtime deps.
unstorageis a type-only optional peer — checkdist/drivers/unstorage.mjshas no import of it if that driver is touched. - The native addon is optional, lazy, and never on the root path. Root mounting
opens
/dev/fuseitself and touches no native code. - The embed is the only copy anything loads, and it is generated — regenerating
native/prebuilt.mjswithpnpm build:nativeis part of anynative/src/change, not a follow-up. FsDriveris a subset ofnode:fs/promises—const driver: FsDriver = await import("node:fs/promises")must compile with no cast.- Capabilities are declared-or-inferred, never faked — unmet answers
ENOSYS/ENOTSUP. - The errno table is transcribed once, in
src/errors.ts. - Wire constants are transcribed from the kernel headers and RFCs named in each
file — never guessed, never read off host
node:fs. - The wire's
O_*and a driver'sO_*are different namespaces.src/fuse/flags.tsis the one crossing; 9P imports it rather than copying it. - Exactly one reply per request (per COMPOUND on v4.1);
handleMessage/handleCallnever reject, and a thrown value becomes that transport's error reply. - A minor version other than 1 is refused with
NFS4ERR_MINOR_VERS_MISMATCH, not guessed at. - No grace period on NFSv4.1 — every reclaim answers
NFS4ERR_NO_GRACE, andRECLAIM_COMPLETEstill gates ordinary locking. - The zero-copy contract. Sessions copy everything they keep before the first
await; the reply path returns a view of a per-reply writer that is never written to again. - Decoders always copy the bytes they retain —
sliceissubarray. One documented exception:FuseRequest.payload. - No mount stacking, in either direction.
- An unreadable mount table means "still mounted", never "gone".
- Teardown has a deadline (
unmountTimeout, default 10 s) and it is per phase over a sharedDeadline, never per spawn; every spawned child is bounded and abandoned past it. Server shutdown is a step inside the phase. - Unmount is the transport's job to detect — no
FUSE_DESTROY, noTdestroy; EOF on/dev/fuseor on the 9P connection is the signal. - On macOS the umount escalation can be refused outright by the sandbox consent
gate — name it (
isConsentDenial/consentAdvice), never paper over it. - Unprivileged FUSE teardown is weaker, and says so —
fusermount3 -u -zonly. - Self-client hazard. Serving a mount and using it from the same process wedges
(threadpool on FUSE,
uv_spawnon both FUSE and 9P). process.exit()does not work with a mount up —await unmount()and setprocess.exitCode.- Source stays NUL-free and grep-able (e.g. the cookie delimiter is
"\0", the two-character string). - Golden fixtures give every field a distinct value — mirrored values pass with transposed encode/decode.
- Published perf claims come only from
.agents/benchmarks.md, carrying its host line.
| Command | What | Root |
|---|---|---|
pnpm test |
lint + typecheck + the Tier-0/Tier-1 suites; runs everywhere | no |
pnpm test:rootless |
Tier-2 unprivileged mounts: FUSE on Linux, NFS on macOS, plus auto |
no |
pnpm test:root |
the five Tier-2 real-mount suites (FUSE ×3, NFS, 9P) | sudo |
pnpm test:mount / :nfs:mount / :9p:mount |
one Tier-2 suite each | sudo |
pnpm test:pjdfstest |
pjdfstest against a real mount | sudo |
pnpm build:native |
zig build and regenerate the embed; only for native/src/ changes |
no |
pnpm mountx |
the CLI from source; --help for flags |
no |
pnpm matrix |
regenerate .agents/conformance-matrix.md |
no |
pnpm bench / :root / :9p |
loopback + NFS columns / the FUSE column / the 9P column | no / sudo |
pnpm bench:bun |
bun install on every mounting transport + a CPU profile per column |
sudo |
pnpm fmt / pnpm lint / pnpm build |
automd+oxlint+oxfmt / check / obuild |
no |
Every Tier-2 file skips itself when the host cannot mount (nfsClientProbe(),
p9ClientProbe(), rootlessProbe()) or when UV_THREADPOOL_SIZE has not been
raised, so pnpm test never mounts anything. docs/ is its own project: pnpm install && pnpm dev from inside it.
| File | What |
|---|---|
architecture.md |
the per-file map and the design reasoning behind it |
invariants.md |
the list above, in full, with the "why" |
testing.md |
tiers, the conformance matrix, per-area test layout, known gaps |
roadmap.md |
open work only: the decisions still binding, then open work by area |
environment.md |
verified host facts (FUSE, 9P, VM guests, rclone, macOS) and wedge recovery |
conformance-matrix.md |
generated per-transport conformance table (pnpm matrix) |
benchmarks.md |
generated performance numbers and their interpretation (pnpm bench) |
pjdfstest-results.md |
pjdfstest pass/fail breakdown and the bugs it found |
9p-plan.md, s3-plan.md |
the implementation plans those two transports shipped from |