@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
JavaScript
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 };