UNPKG

@kadi.build/local-remote-file-manager-ability

Version:

Local & Remote File Management System with S3-compatible container registry, HTTP server provider, file streaming, and comprehensive testing suite

696 lines (568 loc) • 20.9 kB
import chokidar from 'chokidar'; import { promises as fs } from 'fs'; import path from 'path'; import EventEmitter from 'events'; class WatchProvider extends EventEmitter { constructor(config) { super(); this.config = config || {}; this.localRoot = this.config.localRoot || process.cwd(); this.enabled = this.config.enabled !== false; // Default true this.recursive = this.config.recursive !== false; // Default true this.ignoreDotfiles = this.config.ignoreDotfiles !== false; // Default true this.debounceMs = this.config.debounceMs || 100; this.maxWatchers = this.config.maxWatchers || 50; // Active watchers registry this.watchers = new Map(); this.debounceTimers = new Map(); this.watchCount = 0; } // ============================================================================ // CONNECTION AND VALIDATION // ============================================================================ async testConnection() { try { // Test if we can access the local root const stats = await fs.stat(this.localRoot); if (!stats.isDirectory()) { throw new Error(`Local root '${this.localRoot}' is not a directory`); } // Test chokidar availability const testWatcher = chokidar.watch(this.localRoot, { ignored: () => true, // Ignore everything for test persistent: false, ignoreInitial: true }); await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Chokidar initialization timeout')); }, 5000); testWatcher.on('ready', () => { clearTimeout(timeout); testWatcher.close(); resolve(); }); testWatcher.on('error', (error) => { clearTimeout(timeout); reject(error); }); }); return { provider: 'watch', localRoot: this.localRoot, enabled: this.enabled, recursive: this.recursive, activeWatchers: this.watchers.size, maxWatchers: this.maxWatchers, debounceMs: this.debounceMs }; } catch (error) { throw new Error(`Watch provider connection test failed: ${error.message}`); } } validateConfig() { const errors = []; const warnings = []; if (!this.localRoot) { errors.push('Local root directory is required for watching'); } if (this.debounceMs < 0) { errors.push('Debounce time must be non-negative'); } if (this.maxWatchers <= 0) { errors.push('Max watchers must be positive'); } if (this.debounceMs > 10000) { warnings.push('Very high debounce time (>10s) may delay notifications significantly'); } if (this.maxWatchers > 100) { warnings.push('Very high max watchers (>100) may impact performance'); } if (!this.enabled) { warnings.push('File watching is disabled'); } return { isValid: errors.length === 0, errors, warnings }; } // ============================================================================ // PATH MANAGEMENT METHODS // ============================================================================ normalizePath(inputPath) { if (!inputPath || inputPath === '/') { return this.localRoot; } // Handle absolute paths if (path.isAbsolute(inputPath)) { return path.normalize(inputPath); } // Handle relative paths - resolve them relative to localRoot const resolvedLocalRoot = path.resolve(this.localRoot); return path.resolve(resolvedLocalRoot, inputPath); } validatePath(inputPath) { if (!inputPath) { throw new Error('Path cannot be empty'); } // Check for invalid characters if (/[<>:"|?*\x00-\x1f]/.test(inputPath)) { throw new Error(`Path contains invalid characters: ${inputPath}`); } return true; } generateWatchId(watchPath, options = {}) { const normalizedPath = this.normalizePath(watchPath); const optionsHash = JSON.stringify(options); return `${normalizedPath}:${optionsHash}`; } // ============================================================================ // CORE WATCHING METHODS // ============================================================================ async startWatching(watchPath, options = {}) { this.validatePath(watchPath); if (!this.enabled) { throw new Error('File watching is disabled in configuration'); } const normalizedPath = this.normalizePath(watchPath); // Check if path exists try { const stats = await fs.stat(normalizedPath); if (!stats.isDirectory() && !stats.isFile()) { throw new Error(`Path '${watchPath}' is neither a file nor directory`); } } catch (error) { if (error.code === 'ENOENT') { throw new Error(`Path not found: ${watchPath}`); } throw error; } const watchId = this.generateWatchId(watchPath, options); // Check if already watching if (this.watchers.has(watchId)) { console.log(`āš ļø Already watching ${watchPath}`); return { watchId, path: watchPath, alreadyWatching: true }; } // Check watcher limit if (this.watchCount >= this.maxWatchers) { throw new Error(`Maximum number of watchers (${this.maxWatchers}) reached`); } const { recursive = this.recursive, ignoreDotfiles = this.ignoreDotfiles, events = ['add', 'change', 'unlink', 'addDir', 'unlinkDir'], callback = null, persistent = true } = options; console.log(`šŸ‘ļø Starting to watch: ${watchPath}`); try { // Create chokidar watcher with enhanced ignore pattern const ignorePattern = this.buildIgnorePattern(ignoreDotfiles, recursive, normalizedPath); console.log(`šŸ”§ Chokidar configuration:`); console.log(` Path: ${normalizedPath}`); console.log(` Recursive: ${recursive}`); console.log(` Ignore patterns: ${ignorePattern ? ignorePattern.length : 0}`); const watcher = chokidar.watch(normalizedPath, { ignored: ignorePattern, persistent: persistent, ignoreInitial: true, followSymlinks: false, disableGlobbing: true, usePolling: false, interval: 100, binaryInterval: 300, ignorePermissionErrors: true }); // Add debugging to chokidar events watcher.on('all', (eventType, filePath) => { console.log(`šŸŽÆ CHOKIDAR RAW EVENT: ${eventType} -> ${filePath}`); }); // Set up event handlers this.setupWatcherEvents(watcher, watchId, watchPath, events, callback); // Wait for watcher to be ready await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Watcher initialization timeout')); }, 10000); watcher.on('ready', () => { clearTimeout(timeout); resolve(); }); watcher.on('error', (error) => { clearTimeout(timeout); reject(error); }); }); // FIXED: Store watcher info with proper initialization this.watchers.set(watchId, { watcher, path: watchPath, normalizedPath, options: { recursive: recursive, // FIXED: Ensure this is the actual value, not always true ignoreDotfiles: ignoreDotfiles, events: events, persistent: persistent }, callback, startedAt: new Date().toISOString(), eventCount: 0 }); console.log(`āœ… Stored watcher options: recursive=${recursive}, ignoreDotfiles=${ignoreDotfiles}`); this.watchCount++; console.log(`āœ… Started watching: ${watchPath} (${watchId})`); return { watchId, path: watchPath, recursive, events, startedAt: new Date().toISOString() }; } catch (error) { throw new Error(`Failed to start watching ${watchPath}: ${error.message}`); } } async stopWatching(watchIdOrPath) { let watchId; if (this.watchers.has(watchIdOrPath)) { watchId = watchIdOrPath; } else { // Try to find by path watchId = this.findWatcherByPath(watchIdOrPath); if (!watchId) { throw new Error(`No active watcher found for: ${watchIdOrPath}`); } } const watcherInfo = this.watchers.get(watchId); if (!watcherInfo) { throw new Error(`Watcher not found: ${watchId}`); } console.log(`šŸ›‘ Stopping watcher: ${watcherInfo.path}`); try { // Close the chokidar watcher await watcherInfo.watcher.close(); // Clean up debounce timers for this watcher this.cleanupDebounceTimers(watchId); // Remove from registry this.watchers.delete(watchId); this.watchCount--; console.log(`āœ… Stopped watching: ${watcherInfo.path}`); return { watchId, path: watcherInfo.path, stopped: true, eventCount: watcherInfo.eventCount, duration: Date.now() - new Date(watcherInfo.startedAt).getTime() }; } catch (error) { throw new Error(`Failed to stop watching: ${error.message}`); } } async stopAllWatching() { console.log(`šŸ›‘ Stopping all ${this.watchers.size} watchers...`); const results = []; const watchIds = Array.from(this.watchers.keys()); for (const watchId of watchIds) { try { const result = await this.stopWatching(watchId); results.push(result); } catch (error) { results.push({ watchId, error: error.message }); } } // Clean up any remaining timers this.debounceTimers.clear(); console.log(`āœ… Stopped ${results.filter(r => r.stopped).length} watcher(s)`); return { stopped: results.filter(r => r.stopped).length, failed: results.filter(r => r.error).length, results }; } // ============================================================================ // WATCHER MANAGEMENT METHODS // ============================================================================ setupWatcherEvents(watcher, watchId, watchPath, events, callback) { const eventHandlers = { add: (filePath) => this.handleFileEvent('add', filePath, watchId, watchPath, callback), change: (filePath) => this.handleFileEvent('change', filePath, watchId, watchPath, callback), unlink: (filePath) => this.handleFileEvent('unlink', filePath, watchId, watchPath, callback), addDir: (dirPath) => this.handleFileEvent('addDir', dirPath, watchId, watchPath, callback), unlinkDir: (dirPath) => this.handleFileEvent('unlinkDir', dirPath, watchId, watchPath, callback) }; // Only attach handlers for requested events events.forEach(eventType => { if (eventHandlers[eventType]) { watcher.on(eventType, eventHandlers[eventType]); } }); // Always attach error handler watcher.on('error', (error) => { console.error(`āŒ Watcher error for ${watchPath}:`, error.message); this.emit('watcherError', { watchId, path: watchPath, error: error.message }); }); } handleFileEvent(eventType, filePath, watchId, watchPath, callback) { const watcherInfo = this.watchers.get(watchId); if (!watcherInfo) { console.log(`āŒ No watcher info found for ${watchId}`); return; } console.log(`\nšŸ“Ø RECEIVED EVENT:`); console.log(` Type: ${eventType}`); console.log(` File: ${filePath}`); console.log(` Watch ID: ${watchId}`); console.log(` Watcher Recursive: ${watcherInfo.options.recursive}`); // FIXED: Add robust non-recursive filtering as a safety net // Check if this watcher is configured as non-recursive const isNonRecursive = watcherInfo.options.recursive === false; if (isNonRecursive) { console.log(`šŸ”’ Non-recursive safety filter:`); const watchedPath = path.resolve(watcherInfo.normalizedPath); const eventPath = path.resolve(filePath); const eventParentDir = path.dirname(eventPath); console.log(` Watched path: ${watchedPath}`); console.log(` Event path: ${eventPath}`); console.log(` Event parent: ${eventParentDir}`); console.log(` Parent === Watched: ${eventParentDir === watchedPath}`); if (eventParentDir !== watchedPath) { console.log(`🚫 SAFETY FILTER: Blocking nested event`); return; // Block nested events } console.log(`āœ… SAFETY FILTER: Allowing direct child event`); } // Increment event counter watcherInfo.eventCount = (watcherInfo.eventCount || 0) + 1; console.log(`šŸ“Š Event count now: ${watcherInfo.eventCount}`); // Create event data const eventData = { type: eventType, path: filePath, relativePath: path.relative(watcherInfo.normalizedPath, filePath), watchId, watchPath, timestamp: new Date().toISOString() }; console.log(`šŸ“¤ EMITTING EVENT: ${eventType} for ${eventData.relativePath || path.basename(filePath)}`); // Apply debouncing if (this.debounceMs > 0) { this.debounceEvent(eventData, callback); } else { this.processEvent(eventData, callback); } } debounceEvent(eventData, callback) { const debounceKey = `${eventData.watchId}:${eventData.path}:${eventData.type}`; // Clear existing timer for this event if (this.debounceTimers.has(debounceKey)) { clearTimeout(this.debounceTimers.get(debounceKey)); } // Set new timer const timer = setTimeout(() => { this.debounceTimers.delete(debounceKey); this.processEvent(eventData, callback); }, this.debounceMs); this.debounceTimers.set(debounceKey, timer); } processEvent(eventData, callback) { // Emit event on provider this.emit('fileEvent', eventData); // Call user callback if provided if (callback && typeof callback === 'function') { try { callback(eventData); } catch (error) { console.error(`āŒ Callback error for ${eventData.path}:`, error.message); } } // Log event (can be configured) const icon = this.getEventIcon(eventData.type); console.log(`${icon} ${eventData.type}: ${eventData.relativePath || eventData.path}`); } // ============================================================================ // UTILITY AND QUERY METHODS // ============================================================================ buildIgnorePattern(ignoreDotfiles, isRecursive = true, watchedPath = null) { const patterns = []; console.log(`šŸ”§ Building ignore pattern: recursive=${isRecursive}, watchedPath=${watchedPath}`); if (ignoreDotfiles) { patterns.push(/(^|[\/\\])\../); // Ignore dotfiles and dot directories } // Always ignore common system files patterns.push(/node_modules/); patterns.push(/\.git/); patterns.push(/\.DS_Store/); patterns.push(/Thumbs\.db/); // FIXED: For non-recursive, use a simpler regex-based approach if (!isRecursive && watchedPath) { const resolvedWatchedPath = path.resolve(watchedPath); console.log(`šŸŽÆ Non-recursive mode: creating ignore pattern for ${resolvedWatchedPath}`); // Create a pattern that ignores anything in subdirectories // This pattern matches: watchedPath/*/anything const nestedPattern = new RegExp( resolvedWatchedPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\\\/][^\\\\\/]+[\\\\/]' ); console.log(`šŸŽÆ Nested pattern: ${nestedPattern}`); patterns.push(nestedPattern); } console.log(`šŸ”§ Created ${patterns.length} ignore patterns`); return patterns.length > 0 ? patterns : null; } findWatcherByPath(searchPath) { const normalizedSearch = this.normalizePath(searchPath); for (const [watchId, watcherInfo] of this.watchers) { if (watcherInfo.normalizedPath === normalizedSearch || watcherInfo.path === searchPath) { return watchId; } } return null; } cleanupDebounceTimers(watchId) { const keysToDelete = []; for (const [key, timer] of this.debounceTimers) { if (key.startsWith(watchId + ':')) { clearTimeout(timer); keysToDelete.push(key); } } keysToDelete.forEach(key => this.debounceTimers.delete(key)); } getEventIcon(eventType) { const icons = { add: 'šŸ“„', change: 'āœļø', unlink: 'šŸ—‘ļø', addDir: 'šŸ“', unlinkDir: 'šŸ—‚ļø' }; return icons[eventType] || 'šŸ“‹'; } // ============================================================================ // INFORMATION AND STATUS METHODS // ============================================================================ listActiveWatchers() { const watchers = []; for (const [watchId, watcherInfo] of this.watchers) { watchers.push({ watchId, path: watcherInfo.path, recursive: watcherInfo.options.recursive, events: watcherInfo.options.events, startedAt: watcherInfo.startedAt, eventCount: watcherInfo.eventCount || 0, // FIXED: Ensure default value duration: Date.now() - new Date(watcherInfo.startedAt).getTime() }); } return watchers; } getWatcherInfo(watchIdOrPath) { let watchId; if (this.watchers.has(watchIdOrPath)) { watchId = watchIdOrPath; } else { watchId = this.findWatcherByPath(watchIdOrPath); if (!watchId) { throw new Error(`Watcher not found: ${watchIdOrPath}`); } } const watcherInfo = this.watchers.get(watchId); if (!watcherInfo) { throw new Error(`Watcher not found: ${watchId}`); } return { watchId, path: watcherInfo.path, normalizedPath: watcherInfo.normalizedPath, options: watcherInfo.options, startedAt: watcherInfo.startedAt, eventCount: watcherInfo.eventCount || 0, // FIXED: Ensure default value duration: Date.now() - new Date(watcherInfo.startedAt).getTime(), hasCallback: !!watcherInfo.callback }; } getWatchingStatus() { return { enabled: this.enabled, activeWatchers: this.watchers.size, maxWatchers: this.maxWatchers, totalEvents: Array.from(this.watchers.values()).reduce((sum, w) => sum + (w.eventCount || 0), 0), pendingDebounces: this.debounceTimers.size, config: { recursive: this.recursive, ignoreDotfiles: this.ignoreDotfiles, debounceMs: this.debounceMs } }; } // ============================================================================ // ERROR HANDLING HELPERS // ============================================================================ isPathNotFoundError(error) { return error.code === 'ENOENT' || error.message.includes('not found') || error.message.includes('no such file'); } isPermissionError(error) { return error.code === 'EACCES' || error.code === 'EPERM' || error.message.includes('permission denied'); } isWatchingError(error) { return error.message.includes('watch') || error.message.includes('EMFILE') || error.message.includes('ENOSPC'); } // ============================================================================ // CLEANUP AND SHUTDOWN // ============================================================================ async shutdown() { console.log('šŸ”„ Shutting down watch provider...'); try { const result = await this.stopAllWatching(); // Remove all event listeners this.removeAllListeners(); console.log('āœ… Watch provider shutdown complete'); return result; } catch (error) { console.error('āŒ Error during watch provider shutdown:', error.message); throw error; } } // ============================================================================ // UTILITY METHODS // ============================================================================ formatBytes(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } formatDuration(milliseconds) { const seconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { return `${hours}h ${minutes % 60}m ${seconds % 60}s`; } else if (minutes > 0) { return `${minutes}m ${seconds % 60}s`; } else { return `${seconds}s`; } } } export { WatchProvider };