diff --git a/packages/vfs/package.json b/packages/vfs/package.json new file mode 100644 index 0000000..056d09d --- /dev/null +++ b/packages/vfs/package.json @@ -0,0 +1,16 @@ +{ + "name": "@scratchyjs/vfs", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "22.16.0", + "typescript": "5.8.3" + } +} diff --git a/packages/vfs/src/errors.ts b/packages/vfs/src/errors.ts new file mode 100644 index 0000000..f047bd3 --- /dev/null +++ b/packages/vfs/src/errors.ts @@ -0,0 +1,92 @@ +/** + * POSIX-style filesystem error helpers for the virtual file system. + * + * Each helper creates a standard {@link NodeJS.ErrnoException} with the + * appropriate `code`, `syscall`, and (where applicable) `path` properties. + * This mirrors the error shapes produced by the real `node:fs` module so that + * callers can use the same `err.code === 'ENOENT'` checks they already rely on. + */ + +function makeError( + code: string, + message: string, + syscall: string, + path?: string, +): NodeJS.ErrnoException { + const err: NodeJS.ErrnoException = new Error( + `${syscall}: ${message}${path !== undefined ? `, ${syscall} '${path}'` : ""}`, + ); + err.code = code; + err.syscall = syscall; + if (path !== undefined) { + err.path = path; + } + return err; +} + +export function createENOENT( + syscall: string, + path: string, +): NodeJS.ErrnoException { + return makeError("ENOENT", "no such file or directory", syscall, path); +} + +export function createENOTDIR( + syscall: string, + path: string, +): NodeJS.ErrnoException { + return makeError("ENOTDIR", "not a directory", syscall, path); +} + +export function createENOTEMPTY( + syscall: string, + path: string, +): NodeJS.ErrnoException { + return makeError("ENOTEMPTY", "directory not empty", syscall, path); +} + +export function createEISDIR( + syscall: string, + path: string, +): NodeJS.ErrnoException { + return makeError("EISDIR", "illegal operation on a directory", syscall, path); +} + +export function createEBADF(syscall: string): NodeJS.ErrnoException { + return makeError("EBADF", "bad file descriptor", syscall); +} + +export function createEEXIST( + syscall: string, + path: string, +): NodeJS.ErrnoException { + return makeError("EEXIST", "file already exists", syscall, path); +} + +export function createEROFS( + syscall: string, + path: string, +): NodeJS.ErrnoException { + return makeError("EROFS", "read-only file system", syscall, path); +} + +export function createEINVAL( + syscall: string, + path: string, +): NodeJS.ErrnoException { + return makeError("EINVAL", "invalid argument", syscall, path); +} + +export function createELOOP( + syscall: string, + path: string, +): NodeJS.ErrnoException { + return makeError("ELOOP", "too many levels of symbolic links", syscall, path); +} + +export function createEACCES( + syscall: string, + path: string, +): NodeJS.ErrnoException { + return makeError("EACCES", "permission denied", syscall, path); +} diff --git a/packages/vfs/src/file-system.ts b/packages/vfs/src/file-system.ts new file mode 100644 index 0000000..7e60fbd --- /dev/null +++ b/packages/vfs/src/file-system.ts @@ -0,0 +1,1248 @@ +/** + * VirtualFileSystem – the public API wrapper for `@scratchyjs/vfs`. + * + * Ported from `lib/internal/vfs/file_system.js` in Node.js PR #61478. + * Adapted for user-space TypeScript: + * + * - Mount/unmount is implemented via monkey-patching `node:fs` module + * properties rather than the C++-level `setVfsHandlers` hook used by the + * upstream Node.js PR. This means the VFS intercepts calls made through + * the `fs` module object (e.g. `fs.readFileSync(path)`) but not calls made + * via a destructured local binding created before `mount()` was called + * (e.g. `const { readFileSync } = fs` followed by `readFileSync(path)`). + * For tests this trade-off is acceptable: callers written to use `fs.*` + * methods directly will be intercepted transparently. + * + * - `addFile` / `addDirectory` are convenience helpers for pre-populating + * the VFS in test setup, matching the ergonomics of the test-runner + * `MockFSContext` API described in the PR. + * + * - Uses `path.join` instead of string concatenation for path construction + * (per avivkeller review feedback on the upstream PR). + */ +import { createENOENT, createENOTDIR } from "./errors.js"; +import type { VfsDirent } from "./memory-provider.js"; +import { MemoryProvider } from "./memory-provider.js"; +import { getRelativePath, isUnderMountPoint } from "./router.js"; +import * as fsNamespace from "node:fs"; +import { createRequire } from "node:module"; +import { join, posix as pathPosix, resolve } from "node:path"; + +// Use `createRequire` to obtain a direct reference to the raw CJS exports +// object from `node:fs`. This is mutable (properties are writable / +// configurable), whereas the ESM namespace object produced by +// `import * as fs from "node:fs"` exposes non-writable property bindings. +// Mutating this object patches `node:fs` for all callers that access it via +// `require("node:fs")` or `import fs from "node:fs"` — which is how most +// code (and `node:fs/promises`) accesses filesystem functions. +const _require = createRequire(import.meta.url); +const fs = _require("node:fs") as typeof fsNamespace; + +/** Type alias for forwarding unknown-typed arguments to saved fs functions. */ +type AnyFn = (...args: unknown[]) => unknown; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface VfsOptions { + /** Whether to enable virtual working-directory support (default: false). */ + virtualCwd?: boolean; + /** Whether to enable overlay mode – only intercept paths that exist in VFS (default: false). */ + overlay?: boolean; +} + +interface WriteFileOptions { + encoding?: BufferEncoding | null; + mode?: number; + flag?: string | number; +} + +interface ReadFileOptions { + encoding?: BufferEncoding | null; + flag?: string | number; +} + +interface ReaddirOptions { + withFileTypes?: boolean; + recursive?: boolean; +} + +// ─── addDirectory populate helper ──────────────────────────────────────────── + +interface DirHelper { + addFile(name: string, content: string | Buffer): void; + addDirectory(name: string, populate?: (dir: DirHelper) => void): void; +} + +function makeDirHelper( + baseProvPath: string, + provider: MemoryProvider, +): DirHelper { + return { + addFile: (name: string, content: string | Buffer) => { + const p = pathPosix.join(baseProvPath, name); + const buf = typeof content === "string" ? Buffer.from(content) : content; + provider.writeFileSync(p, buf); + }, + addDirectory: (name: string, populateChild?: (dir: DirHelper) => void) => { + const p = pathPosix.join(baseProvPath, name); + provider.mkdirSync(p, { recursive: true }); + if (typeof populateChild === "function") { + populateChild(makeDirHelper(p, provider)); + } + }, + }; +} + +// ─── Saved originals bucket ────────────────────────────────────────────────── + +interface SavedFsMethods { + existsSync: typeof fs.existsSync; + readFileSync: typeof fs.readFileSync; + writeFileSync: typeof fs.writeFileSync; + appendFileSync: typeof fs.appendFileSync; + statSync: typeof fs.statSync; + lstatSync: typeof fs.lstatSync; + readdirSync: typeof fs.readdirSync; + mkdirSync: typeof fs.mkdirSync; + rmdirSync: typeof fs.rmdirSync; + rmSync: typeof fs.rmSync; + unlinkSync: typeof fs.unlinkSync; + renameSync: typeof fs.renameSync; + copyFileSync: typeof fs.copyFileSync; + symlinkSync: typeof fs.symlinkSync; + chmodSync: typeof fs.chmodSync; + chownSync: typeof fs.chownSync; + utimesSync: typeof fs.utimesSync; + realpathSync: typeof fs.realpathSync; + readlinkSync: typeof fs.readlinkSync; + accessSync: typeof fs.accessSync; + mkdtempSync: typeof fs.mkdtempSync; + truncateSync: typeof fs.truncateSync; + linkSync: typeof fs.linkSync; + promises: { + readFile: typeof fs.promises.readFile; + writeFile: typeof fs.promises.writeFile; + appendFile: typeof fs.promises.appendFile; + stat: typeof fs.promises.stat; + lstat: typeof fs.promises.lstat; + readdir: typeof fs.promises.readdir; + mkdir: typeof fs.promises.mkdir; + rmdir: typeof fs.promises.rmdir; + rm: typeof fs.promises.rm; + unlink: typeof fs.promises.unlink; + rename: typeof fs.promises.rename; + copyFile: typeof fs.promises.copyFile; + symlink: typeof fs.promises.symlink; + chmod: typeof fs.promises.chmod; + chown: typeof fs.promises.chown; + utimes: typeof fs.promises.utimes; + realpath: typeof fs.promises.realpath; + readlink: typeof fs.promises.readlink; + access: typeof fs.promises.access; + mkdtemp: typeof fs.promises.mkdtemp; + truncate: typeof fs.promises.truncate; + link: typeof fs.promises.link; + }; +} + +// ─── VirtualFileSystem ──────────────────────────────────────────────────────── + +/** + * A virtual file system backed by an in-memory provider. + * + * Typical test usage: + * + * ```ts + * import { create } from "@scratchyjs/vfs"; + * + * const vfs = create(); + * vfs.writeFileSync("/config.json", JSON.stringify({ port: 3000 })); + * vfs.mount("/virtual"); + * + * // From here on, code that calls fs.readFileSync("/virtual/config.json") + * // will read from the in-memory VFS instead of the real filesystem. + * + * // … run the code under test … + * + * vfs.unmount(); // always unmount in afterEach / finally + * ``` + */ +export class VirtualFileSystem { + readonly #provider: MemoryProvider; + #mountPoint: string | null = null; + #mounted = false; + #overlay: boolean; + #virtualCwdEnabled: boolean; + #virtualCwd: string | null = null; + #savedMethods: SavedFsMethods | null = null; + #originalChdir: typeof process.chdir | null = null; + #originalCwd: typeof process.cwd | null = null; + + constructor(options: VfsOptions = {}) { + this.#provider = new MemoryProvider(); + this.#overlay = options.overlay === true; + this.#virtualCwdEnabled = options.virtualCwd === true; + } + + // ─── Metadata getters ────────────────────────────────────────────────────── + + get mountPoint(): string | null { + return this.#mountPoint; + } + + get mounted(): boolean { + return this.#mounted; + } + + get overlay(): boolean { + return this.#overlay; + } + + get virtualCwdEnabled(): boolean { + return this.#virtualCwdEnabled; + } + + // ─── Mount lifecycle ─────────────────────────────────────────────────────── + + /** + * Mounts the VFS at `prefix`, installing monkey-patches on `node:fs` that + * route all paths starting with `prefix` through the in-memory provider. + * + * @param prefix Absolute path prefix under which the VFS is visible. + * @returns `this` for method chaining. + */ + mount(prefix: string): this { + if (this.#mounted) { + throw new Error("VFS is already mounted"); + } + this.#mountPoint = resolve(prefix); + this.#mounted = true; + this.#installHooks(); + if (this.#virtualCwdEnabled) { + this.#hookProcessCwd(); + } + return this; + } + + /** + * Unmounts the VFS and restores the original `node:fs` methods. + * Always call this in `afterEach` or a `finally` block. + */ + unmount(): void { + if (!this.#mounted) return; + this.#restoreHooks(); + if (this.#virtualCwdEnabled) { + this.#unhookProcessCwd(); + } + this.#mountPoint = null; + this.#mounted = false; + this.#virtualCwd = null; + } + + /** Dispose via `using` declaration (Explicit Resource Management). */ + [Symbol.dispose](): void { + if (this.#mounted) this.unmount(); + } + + // ─── Virtual CWD ────────────────────────────────────────────────────────── + + /** + * Returns the virtual current working directory, or `null` if not set. + * Only available when `virtualCwd: true` was passed to `create()`. + */ + cwd(): string | null { + if (!this.#virtualCwdEnabled) { + throw Object.assign(new Error("virtual cwd is not enabled"), { + code: "ERR_INVALID_STATE", + }); + } + return this.#virtualCwd; + } + + /** + * Sets the virtual current working directory. + * The path must exist in the VFS and be a directory. + */ + chdir(dirPath: string): void { + if (!this.#virtualCwdEnabled) { + throw Object.assign(new Error("virtual cwd is not enabled"), { + code: "ERR_INVALID_STATE", + }); + } + const providerPath = this.#toProviderPath(dirPath); + const stats = this.#provider.statSync(providerPath); + if (!stats.isDirectory()) throw createENOTDIR("chdir", dirPath); + this.#virtualCwd = this.#toMountedPath(providerPath); + } + + /** + * Resolves `inputPath` relative to the virtual cwd when set; + * otherwise behaves like `path.resolve`. + */ + resolvePath(inputPath: string): string { + if (inputPath.startsWith("/") || inputPath.match(/^[A-Za-z]:\\/)) { + return resolve(inputPath); + } + if (this.#virtualCwdEnabled && this.#virtualCwd !== null) { + return resolve(join(this.#virtualCwd, inputPath)); + } + return resolve(inputPath); + } + + // ─── Convenience helpers for test setup ─────────────────────────────────── + + /** + * Creates (or overwrites) a virtual file. + * + * Works both before and after `mount()`. When called before mounting, + * paths are provider-internal POSIX paths (e.g. `/src/index.ts`). After + * mounting, paths are interpreted relative to the mount prefix. + */ + addFile( + path: string, + content: string | Buffer, + options?: { mode?: number; encoding?: BufferEncoding }, + ): this { + const buf = + typeof content === "string" + ? Buffer.from(content, options?.encoding ?? "utf8") + : content; + const provPath = this.#mounted + ? this.#toProviderPath(path) + : this.#normProviderPath(path); + this.#provider.writeFileSync(provPath, buf); + return this; + } + + /** + * Creates a virtual directory (recursively). + * + * An optional `populate` callback receives a scoped helper to add entries + * inside the directory – mirroring the lazy-population pattern from the + * upstream Node.js PR. + */ + addDirectory(path: string, populate?: (dir: DirHelper) => void): this { + const provPath = this.#mounted + ? this.#toProviderPath(path) + : this.#normProviderPath(path); + this.#provider.mkdirSync(provPath, { recursive: true }); + if (typeof populate === "function") { + populate(makeDirHelper(provPath, this.#provider)); + } + return this; + } + + // ─── Sync FS operations (delegating to the provider) ───────────────────── + + existsSync(path: string): boolean { + try { + const pp = this.#toProviderPath(path); + return this.#provider.existsSync(pp); + } catch { + return false; + } + } + + statSync(path: string): ReturnType { + return this.#provider.statSync(this.#toProviderPath(path)); + } + + lstatSync(path: string): ReturnType { + return this.#provider.lstatSync(this.#toProviderPath(path)); + } + + readFileSync( + path: string, + options?: ReadFileOptions | BufferEncoding | null, + ): Buffer | string { + return this.#provider.readFileSync(this.#toProviderPath(path), options); + } + + writeFileSync( + path: string, + data: string | Buffer | Uint8Array, + options?: WriteFileOptions | BufferEncoding | null, + ): void { + this.#provider.writeFileSync(this.#toProviderPath(path), data, options); + } + + appendFileSync( + path: string, + data: string | Buffer | Uint8Array, + options?: WriteFileOptions | BufferEncoding | null, + ): void { + this.#provider.appendFileSync(this.#toProviderPath(path), data, options); + } + + readdirSync(path: string, options?: ReaddirOptions): string[] | VfsDirent[] { + return this.#provider.readdirSync(this.#toProviderPath(path), options); + } + + mkdirSync( + path: string, + options?: { recursive?: boolean; mode?: number }, + ): string | undefined { + const result = this.#provider.mkdirSync( + this.#toProviderPath(path), + options, + ); + if (result !== undefined) return this.#toMountedPath(result); + return undefined; + } + + rmdirSync(path: string): void { + this.#provider.rmdirSync(this.#toProviderPath(path)); + } + + rmSync( + path: string, + options?: { recursive?: boolean; force?: boolean }, + ): void { + this.#provider.rmSync(this.#toProviderPath(path), options); + } + + unlinkSync(path: string): void { + this.#provider.unlinkSync(this.#toProviderPath(path)); + } + + renameSync(oldPath: string, newPath: string): void { + this.#provider.renameSync( + this.#toProviderPath(oldPath), + this.#toProviderPath(newPath), + ); + } + + copyFileSync(src: string, dest: string): void { + this.#provider.copyFileSync( + this.#toProviderPath(src), + this.#toProviderPath(dest), + ); + } + + symlinkSync(target: string, path: string): void { + const providerTarget = pathPosix.isAbsolute(target) + ? this.#toProviderPath(target) + : target; + this.#provider.symlinkSync(providerTarget, this.#toProviderPath(path)); + } + + readlinkSync(path: string): string { + const result = this.#provider.readlinkSync(this.#toProviderPath(path)); + return pathPosix.isAbsolute(result) + ? this.#toMountedPath(result) + : result; + } + + realpathSync(path: string): string { + const result = this.#provider.realpathSync(this.#toProviderPath(path)); + return this.#toMountedPath(result); + } + + accessSync(path: string, mode?: number): void { + this.#provider.accessSync(this.#toProviderPath(path), mode); + } + + chmodSync(path: string, mode: string | number): void { + this.#provider.chmodSync(this.#toProviderPath(path), mode); + } + + chownSync(path: string, uid: number, gid: number): void { + this.#provider.chownSync(this.#toProviderPath(path), uid, gid); + } + + utimesSync(path: string, atime: number | Date, mtime: number | Date): void { + this.#provider.utimesSync(this.#toProviderPath(path), atime, mtime); + } + + mkdtempSync(prefix: string): string { + const result = this.#provider.mkdtempSync(this.#toProviderPath(prefix)); + return this.#toMountedPath(result); + } + + truncateSync(path: string, len?: number): void { + this.#provider.truncateSync(this.#toProviderPath(path), len); + } + + linkSync(existingPath: string, newPath: string): void { + this.#provider.linkSync( + this.#toProviderPath(existingPath), + this.#toProviderPath(newPath), + ); + } + + // ─── Promises ───────────────────────────────────────────────────────────── + + /** Async equivalents of all sync operations. */ + get promises(): { + readFile( + path: string, + options?: ReadFileOptions | BufferEncoding | null, + ): Promise; + writeFile( + path: string, + data: string | Buffer | Uint8Array, + options?: WriteFileOptions | BufferEncoding | null, + ): Promise; + appendFile( + path: string, + data: string | Buffer | Uint8Array, + options?: WriteFileOptions | BufferEncoding | null, + ): Promise; + stat(path: string): Promise>; + lstat(path: string): Promise>; + readdir( + path: string, + options?: ReaddirOptions, + ): Promise; + mkdir( + path: string, + options?: { recursive?: boolean; mode?: number }, + ): Promise; + rmdir(path: string): Promise; + rm( + path: string, + options?: { recursive?: boolean; force?: boolean }, + ): Promise; + unlink(path: string): Promise; + rename(oldPath: string, newPath: string): Promise; + copyFile(src: string, dest: string): Promise; + symlink(target: string, path: string): Promise; + readlink(path: string): Promise; + realpath(path: string): Promise; + access(path: string, mode?: number): Promise; + chmod(path: string, mode: string | number): Promise; + chown(path: string, uid: number, gid: number): Promise; + utimes( + path: string, + atime: number | Date, + mtime: number | Date, + ): Promise; + mkdtemp(prefix: string): Promise; + truncate(path: string, len?: number): Promise; + link(existingPath: string, newPath: string): Promise; + } { + return { + readFile: (p, o) => this.#provider.readFile(this.#toProviderPath(p), o), + writeFile: (p, d, o) => + this.#provider.writeFile(this.#toProviderPath(p), d, o), + appendFile: (p, d, o) => + this.#provider.appendFile(this.#toProviderPath(p), d, o), + stat: (p) => this.#provider.stat(this.#toProviderPath(p)), + lstat: (p) => this.#provider.lstat(this.#toProviderPath(p)), + readdir: (p, o) => this.#provider.readdir(this.#toProviderPath(p), o), + mkdir: async (p, o) => { + const result = await this.#provider.mkdir(this.#toProviderPath(p), o); + return result !== undefined ? this.#toMountedPath(result) : undefined; + }, + rmdir: (p) => this.#provider.rmdir(this.#toProviderPath(p)), + rm: (p, o) => this.#provider.rm(this.#toProviderPath(p), o), + unlink: (p) => this.#provider.unlink(this.#toProviderPath(p)), + rename: (o, n) => + this.#provider.rename(this.#toProviderPath(o), this.#toProviderPath(n)), + copyFile: (s, d) => + this.#provider.copyFile( + this.#toProviderPath(s), + this.#toProviderPath(d), + ), + symlink: (t, p) => this.#provider.symlink(t, this.#toProviderPath(p)), + readlink: (p) => + Promise.resolve(this.#provider.readlinkSync(this.#toProviderPath(p))), + realpath: async (p) => + this.#toMountedPath( + this.#provider.realpathSync(this.#toProviderPath(p)), + ), + access: (p, m) => { + this.#provider.accessSync(this.#toProviderPath(p), m); + return Promise.resolve(); + }, + chmod: (p, m) => this.#provider.chmod(this.#toProviderPath(p), m), + chown: (p, u, g) => this.#provider.chown(this.#toProviderPath(p), u, g), + utimes: (p, a, t) => { + this.#provider.utimesSync(this.#toProviderPath(p), a, t); + return Promise.resolve(); + }, + mkdtemp: async (p) => + this.#toMountedPath( + await this.#provider.mkdtemp(this.#toProviderPath(p)), + ), + truncate: (p, l) => this.#provider.truncate(this.#toProviderPath(p), l), + link: (e, n) => + this.#provider.link(this.#toProviderPath(e), this.#toProviderPath(n)), + }; + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + /** Returns `true` when an absolute path should be handled by this VFS. */ + #shouldHandle(inputPath: string): boolean { + if (!this.#mounted || !this.#mountPoint) return false; + const normalized = resolve(inputPath); + if (!isUnderMountPoint(normalized, this.#mountPoint)) return false; + if (this.#overlay) { + try { + return this.#provider.existsSync( + getRelativePath(normalized, this.#mountPoint), + ); + } catch { + return false; + } + } + return true; + } + + /** Translates an absolute mounted path to a provider-internal path. */ + #toProviderPath(inputPath: string): string { + if (this.#mounted && this.#mountPoint) { + const resolved = resolve(inputPath); + if (isUnderMountPoint(resolved, this.#mountPoint)) { + return getRelativePath(resolved, this.#mountPoint); + } + throw createENOENT("open", inputPath); + } + return this.#normProviderPath(inputPath); + } + + /** Translates a provider-internal path to the mounted absolute path. */ + #toMountedPath(providerPath: string): string { + if (this.#mounted && this.#mountPoint) { + // Ensure `providerPath` is treated as relative when joining so that + // `this.#mountPoint` is not discarded even if `providerPath` is + // absolute (starts with `/` or `\`). + const relativeProviderPath = providerPath.replace(/^[/\\]+/, ""); + return join(this.#mountPoint, relativeProviderPath); + } + return providerPath; + } + + /** Normalises a pre-mount provider path to POSIX format. */ + #normProviderPath(p: string): string { + return "/" + p.replace(/\\/g, "/").replace(/^\/+/, ""); + } + + // ─── Monkey-patching ────────────────────────────────────────────────────── + + #installHooks(): void { + const saved: SavedFsMethods = { + existsSync: fs.existsSync, + readFileSync: fs.readFileSync, + writeFileSync: fs.writeFileSync, + appendFileSync: fs.appendFileSync, + statSync: fs.statSync, + lstatSync: fs.lstatSync, + readdirSync: fs.readdirSync, + mkdirSync: fs.mkdirSync, + rmdirSync: fs.rmdirSync, + rmSync: fs.rmSync, + unlinkSync: fs.unlinkSync, + renameSync: fs.renameSync, + copyFileSync: fs.copyFileSync, + symlinkSync: fs.symlinkSync, + chmodSync: fs.chmodSync, + chownSync: fs.chownSync, + utimesSync: fs.utimesSync, + realpathSync: fs.realpathSync, + readlinkSync: fs.readlinkSync, + accessSync: fs.accessSync, + mkdtempSync: fs.mkdtempSync, + truncateSync: fs.truncateSync, + linkSync: fs.linkSync, + promises: { + readFile: fs.promises.readFile, + writeFile: fs.promises.writeFile, + appendFile: fs.promises.appendFile, + stat: fs.promises.stat, + lstat: fs.promises.lstat, + readdir: fs.promises.readdir, + mkdir: fs.promises.mkdir, + rmdir: fs.promises.rmdir, + rm: fs.promises.rm, + unlink: fs.promises.unlink, + rename: fs.promises.rename, + copyFile: fs.promises.copyFile, + symlink: fs.promises.symlink, + chmod: fs.promises.chmod, + chown: fs.promises.chown, + utimes: fs.promises.utimes, + realpath: fs.promises.realpath, + readlink: fs.promises.readlink, + access: fs.promises.access, + mkdtemp: fs.promises.mkdtemp, + truncate: fs.promises.truncate, + link: fs.promises.link, + }, + }; + this.#savedMethods = saved; + + // Obtain a mutable reference to the fs module (typed as a record so that + // individual method replacements below don't require per-line eslint-disable + // comments for the `any` cast). + const fsMut = fs as unknown as Record; + const promMut = fs.promises as unknown as Record; + + // ── Sync methods ──────────────────────────────────────────────────────── + fsMut.existsSync = (p: unknown) => + typeof p === "string" && this.#shouldHandle(p) + ? this.#provider.existsSync(this.#toProviderPath(p)) + : saved.existsSync(p as string); + + fsMut.readFileSync = (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.readFileSync( + this.#toProviderPath(p), + opts as ReadFileOptions | BufferEncoding | null, + ); + } + return (saved.readFileSync as AnyFn)(p, opts); + }; + + fsMut.writeFileSync = (p: unknown, data: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + this.#provider.writeFileSync( + this.#toProviderPath(p), + data as string | Buffer, + opts as WriteFileOptions | BufferEncoding | null, + ); + return; + } + return (saved.writeFileSync as AnyFn)(p, data, opts); + }; + + fsMut.appendFileSync = (p: unknown, data: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + this.#provider.appendFileSync( + this.#toProviderPath(p), + data as string | Buffer, + opts as WriteFileOptions | BufferEncoding | null, + ); + return; + } + return (saved.appendFileSync as AnyFn)(p, data, opts); + }; + + fsMut.statSync = (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.statSync(this.#toProviderPath(p)); + } + return (saved.statSync as AnyFn)(p, opts); + }; + + fsMut.lstatSync = (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.lstatSync(this.#toProviderPath(p)); + } + return (saved.lstatSync as AnyFn)(p, opts); + }; + + fsMut.readdirSync = (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.readdirSync( + this.#toProviderPath(p), + opts as ReaddirOptions, + ); + } + return (saved.readdirSync as AnyFn)(p, opts); + }; + + fsMut.mkdirSync = (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + const result = this.#provider.mkdirSync( + this.#toProviderPath(p), + opts as { recursive?: boolean; mode?: number }, + ); + return result !== undefined ? this.#toMountedPath(result) : undefined; + } + return (saved.mkdirSync as AnyFn)(p, opts); + }; + + fsMut.rmdirSync = (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.rmdirSync(this.#toProviderPath(p)); + } + return (saved.rmdirSync as AnyFn)(p, opts); + }; + + fsMut.rmSync = (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.rmSync( + this.#toProviderPath(p), + opts as { recursive?: boolean; force?: boolean }, + ); + } + return (saved.rmSync as AnyFn)(p, opts); + }; + + fsMut.unlinkSync = (p: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.unlinkSync(this.#toProviderPath(p)); + } + return (saved.unlinkSync as AnyFn)(p); + }; + + fsMut.renameSync = (oldP: unknown, newP: unknown) => { + if (typeof oldP === "string" && typeof newP === "string") { + const oldInVfs = this.#shouldHandle(oldP); + const newInVfs = this.#shouldHandle(newP); + + // Both paths are under the virtual mount: handle entirely in the provider. + if (oldInVfs && newInVfs) { + return this.#provider.renameSync( + this.#toProviderPath(oldP), + this.#toProviderPath(newP), + ); + } + + // One path is virtual and the other is real: do not allow a cross-device + // rename between VFS and the real filesystem. + if (oldInVfs !== newInVfs) { + const err: NodeJS.ErrnoException = new Error( + "Cross-device link not permitted between virtual and real file systems", + ); + err.code = "EXDEV"; + throw err; + } + } + + // Fallback: both paths are outside the mount or non-string; delegate to the + // original fs.renameSync implementation. + return (saved.renameSync as AnyFn)(oldP, newP); + }; + + fsMut.copyFileSync = (src: unknown, dest: unknown, mode?: unknown) => { + if (typeof src === "string" && typeof dest === "string") { + const srcHandled = this.#shouldHandle(src); + const destHandled = this.#shouldHandle(dest); + + if (srcHandled && destHandled) { + return this.#provider.copyFileSync( + this.#toProviderPath(src), + this.#toProviderPath(dest), + ); + } + + if (srcHandled !== destHandled) { + const err = new Error("Cross-device link not permitted") as NodeJS.ErrnoException; + err.code = "EXDEV"; + throw err; + } + } + return (saved.copyFileSync as AnyFn)(src, dest, mode); + }; + + fsMut.symlinkSync = (target: unknown, p: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + let internalTarget = target; + if ( + typeof target === "string" && + pathPosix.isAbsolute(target) && + this.#shouldHandle(target) + ) { + internalTarget = this.#toProviderPath(target); + } + return this.#provider.symlinkSync( + internalTarget as string, + this.#toProviderPath(p), + ); + } + return (saved.symlinkSync as AnyFn)(target, p); + }; + + fsMut.chmodSync = (p: unknown, mode: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.chmodSync( + this.#toProviderPath(p), + mode as string | number, + ); + } + return (saved.chmodSync as AnyFn)(p, mode); + }; + + fsMut.chownSync = (p: unknown, uid: unknown, gid: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.chownSync( + this.#toProviderPath(p), + uid as number, + gid as number, + ); + } + return (saved.chownSync as AnyFn)(p, uid, gid); + }; + + fsMut.utimesSync = (p: unknown, atime: unknown, mtime: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.utimesSync( + this.#toProviderPath(p), + atime as number | Date, + mtime as number | Date, + ); + } + return (saved.utimesSync as AnyFn)(p, atime, mtime); + }; + + fsMut.realpathSync = (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#toMountedPath( + this.#provider.realpathSync(this.#toProviderPath(p)), + ); + } + return (saved.realpathSync as AnyFn)(p, opts); + }; + + fsMut.readlinkSync = (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.readlinkSync(this.#toProviderPath(p)); + } + return (saved.readlinkSync as AnyFn)(p, opts); + }; + + fsMut.accessSync = (p: unknown, mode?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.accessSync( + this.#toProviderPath(p), + mode as number | undefined, + ); + } + return (saved.accessSync as AnyFn)(p, mode); + }; + + fsMut.mkdtempSync = (prefix: unknown, opts?: unknown) => { + if (typeof prefix === "string" && this.#shouldHandle(prefix)) { + return this.#toMountedPath( + this.#provider.mkdtempSync(this.#toProviderPath(prefix)), + ); + } + return (saved.mkdtempSync as AnyFn)(prefix, opts); + }; + + fsMut.truncateSync = (p: unknown, len?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.truncateSync( + this.#toProviderPath(p), + len as number | undefined, + ); + } + return (saved.truncateSync as AnyFn)(p, len); + }; + + fsMut.linkSync = (existing: unknown, newP: unknown) => { + if (typeof existing === "string" && this.#shouldHandle(existing)) { + return this.#provider.linkSync( + this.#toProviderPath(existing), + this.#toProviderPath(newP as string), + ); + } + return (saved.linkSync as AnyFn)(existing, newP); + }; + + // ── Promise methods ───────────────────────────────────────────────────── + + promMut.readFile = async (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.readFile( + this.#toProviderPath(p), + opts as ReadFileOptions | BufferEncoding | null, + ); + } + return (saved.promises.readFile as AnyFn)(p, opts); + }; + + promMut.writeFile = async (p: unknown, data: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.writeFile( + this.#toProviderPath(p), + data as string | Buffer, + opts as WriteFileOptions | BufferEncoding | null, + ); + } + return (saved.promises.writeFile as AnyFn)(p, data, opts); + }; + + promMut.appendFile = async (p: unknown, data: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.appendFile( + this.#toProviderPath(p), + data as string | Buffer, + opts as WriteFileOptions | BufferEncoding | null, + ); + } + return (saved.promises.appendFile as AnyFn)(p, data, opts); + }; + + promMut.stat = async (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.stat(this.#toProviderPath(p)); + } + return (saved.promises.stat as AnyFn)(p, opts); + }; + + promMut.lstat = async (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.lstat(this.#toProviderPath(p)); + } + return (saved.promises.lstat as AnyFn)(p, opts); + }; + + promMut.readdir = async (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.readdir( + this.#toProviderPath(p), + opts as ReaddirOptions, + ); + } + return (saved.promises.readdir as AnyFn)(p, opts); + }; + + promMut.mkdir = async (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + const result = await this.#provider.mkdir( + this.#toProviderPath(p), + opts as { recursive?: boolean; mode?: number }, + ); + return result !== undefined ? this.#toMountedPath(result) : undefined; + } + return (saved.promises.mkdir as AnyFn)(p, opts); + }; + + promMut.rmdir = async (p: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.rmdir(this.#toProviderPath(p)); + } + return (saved.promises.rmdir as AnyFn)(p); + }; + + promMut.rm = async (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.rm( + this.#toProviderPath(p), + opts as { recursive?: boolean; force?: boolean }, + ); + } + return (saved.promises.rm as AnyFn)(p, opts); + }; + + promMut.unlink = async (p: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.unlink(this.#toProviderPath(p)); + } + return (saved.promises.unlink as AnyFn)(p); + }; + + promMut.rename = async (oldP: unknown, newP: unknown) => { + if (typeof oldP === "string" && this.#shouldHandle(oldP)) { + return this.#provider.rename( + this.#toProviderPath(oldP), + this.#toProviderPath(newP as string), + ); + } + return (saved.promises.rename as AnyFn)(oldP, newP); + }; + + promMut.copyFile = async (src: unknown, dest: unknown) => { + if (typeof src === "string" && this.#shouldHandle(src)) { + return this.#provider.copyFile( + this.#toProviderPath(src), + this.#toProviderPath(dest as string), + ); + } + return (saved.promises.copyFile as AnyFn)(src, dest); + }; + + promMut.symlink = async (target: unknown, p: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + const internalTarget = + typeof target === "string" && this.#shouldHandle(target) + ? this.#toProviderPath(target) + : (target as string); + return this.#provider.symlink( + internalTarget, + this.#toProviderPath(p), + ); + } + return (saved.promises.symlink as AnyFn)(target, p); + }; + + promMut.chmod = async (p: unknown, mode: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.chmod( + this.#toProviderPath(p), + mode as string | number, + ); + } + return (saved.promises.chmod as AnyFn)(p, mode); + }; + + promMut.chown = async (p: unknown, uid: unknown, gid: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.chown( + this.#toProviderPath(p), + uid as number, + gid as number, + ); + } + return (saved.promises.chown as AnyFn)(p, uid, gid); + }; + + promMut.utimes = async (p: unknown, atime: unknown, mtime: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + this.#provider.utimesSync( + this.#toProviderPath(p), + atime as number | Date, + mtime as number | Date, + ); + return; + } + return (saved.promises.utimes as AnyFn)(p, atime, mtime); + }; + + promMut.realpath = async (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#toMountedPath( + this.#provider.realpathSync(this.#toProviderPath(p)), + ); + } + return (saved.promises.realpath as AnyFn)(p, opts); + }; + + promMut.readlink = async (p: unknown, opts?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.readlinkSync(this.#toProviderPath(p)); + } + return (saved.promises.readlink as AnyFn)(p, opts); + }; + + promMut.access = async (p: unknown, mode?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + this.#provider.accessSync( + this.#toProviderPath(p), + mode as number | undefined, + ); + return; + } + return (saved.promises.access as AnyFn)(p, mode); + }; + + promMut.mkdtemp = async (prefix: unknown, opts?: unknown) => { + if (typeof prefix === "string" && this.#shouldHandle(prefix)) { + return this.#toMountedPath( + await this.#provider.mkdtemp(this.#toProviderPath(prefix)), + ); + } + return (saved.promises.mkdtemp as AnyFn)(prefix, opts); + }; + + promMut.truncate = async (p: unknown, len?: unknown) => { + if (typeof p === "string" && this.#shouldHandle(p)) { + return this.#provider.truncate( + this.#toProviderPath(p), + len as number | undefined, + ); + } + return (saved.promises.truncate as AnyFn)(p, len); + }; + + promMut.link = async (existing: unknown, newP: unknown) => { + if (typeof existing === "string" && this.#shouldHandle(existing)) { + return this.#provider.link( + this.#toProviderPath(existing), + this.#toProviderPath(newP as string), + ); + } + return (saved.promises.link as AnyFn)(existing, newP); + }; + } + + #restoreHooks(): void { + if (!this.#savedMethods) return; + const saved = this.#savedMethods; + + const fsMut = fs as unknown as Record; + fsMut.existsSync = saved.existsSync; + fsMut.readFileSync = saved.readFileSync; + fsMut.writeFileSync = saved.writeFileSync; + fsMut.appendFileSync = saved.appendFileSync; + fsMut.statSync = saved.statSync; + fsMut.lstatSync = saved.lstatSync; + fsMut.readdirSync = saved.readdirSync; + fsMut.mkdirSync = saved.mkdirSync; + fsMut.rmdirSync = saved.rmdirSync; + fsMut.rmSync = saved.rmSync; + fsMut.unlinkSync = saved.unlinkSync; + fsMut.renameSync = saved.renameSync; + fsMut.copyFileSync = saved.copyFileSync; + fsMut.symlinkSync = saved.symlinkSync; + fsMut.chmodSync = saved.chmodSync; + fsMut.chownSync = saved.chownSync; + fsMut.utimesSync = saved.utimesSync; + fsMut.realpathSync = saved.realpathSync; + fsMut.readlinkSync = saved.readlinkSync; + fsMut.accessSync = saved.accessSync; + fsMut.mkdtempSync = saved.mkdtempSync; + fsMut.truncateSync = saved.truncateSync; + fsMut.linkSync = saved.linkSync; + + const promMut = fs.promises as unknown as Record; + promMut.readFile = saved.promises.readFile; + promMut.writeFile = saved.promises.writeFile; + promMut.appendFile = saved.promises.appendFile; + promMut.stat = saved.promises.stat; + promMut.lstat = saved.promises.lstat; + promMut.readdir = saved.promises.readdir; + promMut.mkdir = saved.promises.mkdir; + promMut.rmdir = saved.promises.rmdir; + promMut.rm = saved.promises.rm; + promMut.unlink = saved.promises.unlink; + promMut.rename = saved.promises.rename; + promMut.copyFile = saved.promises.copyFile; + promMut.symlink = saved.promises.symlink; + promMut.chmod = saved.promises.chmod; + promMut.chown = saved.promises.chown; + promMut.utimes = saved.promises.utimes; + promMut.realpath = saved.promises.realpath; + promMut.readlink = saved.promises.readlink; + promMut.access = saved.promises.access; + promMut.mkdtemp = saved.promises.mkdtemp; + promMut.truncate = saved.promises.truncate; + promMut.link = saved.promises.link; + + this.#savedMethods = null; + } + + // ─── process.cwd / process.chdir hooks ──────────────────────────────────── + + #hookProcessCwd(): void { + if (this.#originalChdir !== null) return; + + this.#originalChdir = process.chdir; + this.#originalCwd = process.cwd; + + // Capture the saved originals in local constants (guaranteed non-null here) + // so that arrow functions below can call them without a non-null assertion. + const savedChdir = this.#originalChdir; + const savedCwd = this.#originalCwd; + + process.chdir = (directory: string): void => { + const normalized = resolve(directory); + if (this.#shouldHandle(normalized)) { + this.chdir(normalized); + return; + } + savedChdir.call(process, directory); + }; + + process.cwd = (): string => { + if (this.#virtualCwd !== null) { + return this.#virtualCwd; + } + return savedCwd.call(process); + }; + } + + #unhookProcessCwd(): void { + if (this.#originalChdir === null) return; + process.chdir = this.#originalChdir; + if (this.#originalCwd !== null) { + process.cwd = this.#originalCwd; + } + this.#originalChdir = null; + this.#originalCwd = null; + } +} diff --git a/packages/vfs/src/index.test.ts b/packages/vfs/src/index.test.ts new file mode 100644 index 0000000..1874f45 --- /dev/null +++ b/packages/vfs/src/index.test.ts @@ -0,0 +1,509 @@ +/** + * Tests for `@scratchyjs/vfs`. + * + * Covers three layers: + * 1. `MemoryProvider` – low-level in-memory FS operations. + * 2. `VirtualFileSystem` – path routing and `addFile`/`addDirectory` helpers. + * 3. `mount()` / `unmount()` – monkey-patching of `node:fs`. + */ +import { create } from "./index.js"; +import { MemoryProvider } from "./memory-provider.js"; +import fs from "node:fs"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +// ─── MemoryProvider ─────────────────────────────────────────────────────────── + +describe("MemoryProvider", () => { + let provider: MemoryProvider; + + beforeEach(() => { + provider = new MemoryProvider(); + }); + + it("returns false for existsSync on a fresh provider", () => { + expect(provider.existsSync("/missing")).toBe(false); + }); + + it("writes and reads a file as Buffer", () => { + provider.writeFileSync("/hello.txt", Buffer.from("hello")); + const result = provider.readFileSync("/hello.txt"); + expect(Buffer.isBuffer(result)).toBe(true); + expect((result as Buffer).toString()).toBe("hello"); + }); + + it("writes and reads a file as string with encoding", () => { + provider.writeFileSync("/greet.txt", "world"); + const result = provider.readFileSync("/greet.txt", { encoding: "utf8" }); + expect(result).toBe("world"); + }); + + it("existsSync returns true after writing a file", () => { + provider.writeFileSync("/exists.txt", "data"); + expect(provider.existsSync("/exists.txt")).toBe(true); + }); + + it("statSync returns an isFile() stat for a file", () => { + provider.writeFileSync("/stat.txt", "abc"); + const stats = provider.statSync("/stat.txt"); + expect(stats.isFile()).toBe(true); + expect(stats.isDirectory()).toBe(false); + expect(stats.size).toBe(3); + }); + + it("statSync returns an isDirectory() stat for a directory", () => { + provider.mkdirSync("/mydir"); + const stats = provider.statSync("/mydir"); + expect(stats.isDirectory()).toBe(true); + expect(stats.isFile()).toBe(false); + }); + + it("throws ENOENT when reading a missing file", () => { + expect(() => provider.readFileSync("/nope.txt")).toThrow( + expect.objectContaining({ code: "ENOENT" }), + ); + }); + + it("throws EISDIR when reading a directory as a file", () => { + provider.mkdirSync("/adir"); + expect(() => provider.readFileSync("/adir")).toThrow( + expect.objectContaining({ code: "EISDIR" }), + ); + }); + + it("mkdirSync creates a directory", () => { + provider.mkdirSync("/newdir"); + expect(provider.existsSync("/newdir")).toBe(true); + }); + + it("mkdirSync with recursive creates nested directories", () => { + provider.mkdirSync("/a/b/c", { recursive: true }); + expect(provider.existsSync("/a/b/c")).toBe(true); + }); + + it("mkdirSync throws EEXIST when directory already exists", () => { + provider.mkdirSync("/dup"); + expect(() => provider.mkdirSync("/dup")).toThrow( + expect.objectContaining({ code: "EEXIST" }), + ); + }); + + it("mkdirSync with recursive does NOT throw when directory exists", () => { + provider.mkdirSync("/safe"); + expect(() => + provider.mkdirSync("/safe", { recursive: true }), + ).not.toThrow(); + }); + + it("readdirSync lists files in a directory", () => { + provider.mkdirSync("/listing"); + provider.writeFileSync("/listing/a.ts", ""); + provider.writeFileSync("/listing/b.ts", ""); + const entries = provider.readdirSync("/listing") as string[]; + expect(entries.sort()).toEqual(["a.ts", "b.ts"]); + }); + + it("readdirSync with withFileTypes returns Dirent-like objects", () => { + provider.mkdirSync("/typed"); + provider.writeFileSync("/typed/file.txt", ""); + provider.mkdirSync("/typed/subdir"); + const entries = provider.readdirSync("/typed", { + withFileTypes: true, + }) as import("./memory-provider.js").VfsDirent[]; + const file = entries.find((e) => e.name === "file.txt"); + const dir = entries.find((e) => e.name === "subdir"); + expect(file?.isFile()).toBe(true); + expect(dir?.isDirectory()).toBe(true); + }); + + it("unlinkSync removes a file", () => { + provider.writeFileSync("/del.txt", "bye"); + provider.unlinkSync("/del.txt"); + expect(provider.existsSync("/del.txt")).toBe(false); + }); + + it("unlinkSync throws ENOENT on missing file", () => { + expect(() => provider.unlinkSync("/ghost.txt")).toThrow( + expect.objectContaining({ code: "ENOENT" }), + ); + }); + + it("rmdirSync removes an empty directory", () => { + provider.mkdirSync("/empty"); + provider.rmdirSync("/empty"); + expect(provider.existsSync("/empty")).toBe(false); + }); + + it("rmdirSync throws ENOTEMPTY on non-empty directory", () => { + provider.mkdirSync("/notempty"); + provider.writeFileSync("/notempty/x.txt", ""); + expect(() => provider.rmdirSync("/notempty")).toThrow( + expect.objectContaining({ code: "ENOTEMPTY" }), + ); + }); + + it("rmSync with recursive removes a tree", () => { + provider.mkdirSync("/tree/a/b", { recursive: true }); + provider.writeFileSync("/tree/a/b/deep.txt", "deep"); + provider.rmSync("/tree", { recursive: true }); + expect(provider.existsSync("/tree")).toBe(false); + }); + + it("rmSync with force does not throw on missing path", () => { + expect(() => + provider.rmSync("/nowhere", { recursive: true, force: true }), + ).not.toThrow(); + }); + + it("renameSync moves a file", () => { + provider.writeFileSync("/old.txt", "content"); + provider.renameSync("/old.txt", "/new.txt"); + expect(provider.existsSync("/old.txt")).toBe(false); + expect(provider.readFileSync("/new.txt", { encoding: "utf8" })).toBe( + "content", + ); + }); + + it("appendFileSync appends to an existing file", () => { + provider.writeFileSync("/append.txt", "hello"); + provider.appendFileSync("/append.txt", " world"); + expect(provider.readFileSync("/append.txt", { encoding: "utf8" })).toBe( + "hello world", + ); + }); + + it("appendFileSync creates the file if it does not exist", () => { + provider.appendFileSync("/new-append.txt", "first"); + expect(provider.readFileSync("/new-append.txt", { encoding: "utf8" })).toBe( + "first", + ); + }); + + it("copyFileSync duplicates a file", () => { + provider.writeFileSync("/src.txt", "original"); + provider.copyFileSync("/src.txt", "/dest.txt"); + expect(provider.readFileSync("/dest.txt", { encoding: "utf8" })).toBe( + "original", + ); + }); + + it("symlinkSync + readlinkSync round-trip", () => { + provider.writeFileSync("/target.txt", "data"); + provider.symlinkSync("/target.txt", "/link.txt"); + expect(provider.readlinkSync("/link.txt")).toBe("/target.txt"); + }); + + it("statSync follows symlinks; lstatSync does not", () => { + provider.writeFileSync("/real.txt", "real"); + provider.symlinkSync("/real.txt", "/sym.txt"); + expect(provider.statSync("/sym.txt").isFile()).toBe(true); + expect(provider.lstatSync("/sym.txt").isSymbolicLink()).toBe(true); + }); + + it("truncateSync shrinks a file", () => { + provider.writeFileSync("/big.txt", "abcdef"); + provider.truncateSync("/big.txt", 3); + expect(provider.readFileSync("/big.txt", { encoding: "utf8" })).toBe("abc"); + }); + + it("mkdtempSync creates a unique directory", () => { + provider.mkdirSync("/tmp"); + const dir1 = provider.mkdtempSync("/tmp/prefix-"); + const dir2 = provider.mkdtempSync("/tmp/prefix-"); + expect(provider.statSync(dir1).isDirectory()).toBe(true); + expect(dir1).not.toBe(dir2); + }); + + it("linkSync creates a hard link", () => { + provider.writeFileSync("/original.txt", "shared"); + provider.linkSync("/original.txt", "/hardlink.txt"); + expect(provider.readFileSync("/hardlink.txt", { encoding: "utf8" })).toBe( + "shared", + ); + }); + + it("realpathSync resolves symlinks", () => { + provider.mkdirSync("/real-dir"); + provider.writeFileSync("/real-dir/file.txt", "hi"); + provider.symlinkSync("/real-dir", "/link-dir"); + const resolved = provider.realpathSync("/link-dir/file.txt"); + expect(resolved).toBe("/real-dir/file.txt"); + }); + + it("accessSync does not throw for a readable file", () => { + provider.writeFileSync("/access.txt", ""); + expect(() => provider.accessSync("/access.txt")).not.toThrow(); + }); + + it("accessSync throws ENOENT for a missing file", () => { + expect(() => provider.accessSync("/absent.txt")).toThrow( + expect.objectContaining({ code: "ENOENT" }), + ); + }); +}); + +// ─── VirtualFileSystem (no mount) ──────────────────────────────────────────── + +describe("VirtualFileSystem (unmounted)", () => { + it("create() returns a VirtualFileSystem with mounted=false", () => { + const vfs = create(); + expect(vfs.mounted).toBe(false); + expect(vfs.mountPoint).toBeNull(); + }); + + it("addFile + readFileSync work before mounting", () => { + const vfs = create(); + vfs.addFile("/config.json", '{"ok":true}'); + const result = vfs.readFileSync("/config.json", { encoding: "utf8" }); + expect(result).toBe('{"ok":true}'); + }); + + it("addDirectory creates a directory pre-mount", () => { + const vfs = create(); + vfs.addDirectory("/src"); + expect(vfs.statSync("/src").isDirectory()).toBe(true); + }); + + it("addDirectory with populate callback creates nested files", () => { + const vfs = create(); + vfs.addDirectory("/assets", (dir) => { + dir.addFile("logo.svg", ""); + dir.addDirectory("icons"); + }); + expect(vfs.existsSync("/assets/logo.svg")).toBe(true); + expect(vfs.statSync("/assets/icons").isDirectory()).toBe(true); + }); + + it("throws ERR_INVALID_STATE when calling cwd() without virtualCwd option", () => { + const vfs = create(); + expect(() => vfs.cwd()).toThrow( + expect.objectContaining({ code: "ERR_INVALID_STATE" }), + ); + }); +}); + +// ─── mount / unmount ───────────────────────────────────────────────────────── + +describe("mount / unmount", () => { + let vfs: ReturnType; + + afterEach(() => { + // Ensure we always clean up even if a test throws + if (vfs?.mounted) vfs.unmount(); + }); + + it("mount() sets mounted=true and mountPoint", () => { + vfs = create(); + vfs.mount("/vfs-test"); + expect(vfs.mounted).toBe(true); + expect(vfs.mountPoint).toContain("vfs-test"); + }); + + it("throws when mount() is called twice", () => { + vfs = create(); + vfs.mount("/vfs-double"); + expect(() => vfs.mount("/vfs-double-2")).toThrow("already mounted"); + }); + + it("unmount() sets mounted=false and restores fs", () => { + const originalFn = fs.existsSync; + vfs = create(); + vfs.mount("/vfs-restore"); + // While mounted, existsSync should be the VFS wrapper + expect(fs.existsSync).not.toBe(originalFn); + vfs.unmount(); + expect(vfs.mounted).toBe(false); + // After unmount, the original function should be restored + expect(fs.existsSync).toBe(originalFn); + }); + + it("unmount() is idempotent", () => { + vfs = create(); + vfs.mount("/vfs-idempotent"); + vfs.unmount(); + expect(() => vfs.unmount()).not.toThrow(); + }); +}); + +// ─── fs interception via mount ──────────────────────────────────────────────── + +describe("fs interception via mount", () => { + const MOUNT = "/scratchyjs-vfs-test-" + process.pid; + let vfs: ReturnType; + + beforeEach(() => { + vfs = create(); + vfs.mount(MOUNT); + }); + + afterEach(() => { + vfs.unmount(); + }); + + it("fs.existsSync returns true for a mounted virtual file", () => { + vfs.addFile(MOUNT + "/a.txt", "hi"); + expect(fs.existsSync(MOUNT + "/a.txt")).toBe(true); + }); + + it("fs.existsSync returns false for a missing path under mount", () => { + expect(fs.existsSync(MOUNT + "/missing.txt")).toBe(false); + }); + + it("fs.existsSync falls through to real fs for paths outside mount", () => { + // process.cwd() is a real directory that exists + expect(fs.existsSync(process.cwd())).toBe(true); + }); + + it("fs.readFileSync reads a virtual file as Buffer", () => { + vfs.addFile(MOUNT + "/buf.txt", Buffer.from("buffer-data")); + const result = fs.readFileSync(MOUNT + "/buf.txt"); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString()).toBe("buffer-data"); + }); + + it("fs.readFileSync reads a virtual file as string", () => { + vfs.addFile(MOUNT + "/str.txt", "string-data"); + const result = fs.readFileSync(MOUNT + "/str.txt", "utf8"); + expect(result).toBe("string-data"); + }); + + it("fs.readFileSync throws ENOENT for a missing virtual path", () => { + expect(() => fs.readFileSync(MOUNT + "/no-such.txt")).toThrow( + expect.objectContaining({ code: "ENOENT" }), + ); + }); + + it("fs.writeFileSync + fs.readFileSync round-trip", () => { + fs.writeFileSync(MOUNT + "/write.txt", "written"); + expect(fs.readFileSync(MOUNT + "/write.txt", "utf8")).toBe("written"); + }); + + it("fs.statSync.isFile() returns true for a virtual file", () => { + vfs.addFile(MOUNT + "/s.txt", "stat"); + expect(fs.statSync(MOUNT + "/s.txt").isFile()).toBe(true); + }); + + it("fs.statSync.isDirectory() returns true for a virtual directory", () => { + vfs.addDirectory(MOUNT + "/dir"); + expect(fs.statSync(MOUNT + "/dir").isDirectory()).toBe(true); + }); + + it("fs.mkdirSync + fs.readdirSync list the new directory", () => { + fs.mkdirSync(MOUNT + "/newdir"); + fs.writeFileSync(MOUNT + "/newdir/f.ts", ""); + const entries = fs.readdirSync(MOUNT + "/newdir") as string[]; + expect(entries).toContain("f.ts"); + }); + + it("fs.unlinkSync removes a virtual file", () => { + vfs.addFile(MOUNT + "/rm.txt", "bye"); + fs.unlinkSync(MOUNT + "/rm.txt"); + expect(fs.existsSync(MOUNT + "/rm.txt")).toBe(false); + }); + + it("fs.rmSync recursive removes a virtual tree", () => { + vfs.addDirectory(MOUNT + "/tree/sub"); + vfs.addFile(MOUNT + "/tree/sub/x.txt", "x"); + fs.rmSync(MOUNT + "/tree", { recursive: true }); + expect(fs.existsSync(MOUNT + "/tree")).toBe(false); + }); + + it("fs.renameSync moves a virtual file", () => { + vfs.addFile(MOUNT + "/from.txt", "move me"); + fs.renameSync(MOUNT + "/from.txt", MOUNT + "/to.txt"); + expect(fs.existsSync(MOUNT + "/from.txt")).toBe(false); + expect(fs.readFileSync(MOUNT + "/to.txt", "utf8")).toBe("move me"); + }); + + it("fs.appendFileSync appends to a virtual file", () => { + vfs.addFile(MOUNT + "/app.txt", "line1"); + fs.appendFileSync(MOUNT + "/app.txt", "\nline2"); + expect(fs.readFileSync(MOUNT + "/app.txt", "utf8")).toBe("line1\nline2"); + }); + + it("fs.copyFileSync duplicates a virtual file", () => { + vfs.addFile(MOUNT + "/copy-src.txt", "copy"); + fs.copyFileSync(MOUNT + "/copy-src.txt", MOUNT + "/copy-dest.txt"); + expect(fs.readFileSync(MOUNT + "/copy-dest.txt", "utf8")).toBe("copy"); + }); + + it("fs.symlinkSync + fs.readlinkSync round-trip", () => { + vfs.addFile(MOUNT + "/link-target.txt", "target"); + fs.symlinkSync(MOUNT + "/link-target.txt", MOUNT + "/link.txt"); + expect(fs.readlinkSync(MOUNT + "/link.txt")).toBe( + MOUNT + "/link-target.txt", + ); + }); +}); + +// ─── fs.promises interception via mount ────────────────────────────────────── + +describe("fs.promises interception via mount", () => { + const MOUNT = "/scratchyjs-vfs-promises-" + process.pid; + let vfs: ReturnType; + + beforeEach(() => { + vfs = create(); + vfs.mount(MOUNT); + }); + + afterEach(() => { + vfs.unmount(); + }); + + it("fs.promises.readFile reads a virtual file", async () => { + vfs.addFile(MOUNT + "/async.txt", "async-data"); + const result = await fs.promises.readFile(MOUNT + "/async.txt", "utf8"); + expect(result).toBe("async-data"); + }); + + it("fs.promises.writeFile + readFile round-trip", async () => { + await fs.promises.writeFile(MOUNT + "/async-w.txt", "async-written"); + const result = await fs.promises.readFile(MOUNT + "/async-w.txt", "utf8"); + expect(result).toBe("async-written"); + }); + + it("fs.promises.stat returns isFile() for a virtual file", async () => { + vfs.addFile(MOUNT + "/async-stat.txt", ""); + const stats = await fs.promises.stat(MOUNT + "/async-stat.txt"); + expect(stats.isFile()).toBe(true); + }); + + it("fs.promises.mkdir + readdir round-trip", async () => { + await fs.promises.mkdir(MOUNT + "/async-dir"); + await fs.promises.writeFile(MOUNT + "/async-dir/f.txt", ""); + const entries = await fs.promises.readdir(MOUNT + "/async-dir"); + expect(entries).toContain("f.txt"); + }); + + it("fs.promises.unlink removes a virtual file", async () => { + vfs.addFile(MOUNT + "/async-del.txt", "bye"); + await fs.promises.unlink(MOUNT + "/async-del.txt"); + expect(fs.existsSync(MOUNT + "/async-del.txt")).toBe(false); + }); + + it("fs.promises.rm recursive removes a virtual tree", async () => { + vfs.addDirectory(MOUNT + "/async-tree"); + vfs.addFile(MOUNT + "/async-tree/leaf.txt", "leaf"); + await fs.promises.rm(MOUNT + "/async-tree", { recursive: true }); + expect(fs.existsSync(MOUNT + "/async-tree")).toBe(false); + }); +}); + +// ─── Symbol.dispose (explicit resource management) ──────────────────────────── + +describe("Symbol.dispose", () => { + it("unmounts automatically via 'using' / Symbol.dispose", () => { + const vfs = create(); + vfs.mount("/vfs-dispose-test"); + expect(vfs.mounted).toBe(true); + vfs[Symbol.dispose](); + expect(vfs.mounted).toBe(false); + }); + + it("Symbol.dispose is idempotent when already unmounted", () => { + const vfs = create(); + vfs.mount("/vfs-dispose-idem"); + vfs.unmount(); + expect(() => vfs[Symbol.dispose]()).not.toThrow(); + }); +}); diff --git a/packages/vfs/src/index.ts b/packages/vfs/src/index.ts new file mode 100644 index 0000000..8e7f43e --- /dev/null +++ b/packages/vfs/src/index.ts @@ -0,0 +1,56 @@ +import { VirtualFileSystem } from "./file-system.js"; +import type { VfsStats } from "./stats.js"; + +/** + * `@scratchyjs/vfs` – Virtual File System for tests. + * + * A private, in-process virtual filesystem that monkey-patches `node:fs` so + * that paths under a configured mount prefix are served from an in-memory + * store instead of the real disk. Drop-in replacement for `vi.mock('node:fs')` + * in tests that need realistic filesystem interactions. + * + * @example Basic usage + * ```ts + * import { afterEach, beforeEach, it, expect } from "vitest"; + * import { create } from "@scratchyjs/vfs"; + * import fs from "node:fs"; + * + * let vfs: ReturnType; + * + * beforeEach(() => { + * vfs = create(); + * vfs.addFile("/config.json", JSON.stringify({ port: 3000 })); + * vfs.mount("/virtual"); + * }); + * + * afterEach(() => vfs.unmount()); + * + * it("reads the virtual config", () => { + * const raw = fs.readFileSync("/virtual/config.json", "utf8"); + * expect(JSON.parse(raw)).toEqual({ port: 3000 }); + * }); + * ``` + * + * @module @scratchyjs/vfs + */ + +export { VirtualFileSystem } from "./file-system.js"; +export { MemoryProvider } from "./memory-provider.js"; + +export type { VfsDirent } from "./memory-provider.js"; +export type { VfsStats }; + +/** + * Creates a new {@link VirtualFileSystem} instance backed by an in-memory + * provider. + * + * @param options Optional configuration. + * @param options.virtualCwd Enable virtual `process.cwd()` support (default `false`). + * @param options.overlay Only intercept paths that already exist in the VFS (default `false`). + */ +export function create(options?: { + virtualCwd?: boolean; + overlay?: boolean; +}): VirtualFileSystem { + return new VirtualFileSystem(options); +} diff --git a/packages/vfs/src/memory-provider.ts b/packages/vfs/src/memory-provider.ts new file mode 100644 index 0000000..ef8c827 --- /dev/null +++ b/packages/vfs/src/memory-provider.ts @@ -0,0 +1,963 @@ +/** + * In-memory filesystem provider. + * + * Ported from `lib/internal/vfs/providers/memory.js` in Node.js PR #61478. + * Adapted for user-space TypeScript (no `primordials`, no `internalBinding`). + * + * Key changes versus the upstream source: + * - Uses `path.posix` instead of primordials equivalents. + * - Error helpers come from `./errors.ts` (standard `Error` objects). + * - Stats objects come from `./stats.ts` (custom `VfsStats` class). + * - Uses `path.join` instead of string concatenation (avivkeller feedback). + */ +import { + createEACCES, + createEEXIST, + createEINVAL, + createEISDIR, + createELOOP, + createENOENT, + createENOTDIR, + createENOTEMPTY, + createEROFS, +} from "./errors.js"; +import type { VfsStats } from "./stats.js"; +import { + createDirectoryStats, + createFileStats, + createSymlinkStats, +} from "./stats.js"; +import { posix as pathPosix } from "node:path"; + +// ─── Entry types ──────────────────────────────────────────────────────────── + +const TYPE_FILE = 0; +const TYPE_DIR = 1; +const TYPE_SYMLINK = 2; + +const MAX_SYMLINK_DEPTH = 40; + +// ─── Internal entry ───────────────────────────────────────────────────────── + +interface EntryOptions { + mode?: number; +} + +class MemoryEntry { + type: number; + mode: number; + content: Buffer | null = null; + target: string | null = null; // symlink target + children: Map | null = null; + populate: ((scoped: ScopedVfs) => void) | null = null; + populated = true; + nlink = 1; + uid = 0; + gid = 0; + atime: number; + mtime: number; + ctime: number; + birthtime: number; + + constructor(type: number, options: EntryOptions = {}) { + this.type = type; + this.mode = options.mode ?? (type === TYPE_DIR ? 0o755 : 0o644); + const now = Date.now(); + this.atime = now; + this.mtime = now; + this.ctime = now; + this.birthtime = now; + } + + isFile(): boolean { + return this.type === TYPE_FILE; + } + isDirectory(): boolean { + return this.type === TYPE_DIR; + } + isSymbolicLink(): boolean { + return this.type === TYPE_SYMLINK; + } +} + +// ─── Guard helpers ─────────────────────────────────────────────────────────── + +/** + * Returns the children map of a directory entry, throwing `ENOTDIR` if the + * entry is not a directory or has not been initialised. + */ +function getChildren( + entry: MemoryEntry, + syscall: string, + path: string, +): Map { + if (!entry.children) { + throw createENOTDIR(syscall, path); + } + return entry.children; +} + +/** + * Returns the symlink target string, throwing `EINVAL` if it is null. + */ +function getTarget(entry: MemoryEntry, syscall: string, path: string): string { + if (entry.target === null) { + throw createEINVAL(syscall, path); + } + return entry.target; +} + +// ─── Scoped VFS (for lazy directory population) ───────────────────────────── + +interface ScopedVfs { + addFile(name: string, content: string | Buffer, opts?: EntryOptions): void; + addDirectory( + name: string, + populate?: (s: ScopedVfs) => void, + opts?: EntryOptions, + ): void; + addSymlink(name: string, target: string, opts?: EntryOptions): void; +} + +// ─── Dirent-like object ───────────────────────────────────────────────────── + +export class VfsDirent { + readonly name: string; + readonly path: string; + readonly #type: number; + + constructor(name: string, type: number, parentPath: string) { + this.name = name; + this.#type = type; + this.path = parentPath; + } + + isFile(): boolean { + return this.#type === TYPE_FILE; + } + isDirectory(): boolean { + return this.#type === TYPE_DIR; + } + isSymbolicLink(): boolean { + return this.#type === TYPE_SYMLINK; + } + isBlockDevice(): boolean { + return false; + } + isCharacterDevice(): boolean { + return false; + } + isFIFO(): boolean { + return false; + } + isSocket(): boolean { + return false; + } +} + +// ─── Options interfaces ────────────────────────────────────────────────────── + +interface ReaddirOptions { + withFileTypes?: boolean; + recursive?: boolean; + encoding?: BufferEncoding | "buffer"; +} + +interface WriteFileOptions { + encoding?: BufferEncoding | null; + mode?: number; + flag?: string | number; +} + +interface ReadFileOptions { + encoding?: BufferEncoding | null; + flag?: string | number; +} + +// ─── MemoryProvider ───────────────────────────────────────────────────────── + +/** + * A complete, in-memory filesystem. Paths are always POSIX-style (starting + * with `/`) and are relative to the provider's own root, **not** to any + * mount point. Mount-point translation is handled by `VirtualFileSystem`. + */ +export class MemoryProvider { + readonly #root: MemoryEntry; + #readonly = false; + + constructor() { + this.#root = new MemoryEntry(TYPE_DIR); + this.#root.children = new Map(); + } + + get readonly(): boolean { + return this.#readonly; + } + + /** Freeze the provider so no further writes are accepted. */ + setReadOnly(): void { + this.#readonly = true; + } + + // ── Path helpers ──────────────────────────────────────────────────────── + + #normalizePath(p: string): string { + let normalized = p.replace(/\\/g, "/"); + if (!normalized.startsWith("/")) normalized = "/" + normalized; + return pathPosix.normalize(normalized); + } + + #splitPath(p: string): string[] { + if (p === "/") return []; + return p.slice(1).split("/"); + } + + #resolveSymlinkTarget(symlinkPath: string, target: string): string { + if (target.startsWith("/")) return this.#normalizePath(target); + const parentPath = pathPosix.dirname(symlinkPath); + return this.#normalizePath(pathPosix.join(parentPath, target)); + } + + // ── Entry lookup ──────────────────────────────────────────────────────── + + #lookupEntry( + p: string, + followSymlinks = true, + depth = 0, + ): { + entry: MemoryEntry | null; + resolvedPath: string | null; + eloop?: boolean; + } { + const normalized = this.#normalizePath(p); + + if (normalized === "/") { + return { entry: this.#root, resolvedPath: "/" }; + } + + const segments = this.#splitPath(normalized); + let current = this.#root; + let currentPath = "/"; + + for (const segment of segments) { + if (current.isSymbolicLink()) { + if (depth >= MAX_SYMLINK_DEPTH) { + return { entry: null, resolvedPath: null, eloop: true }; + } + const target = current.target ?? ""; + const targetPath = this.#resolveSymlinkTarget(currentPath, target); + const result = this.#lookupEntry(targetPath, true, depth + 1); + if (result.eloop) return result; + if (!result.entry || !result.resolvedPath) { + return { entry: null, resolvedPath: null }; + } + current = result.entry; + currentPath = result.resolvedPath; + } + + if (!current.isDirectory()) { + return { entry: null, resolvedPath: null }; + } + + this.#ensurePopulated(current); + + const children = current.children; + if (!children) return { entry: null, resolvedPath: null }; + const entry = children.get(segment); + if (!entry) return { entry: null, resolvedPath: null }; + + currentPath = pathPosix.join(currentPath, segment); + current = entry; + } + + if (current.isSymbolicLink() && followSymlinks) { + if (depth >= MAX_SYMLINK_DEPTH) { + return { entry: null, resolvedPath: null, eloop: true }; + } + const target = current.target ?? ""; + const targetPath = this.#resolveSymlinkTarget(currentPath, target); + return this.#lookupEntry(targetPath, true, depth + 1); + } + + return { entry: current, resolvedPath: currentPath }; + } + + #getEntry(p: string, syscall: string, followSymlinks = true): MemoryEntry { + const result = this.#lookupEntry(p, followSymlinks); + if (result.eloop) throw createELOOP(syscall, p); + if (!result.entry) throw createENOENT(syscall, p); + return result.entry; + } + + #ensureParent( + p: string, + createMissing: boolean, + syscall: string, + ): MemoryEntry { + if (p === "/") return this.#root; + + const parentPath = pathPosix.dirname(p); + const segments = this.#splitPath(parentPath); + let current = this.#root; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + if (segment === undefined) break; + + if (current.isSymbolicLink()) { + const cp = "/" + segments.slice(0, i).join("/"); + const target = current.target ?? ""; + const targetPath = this.#resolveSymlinkTarget(cp, target); + const result = this.#lookupEntry(targetPath, true, 0); + if (!result.entry) throw createENOENT(syscall, p); + current = result.entry; + } + + if (!current.isDirectory()) throw createENOTDIR(syscall, p); + + this.#ensurePopulated(current); + + const children = getChildren(current, syscall, p); + let entry = children.get(segment); + if (!entry) { + if (createMissing) { + entry = new MemoryEntry(TYPE_DIR); + entry.children = new Map(); + children.set(segment, entry); + } else { + throw createENOENT(syscall, p); + } + } + current = entry; + } + + if (current.isSymbolicLink()) { + const target = current.target ?? ""; + const targetPath = this.#resolveSymlinkTarget(parentPath, target); + const result = this.#lookupEntry(targetPath, true, 0); + if (!result.entry) throw createENOENT(syscall, p); + current = result.entry; + } + + if (!current.isDirectory()) throw createENOTDIR(syscall, p); + this.#ensurePopulated(current); + return current; + } + + #ensurePopulated(entry: MemoryEntry): void { + if (entry.isDirectory() && !entry.populated && entry.populate) { + const children = entry.children; + if (!children) return; + + const scoped: ScopedVfs = { + addFile: (name, content, opts) => { + const fe = new MemoryEntry(TYPE_FILE, opts); + fe.content = + typeof content === "string" ? Buffer.from(content) : content; + children.set(name, fe); + }, + addDirectory: (name, populate, opts) => { + const de = new MemoryEntry(TYPE_DIR, opts); + de.children = new Map(); + if (typeof populate === "function") { + de.populate = populate; + de.populated = false; + } + children.set(name, de); + }, + addSymlink: (name, target, opts) => { + const se = new MemoryEntry(TYPE_SYMLINK, opts); + se.target = target; + children.set(name, se); + }, + }; + entry.populate(scoped); + entry.populated = true; + } + } + + #createStats(entry: MemoryEntry, overrideSize?: number): VfsStats { + const options = { + mode: entry.mode, + nlink: entry.nlink, + uid: entry.uid, + gid: entry.gid, + atimeMs: entry.atime, + mtimeMs: entry.mtime, + ctimeMs: entry.ctime, + birthtimeMs: entry.birthtime, + }; + if (entry.isFile()) { + const size = overrideSize ?? entry.content?.length ?? 0; + return createFileStats(size, options); + } + if (entry.isDirectory()) return createDirectoryStats(options); + // symlink + return createSymlinkStats(entry.target?.length ?? 0, options); + } + + // ── Existence ──────────────────────────────────────────────────────────── + + existsSync(p: string): boolean { + try { + const result = this.#lookupEntry(p, true); + return result.entry !== null; + } catch { + return false; + } + } + + // ── Stat ───────────────────────────────────────────────────────────────── + + statSync(p: string): VfsStats { + const entry = this.#getEntry(p, "stat", true); + return this.#createStats(entry); + } + + async stat(p: string): Promise { + return this.statSync(p); + } + + lstatSync(p: string): VfsStats { + const entry = this.#getEntry(p, "lstat", false); + return this.#createStats(entry); + } + + async lstat(p: string): Promise { + return this.lstatSync(p); + } + + // ── Access ─────────────────────────────────────────────────────────────── + + accessSync(p: string, mode?: number): void { + const entry = this.#getEntry(p, "access", true); + if (mode !== undefined && mode !== 0) { + const effectiveMode = entry.mode & 0o777; + if (mode & 4 && !(effectiveMode & 0o444)) throw createEACCES("access", p); + if (mode & 2 && !(effectiveMode & 0o222)) throw createEACCES("access", p); + } + } + + // ── Read operations ────────────────────────────────────────────────────── + + readFileSync( + p: string, + options?: ReadFileOptions | BufferEncoding | null, + ): Buffer | string { + const entry = this.#getEntry(p, "open", true); + if (entry.isDirectory()) throw createEISDIR("read", p); + + const buf = entry.content ?? Buffer.alloc(0); + const enc = + typeof options === "string" + ? options + : ((options as ReadFileOptions | null | undefined)?.encoding ?? null); + + if (enc) return buf.toString(enc as BufferEncoding); + return buf; + } + + async readFile( + p: string, + options?: ReadFileOptions | BufferEncoding | null, + ): Promise { + return this.readFileSync(p, options); + } + + readdirSync(p: string, options?: ReaddirOptions): string[] | VfsDirent[] { + const entry = this.#getEntry(p, "scandir", true); + if (!entry.isDirectory()) throw createENOTDIR("scandir", p); + + const normalized = this.#normalizePath(p); + this.#ensurePopulated(entry); + + const children = getChildren(entry, "scandir", p); + const withFileTypes = options?.withFileTypes === true; + const recursive = options?.recursive === true; + + if (recursive) { + return this.#readdirRecursive(entry, normalized, withFileTypes); + } + + if (withFileTypes) { + const dirents: VfsDirent[] = []; + for (const [name, childEntry] of children) { + const type = childEntry.isSymbolicLink() + ? TYPE_SYMLINK + : childEntry.isDirectory() + ? TYPE_DIR + : TYPE_FILE; + dirents.push(new VfsDirent(name, type, normalized)); + } + return dirents; + } + + return [...children.keys()]; + } + + async readdir( + p: string, + options?: ReaddirOptions, + ): Promise { + return this.readdirSync(p, options); + } + + #readdirRecursive( + dirEntry: MemoryEntry, + dirPath: string, + withFileTypes: boolean, + ): string[] | VfsDirent[] { + const results: (string | VfsDirent)[] = []; + + const walk = ( + entry: MemoryEntry, + currentPath: string, + relativePath: string, + ) => { + this.#ensurePopulated(entry); + const children = entry.children; + if (!children) return; + for (const [name, childEntry] of children) { + const childRelative = relativePath ? relativePath + "/" + name : name; + if (withFileTypes) { + const type = childEntry.isSymbolicLink() + ? TYPE_SYMLINK + : childEntry.isDirectory() + ? TYPE_DIR + : TYPE_FILE; + results.push(new VfsDirent(childRelative, type, dirPath)); + } else { + results.push(childRelative); + } + // Recurse into directories (follow symlinks to dirs) + let resolved = childEntry; + if (childEntry.isSymbolicLink()) { + const target = childEntry.target ?? ""; + const targetPath = this.#resolveSymlinkTarget( + pathPosix.join(currentPath, name), + target, + ); + const r = this.#lookupEntry(targetPath, true, 0); + if (r.entry) resolved = r.entry; + } + if (resolved.isDirectory()) { + walk(resolved, pathPosix.join(currentPath, name), childRelative); + } + } + }; + + walk(dirEntry, dirPath, ""); + return results as string[] | VfsDirent[]; + } + + realpathSync(p: string): string { + const result = this.#lookupEntry(p, true); + if (result.eloop) throw createELOOP("realpath", p); + if (!result.entry || !result.resolvedPath) + throw createENOENT("realpath", p); + return result.resolvedPath; + } + + readlinkSync(p: string): string { + const entry = this.#getEntry(p, "readlink", false); + if (!entry.isSymbolicLink()) throw createEINVAL("readlink", p); + return getTarget(entry, "readlink", p); + } + + // ── Write operations ───────────────────────────────────────────────────── + + #checkWritable(syscall: string, p: string): void { + if (this.#readonly) throw createEROFS(syscall, p); + } + + writeFileSync( + p: string, + data: string | Buffer | Uint8Array, + options?: WriteFileOptions | BufferEncoding | null, + ): void { + this.#checkWritable("write", p); + const normalized = this.#normalizePath(p); + + const enc = + typeof options === "string" + ? options + : ((options as WriteFileOptions | null | undefined)?.encoding ?? + "utf8"); + + let buf: Buffer; + if (Buffer.isBuffer(data)) { + buf = data; + } else if (data instanceof Uint8Array) { + buf = Buffer.from(data); + } else { + buf = Buffer.from(data as string, enc as BufferEncoding); + } + + const existing = this.#lookupEntry(normalized, true); + if (existing.entry) { + if (existing.entry.isDirectory()) throw createEISDIR("open", p); + existing.entry.content = buf; + const now = Date.now(); + existing.entry.mtime = now; + existing.entry.ctime = now; + } else { + const parent = this.#ensureParent(normalized, false, "open"); + const name = pathPosix.basename(normalized); + const entry = new MemoryEntry(TYPE_FILE); + entry.content = buf; + getChildren(parent, "open", p).set(name, entry); + const now = Date.now(); + parent.mtime = now; + parent.ctime = now; + } + } + + async writeFile( + p: string, + data: string | Buffer | Uint8Array, + options?: WriteFileOptions | BufferEncoding | null, + ): Promise { + return this.writeFileSync(p, data, options); + } + + appendFileSync( + p: string, + data: string | Buffer | Uint8Array, + options?: WriteFileOptions | BufferEncoding | null, + ): void { + this.#checkWritable("write", p); + const normalized = this.#normalizePath(p); + + const enc = + typeof options === "string" + ? options + : ((options as WriteFileOptions | null | undefined)?.encoding ?? + "utf8"); + + let buf: Buffer; + if (Buffer.isBuffer(data)) { + buf = data; + } else if (data instanceof Uint8Array) { + buf = Buffer.from(data); + } else { + buf = Buffer.from(data as string, enc as BufferEncoding); + } + + const existing = this.#lookupEntry(normalized, true); + if (existing.entry) { + if (existing.entry.isDirectory()) throw createEISDIR("open", p); + const prev = existing.entry.content ?? Buffer.alloc(0); + existing.entry.content = Buffer.concat([prev, buf]); + const now = Date.now(); + existing.entry.mtime = now; + existing.entry.ctime = now; + } else { + const parent = this.#ensureParent(normalized, false, "open"); + const name = pathPosix.basename(normalized); + const entry = new MemoryEntry(TYPE_FILE); + entry.content = buf; + getChildren(parent, "open", p).set(name, entry); + } + } + + async appendFile( + p: string, + data: string | Buffer | Uint8Array, + options?: WriteFileOptions | BufferEncoding | null, + ): Promise { + return this.appendFileSync(p, data, options); + } + + mkdirSync( + p: string, + options?: { recursive?: boolean; mode?: number }, + ): string | undefined { + this.#checkWritable("mkdir", p); + const normalized = this.#normalizePath(p); + const recursive = options?.recursive === true; + + const existing = this.#lookupEntry(normalized, true); + if (existing.entry) { + if (existing.entry.isDirectory() && recursive) return undefined; + throw createEEXIST("mkdir", p); + } + + if (recursive) { + const segments = this.#splitPath(normalized); + let current = this.#root; + let firstCreated: string | undefined; + let currentPath = "/"; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + if (segment === undefined) break; + this.#ensurePopulated(current); + const children = getChildren(current, "mkdir", p); + let child = children.get(segment); + if (!child) { + child = new MemoryEntry(TYPE_DIR); + child.children = new Map(); + children.set(segment, child); + if (firstCreated === undefined) { + firstCreated = "/" + segments.slice(0, i + 1).join("/"); + } + } else if (!child.isDirectory()) { + throw createENOTDIR("mkdir", p); + } + currentPath = pathPosix.join(currentPath, segment); + current = child; + } + return firstCreated; + } + + const parent = this.#ensureParent(normalized, false, "mkdir"); + const name = pathPosix.basename(normalized); + const entry = new MemoryEntry(TYPE_DIR, { mode: options?.mode }); + entry.children = new Map(); + getChildren(parent, "mkdir", p).set(name, entry); + return undefined; + } + + async mkdir( + p: string, + options?: { recursive?: boolean; mode?: number }, + ): Promise { + return this.mkdirSync(p, options); + } + + rmdirSync(p: string): void { + this.#checkWritable("rmdir", p); + const normalized = this.#normalizePath(p); + const entry = this.#getEntry(normalized, "rmdir", true); + if (!entry.isDirectory()) throw createENOTDIR("rmdir", p); + + this.#ensurePopulated(entry); + const children = getChildren(entry, "rmdir", p); + if (children.size > 0) throw createENOTEMPTY("rmdir", p); + + const parent = this.#ensureParent(normalized, false, "rmdir"); + const name = pathPosix.basename(normalized); + getChildren(parent, "rmdir", p).delete(name); + } + + async rmdir(p: string): Promise { + return this.rmdirSync(p); + } + + unlinkSync(p: string): void { + this.#checkWritable("unlink", p); + const normalized = this.#normalizePath(p); + const result = this.#lookupEntry(normalized, false); + if (!result.entry) throw createENOENT("unlink", p); + if (result.entry.isDirectory()) throw createEISDIR("unlink", p); + + const parent = this.#ensureParent(normalized, false, "unlink"); + const name = pathPosix.basename(normalized); + getChildren(parent, "unlink", p).delete(name); + } + + async unlink(p: string): Promise { + return this.unlinkSync(p); + } + + renameSync(oldPath: string, newPath: string): void { + this.#checkWritable("rename", oldPath); + const oldNorm = this.#normalizePath(oldPath); + const newNorm = this.#normalizePath(newPath); + + const result = this.#lookupEntry(oldNorm, false); + if (!result.entry) throw createENOENT("rename", oldPath); + + const entry = result.entry; + const oldParent = this.#ensureParent(oldNorm, false, "rename"); + const oldName = pathPosix.basename(oldNorm); + + const newParent = this.#ensureParent(newNorm, false, "rename"); + const newName = pathPosix.basename(newNorm); + + getChildren(oldParent, "rename", oldPath).delete(oldName); + getChildren(newParent, "rename", newPath).set(newName, entry); + } + + async rename(oldPath: string, newPath: string): Promise { + return this.renameSync(oldPath, newPath); + } + + copyFileSync(src: string, dest: string): void { + this.#checkWritable("copyfile", dest); + const srcNorm = this.#normalizePath(src); + const destNorm = this.#normalizePath(dest); + + const srcEntry = this.#getEntry(srcNorm, "copyfile", true); + if (!srcEntry.isFile()) throw createEINVAL("copyfile", src); + + const existing = this.#lookupEntry(destNorm, true); + if (existing.entry) { + if (existing.entry.isDirectory()) throw createEISDIR("copyfile", dest); + existing.entry.content = srcEntry.content + ? Buffer.from(srcEntry.content) + : Buffer.alloc(0); + } else { + const parent = this.#ensureParent(destNorm, false, "copyfile"); + const name = pathPosix.basename(destNorm); + const entry = new MemoryEntry(TYPE_FILE); + entry.content = srcEntry.content + ? Buffer.from(srcEntry.content) + : Buffer.alloc(0); + getChildren(parent, "copyfile", dest).set(name, entry); + } + } + + async copyFile(src: string, dest: string): Promise { + return this.copyFileSync(src, dest); + } + + symlinkSync(target: string, p: string): void { + this.#checkWritable("symlink", p); + const normalized = this.#normalizePath(p); + + const existing = this.#lookupEntry(normalized, false); + if (existing.entry) throw createEEXIST("symlink", p); + + const parent = this.#ensureParent(normalized, false, "symlink"); + const name = pathPosix.basename(normalized); + const entry = new MemoryEntry(TYPE_SYMLINK); + entry.target = target; + getChildren(parent, "symlink", p).set(name, entry); + } + + async symlink(target: string, p: string): Promise { + return this.symlinkSync(target, p); + } + + chmodSync(p: string, mode: string | number): void { + const normalized = this.#normalizePath(p); + const entry = this.#getEntry(normalized, "chmod", true); + entry.mode = + (entry.mode & 0o170000) | + (typeof mode === "string" ? parseInt(mode, 8) : mode & 0o777); + } + + async chmod(p: string, mode: string | number): Promise { + return this.chmodSync(p, mode); + } + + chownSync(p: string, uid: number, gid: number): void { + const normalized = this.#normalizePath(p); + const entry = this.#getEntry(normalized, "chown", true); + entry.uid = uid; + entry.gid = gid; + } + + async chown(p: string, uid: number, gid: number): Promise { + return this.chownSync(p, uid, gid); + } + + utimesSync(p: string, atime: number | Date, mtime: number | Date): void { + const normalized = this.#normalizePath(p); + const entry = this.#getEntry(normalized, "utimes", true); + entry.atime = typeof atime === "number" ? atime * 1000 : +atime; + entry.mtime = typeof mtime === "number" ? mtime * 1000 : +mtime; + entry.ctime = Date.now(); + } + + mkdtempSync(prefix: string): string { + this.#checkWritable("mkdtemp", prefix); + const normalized = this.#normalizePath(prefix); + const chars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let suffix = ""; + for (let i = 0; i < 6; i++) { + suffix += chars[Math.floor(Math.random() * chars.length)]; + } + const dirPath = normalized + suffix; + this.mkdirSync(dirPath, { recursive: true }); + return dirPath; + } + + async mkdtemp(prefix: string): Promise { + return this.mkdtempSync(prefix); + } + + rmSync(p: string, options?: { recursive?: boolean; force?: boolean }): void { + this.#checkWritable("rm", p); + const force = options?.force === true; + const recursive = options?.recursive === true; + + let stats: VfsStats; + try { + stats = this.lstatSync(p); + } catch (err) { + if (force && (err as NodeJS.ErrnoException).code === "ENOENT") return; + throw err; + } + + if (stats.isSymbolicLink()) { + this.unlinkSync(p); + return; + } + + if (stats.isDirectory()) { + if (!recursive) throw createEISDIR("rm", p); + const entries = this.readdirSync(p) as string[]; + for (const name of entries) { + this.rmSync(pathPosix.join(p, name), options); + } + this.rmdirSync(p); + } else { + this.unlinkSync(p); + } + } + + async rm( + p: string, + options?: { recursive?: boolean; force?: boolean }, + ): Promise { + return this.rmSync(p, options); + } + + truncateSync(p: string, len = 0): void { + this.#checkWritable("truncate", p); + const entry = this.#getEntry(p, "truncate", true); + if (entry.isDirectory()) throw createEISDIR("truncate", p); + const current = entry.content ?? Buffer.alloc(0); + if (len <= current.length) { + entry.content = current.subarray(0, len); + } else { + entry.content = Buffer.concat([ + current, + Buffer.alloc(len - current.length), + ]); + } + const now = Date.now(); + entry.mtime = now; + entry.ctime = now; + } + + async truncate(p: string, len?: number): Promise { + return this.truncateSync(p, len); + } + + linkSync(existingPath: string, newPath: string): void { + this.#checkWritable("link", newPath); + const existNorm = this.#normalizePath(existingPath); + const newNorm = this.#normalizePath(newPath); + + const entry = this.#getEntry(existNorm, "link", true); + if (entry.isDirectory()) throw createEISDIR("link", existingPath); + + const existing = this.#lookupEntry(newNorm, false); + if (existing.entry) throw createEEXIST("link", newPath); + + const parent = this.#ensureParent(newNorm, false, "link"); + getChildren(parent, "link", newPath).set( + pathPosix.basename(newNorm), + entry, + ); + entry.nlink++; + } + + async link(existingPath: string, newPath: string): Promise { + return this.linkSync(existingPath, newPath); + } +} diff --git a/packages/vfs/src/router.ts b/packages/vfs/src/router.ts new file mode 100644 index 0000000..a0c5b44 --- /dev/null +++ b/packages/vfs/src/router.ts @@ -0,0 +1,57 @@ +/** + * Path routing helpers for the virtual file system. + * + * Ported from `lib/internal/vfs/router.js` in the Node.js PR #61478. + * Uses `path.join` instead of string concatenation per reviewer feedback + * (avivkeller, 2026-01-29). + * + * Handles cross-platform path separators: on Windows `path.resolve` produces + * backslash-separated paths, so all comparisons use `path.sep` rather than a + * hard-coded `/`. + */ +import { isAbsolute, relative, sep } from "node:path"; + +export { isAbsolute }; + +/** + * Returns `true` when `normalizedPath` is at or under `mountPoint`. + * Both arguments must already have been passed through `path.resolve`. + */ +export function isUnderMountPoint( + normalizedPath: string, + mountPoint: string, +): boolean { + if (normalizedPath === mountPoint) { + return true; + } + // Special case: root mount point – every absolute path lives under it. + if (mountPoint === "/") { + return normalizedPath.startsWith("/"); + } + // Avoid double-separator for mount points that already end with sep + // (e.g. 'C:\' on Windows). + const prefix = + mountPoint[mountPoint.length - 1] === sep ? mountPoint : mountPoint + sep; + return normalizedPath.startsWith(prefix); +} + +/** + * Returns the provider-internal POSIX path for a path that lives under + * `mountPoint`. The result always starts with `/`. + * + * Uses `path.relative` so the correct platform separator is handled, then + * re-joins with `/` to produce a POSIX-style path for the provider. + */ +export function getRelativePath( + normalizedPath: string, + mountPoint: string, +): string { + if (normalizedPath === mountPoint) { + return "/"; + } + if (mountPoint === "/") { + return normalizedPath; + } + const rel = relative(mountPoint, normalizedPath); + return "/" + rel.split(sep).join("/"); +} diff --git a/packages/vfs/src/stats.ts b/packages/vfs/src/stats.ts new file mode 100644 index 0000000..a63087b --- /dev/null +++ b/packages/vfs/src/stats.ts @@ -0,0 +1,139 @@ +/** + * Stats-like objects for the virtual file system. + * + * Node.js does not expose a public constructor for `fs.Stats` with custom + * values, so we define our own {@link VfsStats} class that implements the + * same interface. Code that checks `stats.isFile()`, `stats.isDirectory()`, + * or reads `stats.size` / `stats.mtime` will work identically. + */ + +// Distinctive device number used for all VFS entries (0xVF5 = 4085) +const VFS_DEV = 4085; +// Default block size (4 KiB) +const DEFAULT_BLOCK_SIZE = 4096; + +let inoCounter = 1; + +function nextIno(): number { + return inoCounter++; +} + +export interface StatOptions { + mode?: number; + uid?: number; + gid?: number; + nlink?: number; + atimeMs?: number; + mtimeMs?: number; + ctimeMs?: number; + birthtimeMs?: number; +} + +/** Minimal Stats-like object returned by VFS operations. */ +export class VfsStats { + readonly dev: number = VFS_DEV; + readonly ino: number; + readonly mode: number; + readonly nlink: number; + readonly uid: number; + readonly gid: number; + readonly rdev: number = 0; + readonly size: number; + readonly blksize: number = DEFAULT_BLOCK_SIZE; + readonly blocks: number; + readonly atimeMs: number; + readonly mtimeMs: number; + readonly ctimeMs: number; + readonly birthtimeMs: number; + readonly atime: Date; + readonly mtime: Date; + readonly ctime: Date; + readonly birthtime: Date; + + readonly #isFileFlag: boolean; + readonly #isDirFlag: boolean; + readonly #isSymlinkFlag: boolean; + + constructor( + isFile: boolean, + isDirectory: boolean, + isSymlink: boolean, + size: number, + options: StatOptions = {}, + ) { + const now = Date.now(); + this.#isFileFlag = isFile; + this.#isDirFlag = isDirectory; + this.#isSymlinkFlag = isSymlink; + this.ino = nextIno(); + this.size = size; + this.blocks = Math.ceil(size / 512); + + // File type bits + const S_IFREG = 0o100000; + const S_IFDIR = 0o040000; + const S_IFLNK = 0o120000; + + const defaultMode = isDirectory ? 0o755 : 0o644; + const rawMode = options.mode ?? defaultMode; + const typeBit = isDirectory ? S_IFDIR : isSymlink ? S_IFLNK : S_IFREG; + this.mode = (rawMode & ~0o170000) | typeBit; + + this.nlink = options.nlink ?? 1; + this.uid = options.uid ?? process.getuid?.() ?? 0; + this.gid = options.gid ?? process.getgid?.() ?? 0; + + this.atimeMs = options.atimeMs ?? now; + this.mtimeMs = options.mtimeMs ?? now; + this.ctimeMs = options.ctimeMs ?? now; + this.birthtimeMs = options.birthtimeMs ?? now; + + this.atime = new Date(this.atimeMs); + this.mtime = new Date(this.mtimeMs); + this.ctime = new Date(this.ctimeMs); + this.birthtime = new Date(this.birthtimeMs); + } + + isFile(): boolean { + return this.#isFileFlag; + } + + isDirectory(): boolean { + return this.#isDirFlag; + } + + isSymbolicLink(): boolean { + return this.#isSymlinkFlag; + } + + isBlockDevice(): boolean { + return false; + } + + isCharacterDevice(): boolean { + return false; + } + + isFIFO(): boolean { + return false; + } + + isSocket(): boolean { + return false; + } +} + +export function createFileStats(size: number, options?: StatOptions): VfsStats { + return new VfsStats(true, false, false, size, options); +} + +export function createDirectoryStats(options?: StatOptions): VfsStats { + return new VfsStats(false, true, false, DEFAULT_BLOCK_SIZE, options); +} + +export function createSymlinkStats( + size: number, + options?: StatOptions, +): VfsStats { + return new VfsStats(false, false, true, size, options); +} diff --git a/packages/vfs/tsconfig.json b/packages/vfs/tsconfig.json new file mode 100644 index 0000000..d1d8acd --- /dev/null +++ b/packages/vfs/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts", "../../reset.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 365466c..85ae053 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,6 +332,15 @@ importers: specifier: 5.8.3 version: 5.8.3 + packages/vfs: + devDependencies: + '@types/node': + specifier: 22.16.0 + version: 22.16.0 + typescript: + specifier: 5.8.3 + version: 5.8.3 + packages/vite-plugin: dependencies: vite-tsconfig-paths: