/** * Node.js filesystem provider for SmartFS * Uses Node.js fs/promises and fs.watch APIs */ import * as fs from 'fs/promises'; import * as fsSync from 'fs'; import * as pathModule from 'path'; import { Readable, Writable } from 'stream'; import type { ISmartFsProvider, IProviderCapabilities, TWatchCallback, IWatcherHandle, } from '../interfaces/mod.provider.js'; import type { IFileStats, IDirectoryEntry, IReadOptions, IWriteOptions, IStreamOptions, ICopyOptions, IListOptions, IWatchOptions, ITransactionOperation, IWatchEvent, TWatchEventType, } from '../interfaces/mod.types.js'; /** * Node.js filesystem provider */ export class SmartFsProviderNode implements ISmartFsProvider { public readonly name = 'node'; public readonly capabilities: IProviderCapabilities = { supportsWatch: true, supportsAtomic: true, supportsTransactions: true, supportsStreaming: true, supportsSymlinks: true, supportsPermissions: true, }; // --- File Operations --- public async readFile(path: string, options?: IReadOptions): Promise { const encoding = options?.encoding === 'buffer' ? undefined : (options?.encoding as BufferEncoding); if (encoding) { return fs.readFile(path, { encoding }); } return fs.readFile(path); } public async writeFile(path: string, content: string | Buffer, options?: IWriteOptions): Promise { const encoding = options?.encoding === 'buffer' ? undefined : (options?.encoding as BufferEncoding); const mode = options?.mode; if (options?.atomic) { // Atomic write: write to temp file, then rename const tempPath = `${path}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`; try { await fs.writeFile(tempPath, content, { encoding, mode }); await fs.rename(tempPath, path); } catch (error) { // Clean up temp file on error try { await fs.unlink(tempPath); } catch { // Ignore cleanup errors } throw error; } } else { await fs.writeFile(path, content, { encoding, mode }); } } public async appendFile(path: string, content: string | Buffer, options?: IWriteOptions): Promise { const encoding = options?.encoding === 'buffer' ? undefined : (options?.encoding as BufferEncoding); const mode = options?.mode; await fs.appendFile(path, content, { encoding, mode }); } public async deleteFile(path: string): Promise { await fs.unlink(path); } public async copyFile(from: string, to: string, options?: ICopyOptions): Promise { // Copy the file await fs.copyFile(from, to); // Preserve timestamps if requested if (options?.preserveTimestamps) { const stats = await fs.stat(from); await fs.utimes(to, stats.atime, stats.mtime); } } public async moveFile(from: string, to: string, options?: ICopyOptions): Promise { try { // Try rename first (fastest if on same filesystem) await fs.rename(from, to); // Preserve timestamps if requested if (options?.preserveTimestamps) { const stats = await fs.stat(to); await fs.utimes(to, stats.atime, stats.mtime); } } catch (error: any) { if (error.code === 'EXDEV') { // Cross-device move: copy then delete await this.copyFile(from, to, options); await this.deleteFile(from); } else { throw error; } } } public async fileExists(path: string): Promise { try { await fs.access(path); return true; } catch { return false; } } public async fileStat(path: string): Promise { const stats = await fs.stat(path); return this.convertStats(stats); } public async createReadStream(path: string, options?: IStreamOptions): Promise> { const nodeStream = fsSync.createReadStream(path, { highWaterMark: options?.chunkSize || options?.highWaterMark, }); return this.nodeReadableToWeb(nodeStream); } public async createWriteStream(path: string, options?: IStreamOptions): Promise> { const nodeStream = fsSync.createWriteStream(path, { highWaterMark: options?.chunkSize || options?.highWaterMark, }); return this.nodeWritableToWeb(nodeStream); } // --- Directory Operations --- public async listDirectory(path: string, options?: IListOptions): Promise { const entries: IDirectoryEntry[] = []; if (options?.recursive) { await this.listDirectoryRecursive(path, entries, options); } else { const dirents = await fs.readdir(path, { withFileTypes: true }); for (const dirent of dirents) { const entryPath = pathModule.join(path, dirent.name); const entry: IDirectoryEntry = { name: dirent.name, path: entryPath, isFile: dirent.isFile(), isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink(), }; // Apply filter if (options?.filter && !this.matchesFilter(entry, options.filter)) { continue; } // Add stats if requested if (options?.includeStats) { try { entry.stats = await this.fileStat(entryPath); } catch { // Ignore stat errors } } entries.push(entry); } } return entries; } private async listDirectoryRecursive( path: string, entries: IDirectoryEntry[], options?: IListOptions, ): Promise { const dirents = await fs.readdir(path, { withFileTypes: true }); for (const dirent of dirents) { const entryPath = pathModule.join(path, dirent.name); const entry: IDirectoryEntry = { name: dirent.name, path: entryPath, isFile: dirent.isFile(), isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink(), }; // Apply filter if (options?.filter && !this.matchesFilter(entry, options.filter)) { // Skip this entry but continue recursion for directories if (dirent.isDirectory()) { await this.listDirectoryRecursive(entryPath, entries, options); } continue; } // Add stats if requested if (options?.includeStats) { try { entry.stats = await this.fileStat(entryPath); } catch { // Ignore stat errors } } entries.push(entry); // Recurse into subdirectories if (dirent.isDirectory()) { await this.listDirectoryRecursive(entryPath, entries, options); } } } public async createDirectory(path: string, options?: { recursive?: boolean; mode?: number }): Promise { await fs.mkdir(path, { recursive: options?.recursive, mode: options?.mode, }); } public async deleteDirectory(path: string, options?: { recursive?: boolean }): Promise { await fs.rm(path, { recursive: options?.recursive ?? true, force: true, }); } public async directoryExists(path: string): Promise { try { const stats = await fs.stat(path); return stats.isDirectory(); } catch { return false; } } public async directoryStat(path: string): Promise { const stats = await fs.stat(path); return this.convertStats(stats); } // --- Watch Operations --- public async watch(path: string, callback: TWatchCallback, options?: IWatchOptions): Promise { const watcher = fsSync.watch( path, { recursive: options?.recursive, }, async (eventType, filename) => { if (!filename) return; const fullPath = pathModule.join(path, filename); // Apply filter if (options?.filter && !this.matchesPathFilter(fullPath, options.filter)) { return; } // Determine event type let type: TWatchEventType = 'change'; try { await fs.access(fullPath); type = eventType === 'rename' ? 'add' : 'change'; } catch { type = 'delete'; } // Get stats if available let stats: IFileStats | undefined; if (type !== 'delete') { try { stats = await this.fileStat(fullPath); } catch { // Ignore stat errors } } const event: IWatchEvent = { type, path: fullPath, timestamp: new Date(), stats, }; await callback(event); }, ); return { stop: async () => { watcher.close(); }, }; } // --- Transaction Operations --- public async prepareTransaction(operations: ITransactionOperation[]): Promise { const prepared: ITransactionOperation[] = []; for (const op of operations) { const preparedOp = { ...op }; // Create backup for rollback try { const exists = await this.fileExists(op.path); if (exists) { const content = await this.readFile(op.path); const stats = await this.fileStat(op.path); preparedOp.backup = { existed: true, content: Buffer.isBuffer(content) ? content : Buffer.from(content), stats, }; } else { preparedOp.backup = { existed: false, }; } } catch { preparedOp.backup = { existed: false, }; } prepared.push(preparedOp); } return prepared; } public async executeTransaction(operations: ITransactionOperation[]): Promise { for (const op of operations) { try { switch (op.type) { case 'write': await this.writeFile(op.path, op.content!, { encoding: op.encoding }); break; case 'append': await this.appendFile(op.path, op.content!, { encoding: op.encoding }); break; case 'delete': await this.deleteFile(op.path); break; case 'copy': await this.copyFile(op.path, op.targetPath!); break; case 'move': await this.moveFile(op.path, op.targetPath!); break; } } catch (error) { // On error, rollback the transaction await this.rollbackTransaction(operations); throw error; } } } public async rollbackTransaction(operations: ITransactionOperation[]): Promise { // Rollback in reverse order for (let i = operations.length - 1; i >= 0; i--) { const op = operations[i]; if (!op.backup) continue; try { if (op.backup.existed && op.backup.content) { // Restore original content await this.writeFile(op.path, op.backup.content); } else if (!op.backup.existed) { // Delete file that was created try { await this.deleteFile(op.path); } catch { // Ignore errors } } } catch { // Ignore rollback errors } } } // --- Path Operations --- public normalizePath(path: string): string { return pathModule.normalize(path); } public joinPath(...segments: string[]): string { return pathModule.join(...segments); } // --- Helper Methods --- private convertStats(stats: fsSync.Stats): IFileStats { return { size: stats.size, birthtime: stats.birthtime, mtime: stats.mtime, atime: stats.atime, isFile: stats.isFile(), isDirectory: stats.isDirectory(), isSymbolicLink: stats.isSymbolicLink(), mode: stats.mode, }; } private matchesFilter( entry: IDirectoryEntry, filter: string | RegExp | ((entry: IDirectoryEntry) => boolean), ): boolean { if (typeof filter === 'function') { return filter(entry); } else if (filter instanceof RegExp) { return filter.test(entry.name); } else { // Simple glob-like pattern matching const pattern = filter.replace(/\*/g, '.*'); const regex = new RegExp(`^${pattern}$`); return regex.test(entry.name); } } private matchesPathFilter( path: string, filter: string | RegExp | ((path: string) => boolean), ): boolean { if (typeof filter === 'function') { return filter(path); } else if (filter instanceof RegExp) { return filter.test(path); } else { // Simple glob-like pattern matching const pattern = filter.replace(/\*/g, '.*'); const regex = new RegExp(`^${pattern}$`); return regex.test(path); } } // --- Stream Conversion Helpers --- private nodeReadableToWeb(nodeStream: Readable): ReadableStream { return new ReadableStream({ start(controller) { nodeStream.on('data', (chunk: Buffer) => { controller.enqueue(new Uint8Array(chunk)); }); nodeStream.on('end', () => { controller.close(); }); nodeStream.on('error', (error) => { controller.error(error); }); }, cancel() { nodeStream.destroy(); }, }); } private nodeWritableToWeb(nodeStream: Writable): WritableStream { return new WritableStream({ write(chunk) { return new Promise((resolve, reject) => { const canContinue = nodeStream.write(Buffer.from(chunk)); if (canContinue) { resolve(); } else { nodeStream.once('drain', resolve); nodeStream.once('error', reject); } }); }, close() { return new Promise((resolve, reject) => { nodeStream.end(); nodeStream.once('finish', resolve); nodeStream.once('error', reject); }); }, abort(reason) { nodeStream.destroy(new Error(reason)); }, }); } }