@firesystem/memory
Version:
In-memory implementation of Virtual File System
803 lines (684 loc) • 21.2 kB
text/typescript
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);
}
}