Writing a backend
A backend is a FileSystem implementation that does the actual work, plus a Backend object that describes how to configure and construct it.
The Backend object is the part users interact with and it is what gets passed to configure.
Where to start
Section titled “Where to start”Most backends shouldn’t implement FileSystem at all. Pick the highest-level abstraction that fits whatever you are wrapping:
| What you have | What to use |
|---|---|
Somewhere to put bytes under a numeric key (localStorage, IndexedDB, an ArrayBuffer, a KV service) |
Implement Store, pass it to StoreFS |
| A directory structure you can enumerate up front (an archive, an HTTP index) | Extend IndexFS |
Something with its own path-based semantics (another fs, a remote API with paths) |
Extend FileSystem |
The first case covers most backends. StoreFS implements inodes, directory listings, hard links, and transaction rollback on top of a key-value store, which means a store is usually a few dozen lines.
Stores
Section titled “Stores”A Store needs a name, a way to sync, and a way to begin a transaction. If your storage is synchronous and shaped like a Map, implement SyncMapStore and use SyncMapTransaction. That is all InMemory is:
import { StoreFS, SyncMapTransaction, type SyncMapStore } from '@zenfs/core';
export class ExampleStore extends Map<number, Uint8Array> implements SyncMapStore { /** The name of the file system type, like `tmpfs` for `InMemory` */ public readonly name = 'examplefs';
public async sync(): Promise<void> {}
public transaction(): SyncMapTransaction { return new SyncMapTransaction(this); }}Keys are allocated by StoreFS and should be treated as opaque — an inode and its data live under different keys, so don’t assume the set of keys maps to the set of files. StoreFS creates the root directory itself once the file system is ready, so a new store starts out empty and valid.
A couple of optional members are worth setting:
typeis a unique 32-bit id for the kind of file system. The convention is four ASCII characters, e.g.0x6b766673forkvfs. It isn’t used internally yet.labelnames the instance rather than the type, the same way partition labels do. A share name or database name is a good choice.flagsis for optimizations. Set'partial'if your store can read and write ranges rather than whole values, sinceStoreFSwill then avoid round-tripping entire files.
Asynchronous stores
Section titled “Asynchronous stores”If your storage is async, extend AsyncTransaction instead. It implements the synchronous methods against a cache and throws EAGAIN when the data isn’t cached, so anything you want to work synchronously has to be loaded ahead of time. @zenfs/dom’s IndexedDB store preloads every record when the backend is created:
async create(options: IndexedDBOptions & Partial<SharedConfig>) { const store = new IndexedDBStore(await createDB(options.storeName || 'zenfs', options.idbFactory)); const fs = new StoreFS(store); if (options.disableAsyncCache) return fs; await store.transaction().preload(); return fs;}Honoring disableAsyncCache like this is worth doing. It is part of SharedConfig, so users can already set it on any mount, and it sets the no_async_preload attribute for you; all your backend needs to do is skip the preload.
The backend object
Section titled “The backend object”The backend describes its options, then creates the file system:
import type { Backend } from '@zenfs/core';
export interface ExampleOptions { raw: RawExampleData;}
const _Example = { name: 'Example', options: { raw: { type: 'object', required: true }, }, isAvailable() { return 'exampleStorage' in globalThis; }, create({ raw }: ExampleOptions) { return new StoreFS(new ExampleStore(raw)); },} as const satisfies Backend<StoreFS<ExampleStore>, ExampleOptions>;type _Example = typeof _Example;export interface Example extends _Example {}export const Example: Example = _Example;options is checked before create is called, so you can assume everything required is present and of the right type. The type of an option can be a typeof string, a class name, a class, or a predicate.
isAvailable should check whether the environment supports the backend at all, for example a browser API existing. It should not check whether this particular configuration will work.
The as const satisfies and interface stuff at the end exists to keep error messages readable when someone misconfigures the backend. Backends covers why in detail.
Note that create and isAvailable returning promises means your backend can only be used with configure, not configureSync, which throws EAGAIN rather than blocking.
Implementing FileSystem directly
Section titled “Implementing FileSystem directly”If neither StoreFS nor IndexFS fits, you can extend FileSystem. Every path you are given is absolute and every argument is present, so there is no normalization to do.
Each operation has a sync and an async version. Implementing both is ideal, though you rarely have to write both by hand:
Sync(FileSystem)implements the async methods using the sync ones.Async(FileSystem)implements the sync methods by running them against an in-memory copy and pipelining the real work. Set_syncto the file system used as the cache, usuallyInMemory.create({ label: '...' }).Readonly(FileSystem)implements every mutating method to throwEROFS, and sets theno_writeattribute.
They compose with anything, including IndexFS. For example, @zenfs/dom’s File System Access backend is class WebAccessFS extends Async(IndexFS).
There are a few things the VFS expects that aren’t obvious from the type signatures:
stat('/')must work. Rhe VFS assumes the root exists.inomust be unique within the file system. It is what identifies a file, so using the same one for multiple files puts them all on the same vnode and they’ll appear to share content.- Symlinks aren’t special. The VFS creates a regular file, writes the target into it, then changes the mode to
S_IFLNK. If you preserve modes and file contents you support symlinks. - Metadata comes back through
touch.statreturns anInodeLike, and any change to it — mode, times, ownership, size — arrives as atouchcall with a partial inode to merge in.
attributes controls how the VFS treats the file system. no_write for read-only backends, no_atime if you can’t cheaply update access times, sync if writes are always durable immediately. These are analogous to options in /etc/fstab.
Errors
Section titled “Errors”ZenFS uses kerium for errors. Throw withErrno with the code the equivalent syscall would use:
import { withErrno } from 'kerium';
if (!this.entries.has(path)) throw withErrno('ENOENT');The VFS handles the common cases before calling your FileSystem methods: EISDIR, ENOTDIR, and EEXIST on open are checked against stat.
Mostly you should need ENOENT when something is missing, EEXIST when creating over something that exists, ENOTEMPTY from rmdir, and ENOSPC when you run out of room. Converting errors from the underlying storage is your job; a try/catch that maps native error codes onto errno values is typical.
Testing
Section titled “Testing”@zenfs/core ships the test suite it uses on itself, so you can run the entire fs conformance suite against your backend. Write a setup file that configures it and copies in the fixture data:
import { configureSingle } from '@zenfs/core';import { copyAsync, data } from '@zenfs/core/tests/setup';import { Example } from '../src/index.js';
await configureSingle({ backend: Example, raw: /* ... */ });await copyAsync(data);npx zenfs-test -abcIf your backend can’t do everything (e.g. no hard links, no writes, no atime) export flags from the setup file and the suite will skip exactly the tests that need what’s missing, instead of you filtering by name. See Testing for the full list of flags and options.