Node.js API compatibility layer for the browser, powered by a Virtual File System.
Use familiar Node.js APIs -- fs, path, events, buffer, crypto, and more -- in browser environments, with zero native dependencies.
- 14 Node.js modules polyfilled for browser use
- Virtual File System with swappable backends (in-memory, IndexedDB planned)
- Full
fsmodule with callbacks, sync, and promise APIs (50+ functions) - Web Crypto API backed
cryptomodule (SHA hashing, HMAC, randomBytes, randomUUID) - Stream primitives (Readable, Writable, Transform, Duplex, pipeline)
- 94 tests running in both Node.js and real Chromium via Playwright
- Dual format builds (ESM + CJS) with full TypeScript declarations
- Tree-shakeable -- import only the modules you need
| Package | npm | Description |
|---|---|---|
@lifo-sh/vfs |
packages/vfs |
Standalone Virtual File System with swappable backends |
@lifo-sh/node-compat |
packages/node-compat |
Node.js API compatibility layer |
stories |
packages/stories |
Shared test stories with assertions |
vite-react-app |
packages/vite-react-app |
Browser playground with dashboard UI (Tailwind + shadcn) |
cli-app |
packages/cli-app |
CLI playground with interactive menu |
# Install dependencies
pnpm install
# Build all packages
pnpm build
# Run the browser playground
pnpm dev:vite
# Run tests
pnpm test:node # Node.js
pnpm test:browser # Real Chromium via PlaywrightFull fs module with callback, sync, and promise APIs backed by the Virtual File System.
import fs from "@lifo-sh/node-compat/fs";
fs.mkdirSync("/app/data", { recursive: true });
fs.writeFileSync("/app/data/config.json", '{"port": 3000}', "utf-8");
const config = fs.readFileSync("/app/data/config.json", "utf-8");
console.log(config); // '{"port": 3000}'Supported APIs: readFile, writeFile, appendFile, copyFile, cp, rm, readdir, mkdir, stat, lstat, fstat, unlink, rmdir, rename, existsSync, access, chmod, chown, link, symlink, readlink, realpath, mkdtemp, truncate, utimes, lutimes, open, close, read, write, opendir, glob, statfs, watch, watchFile, unwatchFile, createReadStream, createWriteStream -- plus all sync variants and fs.promises.
import fs from "@lifo-sh/node-compat/fs/promises";
await fs.writeFile("/hello.txt", "Hello!", "utf-8");
const data = await fs.readFile("/hello.txt", "utf-8");Pure path manipulation -- no VFS dependency.
import path from "@lifo-sh/node-compat/path";
path.join("/home", "user", "docs"); // "/home/user/docs"
path.resolve("/a", "b", "../c"); // "/a/c"
path.parse("/home/user/file.txt"); // { root: "/", dir: "/home/user", ... }
path.relative("/a/b/c", "/a/d"); // "../../d"Supported APIs: join, resolve, normalize, isAbsolute, dirname, basename, extname, parse, format, relative, toNamespacedPath, sep, delimiter, posix, win32.
Full EventEmitter implementation.
import { EventEmitter } from "@lifo-sh/node-compat/events";
const ee = new EventEmitter();
ee.on("data", (msg) => console.log(msg));
ee.once("init", () => console.log("initialized"));
ee.emit("init");
ee.emit("data", "hello");Supported APIs: on, addListener, once, off, removeListener, removeAllListeners, emit, listeners, listenerCount, eventNames, setMaxListeners, prependListener, prependOnceListener. Static: EventEmitter.once(), EventEmitter.on() (async iterator).
Buffer via composition pattern (augmented Uint8Array).
import { Buffer } from "@lifo-sh/node-compat/buffer";
const buf = Buffer.from("Hello, world!");
console.log(buf.toString("hex")); // "48656c6c6f2c20776f726c6421"
console.log(buf.toString("base64")); // "SGVsbG8sIHdvcmxkIQ=="
const combined = Buffer.concat([Buffer.from("foo"), Buffer.from("bar")]);
console.log(combined.toString()); // "foobar"Supported APIs: Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.concat, Buffer.isBuffer, Buffer.byteLength, Buffer.isEncoding. Instance: toString, write, copy, equals, compare, toJSON, readUInt8/16/32 BE/LE, readInt8/16/32 BE/LE, writeUInt8/16/32 BE/LE, writeInt8/16/32 BE/LE.
Encodings: utf-8, hex, base64, ascii, latin1, binary.
Stream primitives with flowing/paused modes and backpressure.
import { Readable, Writable, Transform, pipeline } from "@lifo-sh/node-compat/stream";
const source = new Readable({
read() { this.push("hello"); this.push(null); }
});
const upper = new Transform({
objectMode: true,
transform(chunk, enc, cb) { cb(null, chunk.toUpperCase()); }
});
const dest = new Writable({
write(chunk, enc, cb) { console.log(chunk); cb(); }
});
pipeline(source, upper, dest, (err) => {
if (!err) console.log("done!");
});Supported APIs: Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished. Readable supports pipe/unpipe, pause/resume, and Symbol.asyncIterator.
Crypto backed by the Web Crypto API (SubtleCrypto).
import crypto from "@lifo-sh/node-compat/crypto";
// Random values
const bytes = crypto.randomBytes(32);
const id = crypto.randomUUID();
// SHA-256 hash (async -- uses SubtleCrypto)
const hash = crypto.createHash("sha256");
hash.update("hello");
const hex = await hash.digest("hex");
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
// HMAC (async)
const hmac = crypto.createHmac("sha256", "secret");
hmac.update("message");
const sig = await hmac.digest("hex");Supported APIs: randomBytes, randomUUID, randomInt, createHash (SHA-1, SHA-256, SHA-384, SHA-512), createHmac, timingSafeEqual.
Note:
digest()returns aPromisesince it uses the async Web Crypto API under the hood. This differs from Node.js where digest is synchronous.
URL utilities. Re-exports browser globals plus Node-style helpers.
import url from "@lifo-sh/node-compat/url";
const parsed = url.parse("https://user:pass@example.com/path?q=1#hash");
console.log(parsed.hostname); // "example.com"
console.log(parsed.auth); // "user:pass"
url.format({ protocol: "https:", hostname: "example.com", pathname: "/api" });
// "https://example.com/api"Supported APIs: URL, URLSearchParams, parse, format, resolve, domainToASCII, domainToUnicode, fileURLToPath, pathToFileURL.
Process shim for browser environments.
import process from "@lifo-sh/node-compat/process";
console.log(process.platform); // "browser"
console.log(process.cwd()); // "/"
process.nextTick(() => console.log("tick!"));Supported APIs: env, argv, argv0, platform, arch, version, versions, pid, ppid, title, execPath, stdout, stderr, cwd, chdir, nextTick (via queueMicrotask), exit (throws), hrtime (via performance.now), memoryUsage, uptime, emitWarning, on/off/once/emit (event stubs).
Utility functions.
import util from "@lifo-sh/node-compat/util";
util.format("%s has %d items", "list", 3); // "list has 3 items"
util.inspect({ a: 1, b: [2, 3] }); // "{ a: 1, b: [ 2, 3 ] }"
util.isDeepStrictEqual({ x: 1 }, { x: 1 }); // true
const fn = util.promisify(asyncOp);
const result = await fn(42);Supported APIs: TextEncoder, TextDecoder, promisify, callbackify, inherits, deprecate, inspect, format, isDeepStrictEqual.
Assertion module.
import assert from "@lifo-sh/node-compat/assert";
assert(true);
assert.strictEqual(1, 1);
assert.deepStrictEqual({ a: 1 }, { a: 1 });
assert.throws(() => { throw new Error("boom"); });Supported APIs: assert (ok), equal, notEqual, strictEqual, notStrictEqual, deepEqual, notDeepEqual, deepStrictEqual, notDeepStrictEqual, throws, doesNotThrow, ifError, fail. Also: AssertionError class.
Timer functions (re-exports with setImmediate polyfill).
import { setTimeout, setImmediate } from "@lifo-sh/node-compat/timers";
setTimeout(() => console.log("delayed"), 100);
setImmediate(() => console.log("immediate!")); // polyfilled via setTimeout(fn, 0)Supported APIs: setTimeout, setInterval, clearTimeout, clearInterval, setImmediate, clearImmediate.
OS information shim for browser environments.
import os from "@lifo-sh/node-compat/os";
os.platform(); // "browser"
os.arch(); // "wasm"
os.homedir(); // "/"
os.tmpdir(); // "/tmp"
os.cpus(); // based on navigator.hardwareConcurrency
os.EOL; // "\n"Supported APIs: platform, arch, type, release, homedir, tmpdir, hostname, cpus, totalmem, freemem, uptime, loadavg, networkInterfaces, userInfo, endianness, EOL.
@lifo-sh/node-compat
|
|-- fs, fs/promises --> @lifo-sh/vfs --> Backend (MemoryBackend)
|-- path (pure, no VFS)
|-- events (standalone EventEmitter)
|-- buffer (augmented Uint8Array)
|-- stream (Readable/Writable/Transform/Duplex + pipeline)
|-- crypto (Web Crypto API wrapper)
|-- url (globalThis.URL + Node-style helpers)
|-- process (browser shim)
|-- util (promisify, inspect, format, etc.)
|-- timers (globalThis timers + setImmediate polyfill)
|-- assert (assertion functions)
|-- os (browser shim)
The VFS is the core abstraction that makes fs work in the browser:
- Backend interface (
VFSBackend) -- defines all filesystem operations - MemoryBackend -- in-memory implementation using a tree of file/directory/symlink nodes
- Event-driven -- mutations emit events that power
fs.watchandfs.watchFile - Symlink support -- full symbolic link resolution with circular reference detection
- Swappable -- call
fs.configure(vfs)to use a different VFS instance
import { createVFS, MemoryBackend } from "@lifo-sh/vfs";
import fs from "@lifo-sh/node-compat/fs";
const vfs = createVFS({ backend: new MemoryBackend() });
fs.configure(vfs);
fs.writeFileSync("/hello.txt", "Hello from the browser!");Tests run in both environments to ensure true cross-platform compatibility:
pnpm test:node # Vitest in Node.js
pnpm test:browser # Vitest + Playwright in real ChromiumAll 94 stories have test assertions that validate actual behavior. The test runner executes each story's run() function and then calls its test(output) function to assert correctness.
node-compat/
packages/
vfs/ # @lifo-sh/vfs
src/
types.ts # VFSBackend, VFSStat interfaces
memory.ts # In-memory backend implementation
errors.ts # POSIX-style error factories (ENOENT, EEXIST, etc.)
watcher.ts # Event emitter for filesystem mutations
index.ts # createVFS factory
node-compat/ # @lifo-sh/node-compat
src/
fs.ts # fs module (callbacks + sync)
fs/promises.ts # fs.promises module
path.ts # path module
events.ts # EventEmitter
buffer.ts # Buffer
stream.ts # Readable, Writable, Transform, Duplex, pipeline
crypto.ts # createHash, createHmac, randomBytes, randomUUID
url.ts # URL, URLSearchParams, parse, format
process.ts # process shim
util.ts # promisify, inspect, format, etc.
timers.ts # setTimeout, setImmediate, etc.
assert.ts # assert module
os.ts # os shim
index.ts # re-exports
stories/ # Shared test stories
src/
index.ts # 94 stories with run + test functions
__tests__/
stories.test.ts # Vitest runner
vite-react-app/ # Browser playground (React + Tailwind + shadcn)
cli-app/ # CLI playground (interactive menu)
CLAUDE.md # Development instructions and conventions
tsconfig.base.json # Shared TypeScript config
pnpm-workspace.yaml # pnpm workspace config
- Node.js >= 18
- pnpm
# Build everything
pnpm build
# Build individual packages
pnpm --filter @lifo-sh/vfs run build
pnpm --filter @lifo-sh/node-compat run build
pnpm --filter stories run buildpnpm dev:vite # Browser playground at http://localhost:5173
pnpm dev:cli # CLI playground- Create
packages/node-compat/src/{module}.ts - Add the entry to
packages/node-compat/tsup.config.ts - Add the export map to
packages/node-compat/package.json - Add stories with tests to
packages/stories/src/index.ts - Build and run tests in both environments
See CLAUDE.md for the full implementation guide.
- pnpm workspaces -- monorepo management
- tsup -- ESM + CJS dual builds with DTS generation
- TypeScript -- strict mode, ES2022 target
- Vitest -- test runner for both Node.js and browser
- Playwright -- real Chromium browser testing
- React + Vite + Tailwind + shadcn -- browser playground UI
MIT