Skip to content

@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

bash
pnpm add @studnicky/virtual-fs

Requires @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:

ts
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.

VirtualFileSystem builder — seed, write, rename, readdir, stat
/** 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');
Output
Press Execute to run this example against the real library.

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.

Observed VirtualFileSystem — lifecycle 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');
Output
Press Execute to run this example against the real library.

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.

HookWhen it firesArgs
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
ts
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:

typescript
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.

typescript
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

SubpathContents
@studnicky/virtual-fsVirtualFileSystem, VirtualFileSystemBuilder, VirtualFileSystemError, VirtualFileSystemOptionsType
@studnicky/virtual-fs/interfacesFileSystemInterface, StatResultInterface

Source on GitHub