@studnicky/virtual-fs
In-memory synchronous filesystem primitive. Gives file-lock (and any other fs-dependent code) a browser-compatible backend. Subclass to observe every filesystem event.
Install
pnpm add @studnicky/virtual-fsRequires @studnicky:registry=https://npm.pkg.github.com in .npmrc.
Usage
Build an instance with the fluent builder, seed files, then call the familiar synchronous methods:
import { VirtualFileSystem } from '../src/index.js';
const vfs = VirtualFileSystem.builder()
.seed('/data/hello.txt', 'Hello, virtual world!')
.build();
// Ensure /data directory entry exists for readdirSync
vfs.mkdirSync('/data', { 'recursive': true });
// Write a second file
vfs.writeFileSync('/data/config.json', '{"version":1}', 'utf8');
// Read both files
const hello = vfs.readFileSync('/data/hello.txt', 'utf8');
console.log(`hello.txt: ${hello}`);
const config = vfs.readFileSync('/data/config.json', 'utf8');
console.log(`config.json: ${config}`);
// Rename config.json → settings.json
vfs.renameSync('/data/config.json', '/data/settings.json');
console.log('renamed config.json → settings.json');
// List directory
const entries = vfs.readdirSync('/data');
console.log(`/data entries: ${entries.join(', ')}`);
// Stat the renamed file
const stat = vfs.statSync('/data/settings.json');
console.log(`settings.json isFile=${String(stat.isFile())} isDirectory=${String(stat.isDirectory())} mtimeMs=${stat.mtimeMs}`);Try it
Builder demo
The builder seeds /data/hello.txt, writes a second file, renames it, reads the directory listing, and stats the renamed file. All assertions verify the expected state.
/** basicVirtualFs — builder demo: seed, write, read, rename, readdir, stat. Run: npx tsx examples/basicVirtualFs.ts */
import assert from 'node:assert/strict';
// #region usage
import { VirtualFileSystem } from '../src/index.js';
const vfs = VirtualFileSystem.builder()
.seed('/data/hello.txt', 'Hello, virtual world!')
.build();
// Ensure /data directory entry exists for readdirSync
vfs.mkdirSync('/data', { 'recursive': true });
// Write a second file
vfs.writeFileSync('/data/config.json', '{"version":1}', 'utf8');
// Read both files
const hello = vfs.readFileSync('/data/hello.txt', 'utf8');
console.log(`hello.txt: ${hello}`);
const config = vfs.readFileSync('/data/config.json', 'utf8');
console.log(`config.json: ${config}`);
// Rename config.json → settings.json
vfs.renameSync('/data/config.json', '/data/settings.json');
console.log('renamed config.json → settings.json');
// List directory
const entries = vfs.readdirSync('/data');
console.log(`/data entries: ${entries.join(', ')}`);
// Stat the renamed file
const stat = vfs.statSync('/data/settings.json');
console.log(`settings.json isFile=${String(stat.isFile())} isDirectory=${String(stat.isDirectory())} mtimeMs=${stat.mtimeMs}`);
// #endregion usage
// Assertions
assert.equal(hello, 'Hello, virtual world!');
assert.equal(config, '{"version":1}');
assert.ok(!vfs.existsSync('/data/config.json'), 'config.json should not exist after rename');
assert.ok(vfs.existsSync('/data/settings.json'), 'settings.json should exist after rename');
assert.ok(entries.includes('hello.txt'), 'hello.txt should appear in readdirSync');
assert.ok(entries.includes('settings.json'), 'settings.json should appear in readdirSync');
assert.ok(stat.isFile(), 'stat should indicate a file');
assert.ok(!stat.isDirectory(), 'stat should not indicate a directory');
console.log('basicVirtualFs: all assertions passed');
Lifecycle hooks
TracingVfs subclasses VirtualFileSystem and overrides all five hooks: onCreate, onWrite, onRead, onRename, and onDelete. The demo exercises every path — seeding (triggers onCreate), overwriting (triggers onWrite), reading, renaming, and unlinking — printing a full hook trace.
/** observedVirtualFs — lifecycle hooks demo: subclass and override onCreate/onWrite/onRead/onRename/onDelete. Run: npx tsx examples/observedVirtualFs.ts */
// #region usage
import { EventRecorder } from '@studnicky/errors/observers';
import assert from 'node:assert/strict';
import type { HookEventEntity } from './entities/HookEventEntity.js';
import { VirtualFileSystem } from '../src/index.js';
class TracingVfs extends VirtualFileSystem {
readonly #recorder = new EventRecorder<HookEventEntity.Type>();
get events(): HookEventEntity.Type[] { return this.#recorder.events; }
protected override onCreate(path: string): void {
this.#recorder.record({ 'hook': 'onCreate', 'path': path }, `[virtual-fs] onCreate path=${path}`);
}
protected override onDelete(path: string): void {
this.#recorder.record({ 'hook': 'onDelete', 'path': path }, `[virtual-fs] onDelete path=${path}`);
}
protected override onRead(path: string): void {
this.#recorder.record({ 'hook': 'onRead', 'path': path }, `[virtual-fs] onRead path=${path}`);
}
protected override onRename(oldPath: string, newPath: string): void {
this.#recorder.record({ 'hook': 'onRename', 'path': oldPath }, `[virtual-fs] onRename from=${oldPath} to=${newPath}`);
}
protected override onWrite(path: string): void {
this.#recorder.record({ 'hook': 'onWrite', 'path': path }, `[virtual-fs] onWrite path=${path}`);
}
}
// Build without seeding — write the initial file after construction so hooks
// fire after class field initializers have run (events array is ready).
const vfs: TracingVfs = TracingVfs.builder().build() as TracingVfs;
// Write initial file → onCreate
vfs.writeFileSync('/log/init.txt', 'bootstrap', 'utf8');
console.log('--- initial write complete ---');
// Write to same file → onWrite
vfs.writeFileSync('/log/init.txt', 'updated', 'utf8');
// Write new file → onCreate
vfs.writeFileSync('/log/new.txt', 'brand new', 'utf8');
// Read → onRead
vfs.readFileSync('/log/init.txt', 'utf8');
// Mkdir + Readdir → onCreate (on /log/sub) then onRead (on /log)
vfs.mkdirSync('/log', { 'recursive': true });
vfs.mkdirSync('/log/sub', { 'recursive': true });
vfs.readdirSync('/log');
// Rename → onRename
vfs.renameSync('/log/new.txt', '/log/renamed.txt');
// Unlink → onDelete
vfs.unlinkSync('/log/renamed.txt');
console.log('--- hook trace complete ---');
// #endregion usage
// Assertions
const creates = vfs.events.filter((e) => { return e.hook === 'onCreate'; });
const writes = vfs.events.filter((e) => { return e.hook === 'onWrite'; });
const reads = vfs.events.filter((e) => { return e.hook === 'onRead'; });
const renames = vfs.events.filter((e) => { return e.hook === 'onRename'; });
const deletes = vfs.events.filter((e) => { return e.hook === 'onDelete'; });
// writeFileSync('/log/init.txt') → onCreate; writeFileSync('/log/new.txt') → onCreate; mkdirSync('/log/sub') → onCreate
assert.ok(creates.length >= 3, `onCreate fired at least 3 times (got ${creates.length})`);
assert.ok(writes.length >= 1, 'onWrite fired at least once');
assert.ok(reads.length >= 2, 'onRead fired at least twice (readFileSync + readdirSync)');
assert.equal(renames.length, 1, 'onRename fired once');
assert.equal(deletes.length, 1, 'onDelete fired once');
console.log('observedVirtualFs: all assertions passed');
Observability hooks
Subclass VirtualFileSystem and override any protected hook to inject trace logging, metrics, or side-effects at the exact stage where they are needed. Hooks should stay fast and non-blocking; observer-hook failures are contained so the filesystem operation still wins.
| Hook | When it fires | Args |
|---|---|---|
onCreate(path) | A new file or directory is created (writeFileSync on a new path, mkdirSync) | path: string |
onWrite(path) | An existing file is overwritten (writeFileSync on an existing path) | path: string |
onRead(path) | A file or directory is read (readFileSync, readdirSync) | path: string |
onRename(oldPath, newPath) | A file is renamed (renameSync) | oldPath: string, newPath: string |
onDelete(path) | A file is deleted (unlinkSync) | path: string |
import { EventRecorder } from '@studnicky/errors/observers';
import assert from 'node:assert/strict';
import type { HookEventEntity } from './entities/HookEventEntity.js';
import { VirtualFileSystem } from '../src/index.js';
class TracingVfs extends VirtualFileSystem {
readonly #recorder = new EventRecorder<HookEventEntity.Type>();
get events(): HookEventEntity.Type[] { return this.#recorder.events; }
protected override onCreate(path: string): void {
this.#recorder.record({ 'hook': 'onCreate', 'path': path }, `[virtual-fs] onCreate path=${path}`);
}
protected override onDelete(path: string): void {
this.#recorder.record({ 'hook': 'onDelete', 'path': path }, `[virtual-fs] onDelete path=${path}`);
}
protected override onRead(path: string): void {
this.#recorder.record({ 'hook': 'onRead', 'path': path }, `[virtual-fs] onRead path=${path}`);
}
protected override onRename(oldPath: string, newPath: string): void {
this.#recorder.record({ 'hook': 'onRename', 'path': oldPath }, `[virtual-fs] onRename from=${oldPath} to=${newPath}`);
}
protected override onWrite(path: string): void {
this.#recorder.record({ 'hook': 'onWrite', 'path': path }, `[virtual-fs] onWrite path=${path}`);
}
}
// Build without seeding — write the initial file after construction so hooks
// fire after class field initializers have run (events array is ready).
const vfs: TracingVfs = TracingVfs.builder().build() as TracingVfs;
// Write initial file → onCreate
vfs.writeFileSync('/log/init.txt', 'bootstrap', 'utf8');
console.log('--- initial write complete ---');
// Write to same file → onWrite
vfs.writeFileSync('/log/init.txt', 'updated', 'utf8');
// Write new file → onCreate
vfs.writeFileSync('/log/new.txt', 'brand new', 'utf8');
// Read → onRead
vfs.readFileSync('/log/init.txt', 'utf8');
// Mkdir + Readdir → onCreate (on /log/sub) then onRead (on /log)
vfs.mkdirSync('/log', { 'recursive': true });
vfs.mkdirSync('/log/sub', { 'recursive': true });
vfs.readdirSync('/log');
// Rename → onRename
vfs.renameSync('/log/new.txt', '/log/renamed.txt');
// Unlink → onDelete
vfs.unlinkSync('/log/renamed.txt');
console.log('--- hook trace complete ---');The base class never calls any logger or metrics library. All hooks are no-ops by default.
Injectable clock
Pass a @studnicky/clock ClockProviderType via .withClock(clock) on the builder to control mtimeMs timestamps for deterministic test scenarios:
import type { ClockProviderType } from '@studnicky/clock';
import { VirtualFileSystem } from '@studnicky/virtual-fs';
// Any ClockProviderType drives mtimeMs — here a fixed, deterministic clock.
const clock: ClockProviderType = {
hrtime: () => 1_000_000_000n,
now: () => 1000
};
const vfs = VirtualFileSystem.builder().withClock(clock).build();FileSystemInterface contract
VirtualFileSystem implements FileSystemInterface, which is also exported from @studnicky/virtual-fs. Any code that depends on filesystem access can accept FileSystemInterface and receive either the real Node.js fs module adapter or a VirtualFileSystem — enabling browser-safe and test-isolated execution of the same logic.
import type { FileSystemInterface } from '@studnicky/virtual-fs';
function processFiles(fs: FileSystemInterface): void {
const entries = fs.readdirSync('/data');
// works in Node with NodeFileSystem or in the browser with VirtualFileSystem
}Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/virtual-fs | VirtualFileSystem, VirtualFileSystemBuilder, VirtualFileSystemError, VirtualFileSystemOptionsType |
@studnicky/virtual-fs/interfaces | FileSystemInterface, StatResultInterface |