UNPKG

@firesystem/memory

Version:

In-memory implementation of Virtual File System

803 lines (684 loc) 21.2 kB
import type { IFileSystem, IReactiveFileSystem, FileEntry, FileStat, FileMetadata, FSEvent, Disposable, FileSystemEventPayloads, IFileSystemCapabilities, } from "@firesystem/core"; import { normalizePath, dirname, basename, join, TypedEventEmitter, FileSystemEvents, BaseFileSystem, } from "@firesystem/core"; import type { MemoryFileSystemOptions } from "./types"; interface MemoryFile { path: string; type: "file" | "directory"; content?: any; size: number; created: Date; modified: Date; metadata?: FileMetadata; parent: string; } interface WatchListener { id: string; pattern: string; callback: (event: FSEvent) => void; } export class MemoryFileSystem extends BaseFileSystem { private files: Map<string, MemoryFile> = new Map(); private watchers: WatchListener[] = []; public readonly events = new TypedEventEmitter<FileSystemEventPayloads>(); // Capabilities do sistema em memória readonly capabilities: IFileSystemCapabilities = { readonly: false, caseSensitive: true, atomicRename: true, supportsWatch: true, supportsMetadata: true, supportsGlob: true, maxFileSize: 50 * 1024 * 1024, // 50MB limite prático maxPathLength: 1024, }; constructor(options?: MemoryFileSystemOptions) { super(); // Create root directory this.files.set("/", { path: "/", type: "directory", size: 0, created: new Date(), modified: new Date(), parent: "", }); // Add initial files if provided if (options?.initialFiles) { for (const file of options.initialFiles) { const normalized = normalizePath(file.path); this.files.set(normalized, { path: normalized, type: file.type, content: file.content, size: file.size || this.calculateSize(file.content), created: file.created || new Date(), modified: file.modified || new Date(), metadata: file.metadata, parent: dirname(normalized), }); } } } async initialize(): Promise<void> { const startTime = Date.now(); this.events.emit(FileSystemEvents.INITIALIZING, undefined as any); try { // Get all files except root const allFiles = Array.from(this.files.values()).filter( (f) => f.path !== "/", ); const totalFiles = allFiles.length; // Emit events for each file for (let i = 0; i < allFiles.length; i++) { const file = allFiles[i]; // Emit individual file events if (file.type === "file") { this.events.emit(FileSystemEvents.FILE_READ, { path: file.path, size: file.size, }); } else { // For directories, count children const childCount = Array.from(this.files.values()).filter( (f) => f.parent === file.path, ).length; this.events.emit(FileSystemEvents.DIR_READ, { path: file.path, count: childCount, }); } // Emit progress this.events.emit(FileSystemEvents.INIT_PROGRESS, { loaded: i + 1, total: totalFiles, phase: "loading-files", }); } this.events.emit(FileSystemEvents.INITIALIZED, { duration: Date.now() - startTime, }); } catch (error) { this.events.emit(FileSystemEvents.INIT_ERROR, { error: error as Error, }); throw error; } } async readFile(path: string): Promise<FileEntry> { const normalized = normalizePath(path); const startTime = Date.now(); const operationId = `read-${normalized}-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "readFile", path: normalized, id: operationId, }); this.events.emit(FileSystemEvents.FILE_READING, { path: normalized }); try { const file = this.files.get(normalized); if (!file) { throw new Error(`ENOENT: no such file or directory, open '${path}'`); } if (file.type === "directory") { throw new Error( `EISDIR: illegal operation on a directory, read '${path}'`, ); } const result = { path: file.path, name: basename(file.path), type: file.type, size: file.size, created: file.created, modified: file.modified, metadata: file.metadata, content: file.content, }; this.events.emit(FileSystemEvents.FILE_READ, { path: normalized, size: file.size, }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "readFile", path: normalized, id: operationId, duration: Date.now() - startTime, }); return result; } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "readFile", path: normalized, error: error as Error, }); throw error; } } async writeFile( path: string, content: any, metadata?: FileMetadata, ): Promise<FileEntry> { const normalized = normalizePath(path); const startTime = Date.now(); const operationId = `write-${normalized}-${startTime}`; const size = this.calculateSize(content); this.events.emit(FileSystemEvents.OPERATION_START, { operation: "writeFile", path: normalized, id: operationId, }); this.events.emit(FileSystemEvents.FILE_WRITING, { path: normalized, size }); try { // Ensure parent directory exists await this.ensureParentExists(normalized); const existing = this.files.get(normalized); const now = new Date(); const file: MemoryFile = { path: normalized, type: "file", content, size, created: existing?.created || now, modified: now, metadata, parent: dirname(normalized), }; const isUpdate = !!existing; this.files.set(normalized, file); const result = { path: file.path, name: basename(file.path), type: file.type, size: file.size, created: file.created, modified: file.modified, metadata: file.metadata, content: file.content, }; // Notify watchers this.notifyWatchers({ type: isUpdate ? "updated" : "created", path: normalized, timestamp: now, }); this.events.emit(FileSystemEvents.FILE_WRITTEN, { path: normalized, size, }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "writeFile", path: normalized, id: operationId, duration: Date.now() - startTime, }); return result; } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "writeFile", path: normalized, error: error as Error, }); throw error; } } async deleteFile(path: string): Promise<void> { const normalized = normalizePath(path); const file = this.files.get(normalized); if (!file) { throw new Error(`ENOENT: no such file or directory, unlink '${path}'`); } if (file.type === "directory") { // Check if directory is empty const hasChildren = Array.from(this.files.values()).some( (f) => f.parent === normalized, ); if (hasChildren) { throw new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`); } } this.files.delete(normalized); // Notify watchers this.notifyWatchers({ type: "deleted", path: normalized, timestamp: new Date(), }); } async exists(path: string): Promise<boolean> { const normalized = normalizePath(path); return this.files.has(normalized); } async readDir(path: string): Promise<FileEntry[]> { const normalized = normalizePath(path); // Check if directory exists if (normalized !== "/" && !this.files.has(normalized)) { throw new Error(`ENOENT: no such file or directory, scandir '${path}'`); } const entries: FileEntry[] = []; for (const file of this.files.values()) { if (file.parent === normalized) { entries.push({ path: file.path, name: basename(file.path), type: file.type, size: file.size, created: file.created, modified: file.modified, metadata: file.metadata, }); } } return entries; } async mkdir(path: string, recursive: boolean = false): Promise<FileEntry> { const normalized = normalizePath(path); if (normalized === "/") { throw new Error(`EEXIST: file already exists, mkdir '${path}'`); } if (this.files.has(normalized)) { throw new Error(`EEXIST: file already exists, mkdir '${path}'`); } const parent = dirname(normalized); if (!recursive && parent !== "/" && !this.files.has(parent)) { throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`); } // Create parent directories if recursive if (recursive && parent !== "/" && !this.files.has(parent)) { await this.mkdir(parent, true); } const now = new Date(); const dir: MemoryFile = { path: normalized, type: "directory", size: 0, created: now, modified: now, parent: dirname(normalized), }; this.files.set(normalized, dir); // Notify watchers this.notifyWatchers({ type: "created", path: normalized, timestamp: now, }); return { path: dir.path, name: basename(dir.path), type: dir.type, size: 0, created: now, modified: now, }; } async rmdir(path: string, recursive: boolean = false): Promise<void> { const normalized = normalizePath(path); if (normalized === "/") { throw new Error(`EBUSY: resource busy or locked, rmdir '${path}'`); } const file = this.files.get(normalized); if (!file) { throw new Error(`ENOENT: no such file or directory, rmdir '${path}'`); } if (file.type !== "directory") { throw new Error(`ENOTDIR: not a directory, rmdir '${path}'`); } const contents = await this.readDir(normalized); if (contents.length > 0 && !recursive) { throw new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`); } if (recursive) { // Delete all contents recursively await this.deleteRecursive(normalized); } // Delete the directory itself this.files.delete(normalized); // Notify watchers this.notifyWatchers({ type: "deleted", path: normalized, timestamp: new Date(), }); } async rename(oldPath: string, newPath: string): Promise<FileEntry> { const oldNormalized = normalizePath(oldPath); const newNormalized = normalizePath(newPath); const file = this.files.get(oldNormalized); if (!file) { throw new Error(`ENOENT: no such file or directory, rename '${oldPath}'`); } // Ensure new parent exists await this.ensureParentExists(newNormalized); // Check if new path already exists if (this.files.has(newNormalized)) { throw new Error( `EEXIST: file already exists, rename '${oldPath}' -> '${newPath}'`, ); } const now = new Date(); // If it's a directory, update all children if (file.type === "directory") { const toUpdate: Array<[string, MemoryFile]> = []; // Collect all files to update for (const [path, f] of this.files) { if (path === oldNormalized || path.startsWith(oldNormalized + "/")) { const newFilePath = path.replace(oldNormalized, newNormalized); toUpdate.push([ path, { ...f, path: newFilePath, parent: dirname(newFilePath), modified: path === oldNormalized ? now : f.modified, }, ]); } } // Delete old entries and add new ones for (const [oldPath] of toUpdate) { this.files.delete(oldPath); } for (const [, newFile] of toUpdate) { this.files.set(newFile.path, newFile); } // Get the renamed directory entry const renamedDir = this.files.get(newNormalized)!; // Notify watchers this.notifyWatchers({ type: "renamed", path: newNormalized, oldPath: oldNormalized, timestamp: now, }); return { path: renamedDir.path, name: basename(renamedDir.path), type: renamedDir.type, size: renamedDir.size, created: renamedDir.created, modified: renamedDir.modified, metadata: renamedDir.metadata, }; } else { // It's a file, simpler case const newFile: MemoryFile = { ...file, path: newNormalized, parent: dirname(newNormalized), modified: now, }; this.files.delete(oldNormalized); this.files.set(newNormalized, newFile); // Notify watchers this.notifyWatchers({ type: "renamed", path: newNormalized, oldPath: oldNormalized, timestamp: now, }); return { path: newFile.path, name: basename(newFile.path), type: newFile.type, size: newFile.size, created: newFile.created, modified: newFile.modified, metadata: newFile.metadata, content: newFile.content, }; } } async move(sourcePaths: string[], targetPath: string): Promise<void> { const targetNormalized = normalizePath(targetPath); // Ensure target is a directory const target = this.files.get(targetNormalized); if (!target || target.type !== "directory") { throw new Error(`ENOTDIR: not a directory, move to '${targetPath}'`); } for (const sourcePath of sourcePaths) { const name = basename(sourcePath); const newPath = join(targetNormalized, name); await this.rename(sourcePath, newPath); } } async copy(sourcePath: string, targetPath: string): Promise<FileEntry> { const sourceNormalized = normalizePath(sourcePath); const source = this.files.get(sourceNormalized); if (!source) { throw new Error( `ENOENT: no such file or directory, open '${sourcePath}'`, ); } if (source.type === "directory") { throw new Error( `EISDIR: illegal operation on a directory, read '${sourcePath}'`, ); } return this.writeFile(targetPath, source.content, source.metadata); } watch(pattern: string, callback: (event: FSEvent) => void): Disposable { const id = this.generateWatcherId(); const listener: WatchListener = { id, pattern, callback }; this.watchers.push(listener); return { dispose: () => { const index = this.watchers.findIndex((w) => w.id === id); if (index !== -1) { this.watchers.splice(index, 1); } }, }; } async stat(path: string): Promise<FileStat> { const normalized = normalizePath(path); const file = this.files.get(normalized); if (!file) { throw new Error(`ENOENT: no such file or directory, stat '${path}'`); } return { path: file.path, size: file.size, type: file.type, created: file.created, modified: file.modified, readonly: false, // Sempre false em memória }; } async glob(pattern: string): Promise<string[]> { const paths: string[] = []; for (const file of this.files.values()) { if (this.matchesPattern(file.path, pattern)) { paths.push(file.path); } } return paths.sort(); } async clear(): Promise<void> { const startTime = Date.now(); const operationId = `clear-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "clear", id: operationId, }); this.events.emit(FileSystemEvents.STORAGE_CLEARING, undefined as any); try { this.files.clear(); // Recreate root directory this.files.set("/", { path: "/", type: "directory", size: 0, created: new Date(), modified: new Date(), parent: "", }); const duration = Date.now() - startTime; this.events.emit(FileSystemEvents.STORAGE_CLEARED, { duration }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "clear", id: operationId, duration, }); } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "clear", error: error as Error, }); throw error; } } async size(): Promise<number> { const startTime = Date.now(); const operationId = `size-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "size", id: operationId, }); try { let totalSize = 0; for (const file of this.files.values()) { totalSize += file.size; } this.events.emit(FileSystemEvents.STORAGE_SIZE_CALCULATED, { size: totalSize, }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "size", id: operationId, duration: Date.now() - startTime, }); return totalSize; } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "size", error: error as Error, }); throw error; } } // Helper methods private calculateSize(content: any): number { if (content === null || content === undefined) { return 0; } if (typeof content === "string") { // UTF-16 encoding (JavaScript strings) return content.length * 2; } if (content instanceof ArrayBuffer) { return content.byteLength; } if (typeof content === "object") { return JSON.stringify(content).length * 2; } return 0; } private async ensureParentExists(path: string): Promise<void> { const parent = dirname(path); if (parent === "/" || parent === path) return; if (!this.files.has(parent)) { throw new Error(`ENOENT: no such file or directory, open '${path}'`); } } private async deleteRecursive(path: string): Promise<void> { const toDelete: string[] = []; // Collect all descendants for (const file of this.files.values()) { if (file.path.startsWith(path + "/")) { toDelete.push(file.path); } } // Sort by path length descending (delete children first) toDelete.sort((a, b) => b.length - a.length); // Delete all children for (const filePath of toDelete) { this.files.delete(filePath); } } private matchesPattern(path: string, pattern: string): boolean { // Handle simple cases if (pattern === "**" || pattern === "**/*") { return true; } if (pattern === "*") { // * should only match files in root directory return path.split("/").length === 2 && path !== "/"; } // Convert glob pattern to regex let regex = pattern // Escape special regex characters except glob ones .replace(/[.+^${}()|[\]\\]/g, "\\$&") // Handle ** (matches any number of directories) .replace(/\*\*/g, "___GLOBSTAR___") // Handle * (matches any characters except /) .replace(/\*/g, "[^/]*") // Handle ? (matches single character except /) .replace(/\?/g, "[^/]") // Restore ** handling .replace(/___GLOBSTAR___\//g, "(.*/)?") .replace(/\/___GLOBSTAR___/g, "(/.*)?") .replace(/___GLOBSTAR___/g, ".*"); // Anchor the pattern regex = "^" + regex + "$"; return new RegExp(regex).test(path); } private notifyWatchers(event: FSEvent): void { for (const watcher of this.watchers) { if (this.matchesPattern(event.path, watcher.pattern)) { try { watcher.callback(event); } catch (error) { console.error( `Error in watch callback for pattern "${watcher.pattern}":`, error, ); } } } } private generateWatcherId(): string { return `watch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } // Sobrescrever métodos de permissão (sempre permitido em memória) async canModify(path: string): Promise<boolean> { return true; // Sempre permitido em memória } async canCreateIn(parentPath: string): Promise<boolean> { try { const stat = await this.stat(parentPath); return stat.type === "directory"; } catch { // Se não existe, verifica se é o root return normalizePath(parentPath) === "/"; } } // writeFileAtomic já está implementado na classe base // Mas podemos otimizar para memória async writeFileAtomic( path: string, content: any, metadata?: FileMetadata, ): Promise<FileEntry> { // Em memória, podemos fazer atomicamente sem arquivo temp return this.writeFile(path, content, metadata); } }