Better FS Tools
Filesystems

Memory filesystem

Keep files in memory with memoryFileSystem for tests and examples, simulate other backends, and make a read-only view of any backend.

memoryFileSystem() from @better-fs-tools/fs keeps files in memory. It is a full WritableFileSystem, so the read tool and the write tools both work with it. It needs no Node module, and has no dependencies. Use it in tests, in examples, and when your files are already in memory.

npm install @better-fs-tools/fs @better-fs-tools/read @better-fs-tools/write
import { memoryFileSystem, readOnlyFileSystem } from "@better-fs-tools/fs";
import { createReadTool, textOf } from "@better-fs-tools/read";
import { createWriteTool } from "@better-fs-tools/write";

// Seed files and folders, then change them from the test.
const fs = memoryFileSystem({
  files: { "/src/index.ts": "export const a = 1;\n" },
  directories: ["/src/empty"],
  denyRoots: ["/secrets"],
});
fs.setFile("/secrets/key.pem", "-----BEGIN KEY-----\n");

const read = createReadTool({ fs });
const write = createWriteTool({ fs });

console.log(textOf(await write({ path: "/src/index.ts", content: "export const a = 2;\n" })));
console.log(new TextDecoder().decode(fs.peek("/src/index.ts")?.bytes)); // "export const a = 2;\n"
console.log(textOf(await read({ path: "/secrets/key.pem" })));
Updated /src/index.ts (+1 -1 lines).
export const a = 2;

[read:dangerous-path] /secrets/key.pem is refused by policy (/secrets). Choose another path.

Paths are absolute POSIX paths. The backend has no allowed roots, so every absolute path is inside. It has no symbolic links. A relative path resolves against /.

Options

OptionDefaultMeaning
filesNoneSeed files, keyed by absolute path. A value is a string or a Uint8Array. Parent folders are created
directoriesNoneSeed folders. Use it for empty folders
denyRootsNoneRefused as DANGEROUS_PATH
id"memory"Appears in result.file.backend and in versions
identity"required""none" reports no identity, as a backend without inodes does
maxBufferedBytes16 MiBA larger file gives TOO_LARGE, for reads and writes
chunkBytes64 KiBThe size of each chunk that bytes() yields
streamingtruefalse yields the whole file as one chunk, as a buffered backend does
listtruefalse removes list(), so the read tool makes no suggestions and no folder listings
stagetruefalse removes stage(), so apply_patch writes each file in turn
removetruefalse removes remove(), so a patch cannot delete or move a file
writeCapabilities{ compareAndSwap: true }compareAndSwap: false makes the write tools also check the file just before the write
readOnlyfalsetrue makes every write give read-only
faultsNoneA function that can fail a write, a stage, a publish, or a remove

The backend always checks the precondition and writes in one synchronous step, even with compareAndSwap: false. A new file gets mode 0o644. A replace keeps the old mode.

Test helpers

The result has helpers that change the files directly, with no tool and no check:

HelperDoes
setFile(path, contents, { mode? })Sets the bytes and gives the file a new version. Creates parent folders. Keeps the mode of an existing file, else 0o644
deleteFile(path)Removes the file
makeDirectory(path)Adds a folder
setMimeType(path, value)Sets the MIME type that open() reports, or null
peek(path)Returns { bytes, mode, version }, or null when the file does not exist. Use it in assertions

Use setFile() to change a file after the model read it. The next edit or write then sees a new version.

Simulate other backends

The options let a test act like a weaker backend. A backend that refuses every write:

const locked = memoryFileSystem({ files: { "/notes.txt": "one\n" }, readOnly: true });
const lockedWrite = createWriteTool({ fs: locked });
console.log(textOf(await lockedWrite({ path: "/notes.txt", content: "two\n" })));
[write:read-only] /notes.txt is on a read-only filesystem, so it cannot be changed.

faults gets the operation and the resolved path, after every other check passed. It returns a MutationError to fail that operation, or null to let it run. A full disk:

const full = memoryFileSystem({
  faults: (operation) => (operation === "write" ? { reason: "no-space" } : null),
});
const fullWrite = createWriteTool({ fs: full });
console.log(textOf(await fullWrite({ path: "/notes.txt", content: "two\n" })));
[write:no-space] There is no space left to write /notes.txt.

To act like a buffered backend such as just-bash, set streaming: false. To act like a backend with no stage(), as every backend except Node and memory, set stage: false.

A read-only view

readOnlyFileSystem(fs) works over every backend, not only the memory backend. It keeps open, list, id, capabilities, paths, and the root settings of the backend. It drops writeCapabilities and every write method. A read tool works as before. A write tool throws when you create it:

try {
  // @ts-expect-error A read-only view is not a WritableFileSystem.
  createWriteTool({ fs: readOnlyFileSystem(fs) });
} catch (error) {
  console.log(String(error));
}
TypeError: fs has no write methods: a write tool needs a WritableFileSystem

isWritableFileSystem(fs) from @better-fs-tools/fs returns true when fs has writeCapabilities, stat, and write.

The type of the view is ReadOnlyFileSystem<typeof fs>, so TypeScript also refuses it where a WritableFileSystem is needed.

On this page