Skip to main content
Glama

tbls MCP Server

by yhosok
file-watcher.ts10.1 kB
import { FSWatcher, watch, promises as fs } from 'fs'; import { safeExecuteAsync } from '../utils/result'; /** * File change callback function signature */ export type FileChangeCallback = ( filePath: string, newMtime: Date, oldMtime: Date ) => void; /** * Error callback function signature */ export type ErrorCallback = (error: Error) => void; /** * Directory watching options */ export interface WatchOptions { /** Whether to watch subdirectories recursively */ recursive?: boolean; /** Function to filter which files should trigger callbacks */ fileFilter?: (filename: string) => boolean; } /** * Internal watcher state */ interface WatcherState { /** File system watcher instance */ watcher: FSWatcher; /** Last known modification time */ lastMtime: Date; /** Change callback function */ changeCallback: FileChangeCallback; /** Error callback function */ errorCallback?: ErrorCallback; /** Debounce timeout ID */ debounceTimeout?: NodeJS.Timeout; /** Whether this is a directory watcher */ isDirectory: boolean; /** File filter for directory watching */ fileFilter?: (filename: string) => boolean; } /** * File system monitoring class with change detection and throttling * * Features: * - Individual file watching * - Directory watching with recursive option * - File filtering for directory watching * - Change event throttling/debouncing (100ms default) * - Multiple watcher management * - Error handling with callbacks * - Automatic cleanup on destroy */ export class FileWatcher { private watchers = new Map<string, WatcherState>(); private readonly debounceDelayMs = 100; /** * Starts watching a file for changes * * @param filePath - Path to the file to watch * @param changeCallback - Callback when file changes * @param errorCallback - Optional callback for errors */ async watchFile( filePath: string, changeCallback: FileChangeCallback, errorCallback?: ErrorCallback ): Promise<void> { if (!filePath || filePath.trim().length === 0) { throw new Error('Invalid file path'); } // Get initial modification time const statResult = await safeExecuteAsync( async () => await fs.stat(filePath), 'Failed to get initial file stats' ); if (statResult.isErr()) { if (errorCallback) { // Extract the original error from the wrapped error const originalError = statResult.error.message.includes(':') ? new Error(statResult.error.message.split(': ').slice(1).join(': ')) : statResult.error; errorCallback(originalError); } return; } const stats = statResult.value; if (!stats.isFile()) { const error = new Error(`Path is not a file: ${filePath}`); if (errorCallback) { errorCallback(error); } return; } // Stop any existing watcher for this path this.stopWatching(filePath); // Create new watcher const watcher = watch(filePath); // Set up change event handling watcher.on('change', (eventType, filename) => { this.handleFileChange(filePath, eventType, filename?.toString() ?? null); }); // Set up error handling watcher.on('error', (error) => { if (errorCallback) { errorCallback(error); } }); // Store watcher state this.watchers.set(filePath, { watcher, lastMtime: stats.mtime, changeCallback, errorCallback, isDirectory: false, }); } /** * Starts watching a directory for changes * * @param dirPath - Path to the directory to watch * @param changeCallback - Callback when directory or files change * @param errorCallback - Optional callback for errors * @param options - Watch options including recursive and file filter */ async watchDirectory( dirPath: string, changeCallback: FileChangeCallback, errorCallback?: ErrorCallback, options: WatchOptions = {} ): Promise<void> { if (!dirPath || dirPath.trim().length === 0) { throw new Error('Invalid directory path'); } // Get initial modification time const statResult = await safeExecuteAsync( async () => await fs.stat(dirPath), 'Failed to get initial directory stats' ); if (statResult.isErr()) { if (errorCallback) { // Extract the original error from the wrapped error const originalError = statResult.error.message.includes(':') ? new Error(statResult.error.message.split(': ').slice(1).join(': ')) : statResult.error; errorCallback(originalError); } return; } const stats = statResult.value; if (!stats.isDirectory()) { const error = new Error(`Path is not a directory: ${dirPath}`); if (errorCallback) { errorCallback(error); } return; } // Stop any existing watcher for this path this.stopWatching(dirPath); // Create new watcher with recursive option (default to true) const recursive = options.recursive !== false; // Default to true const watcher = recursive ? watch(dirPath, { recursive: true }) : watch(dirPath); // Set up change event handling watcher.on('change', (eventType, filename) => { this.handleDirectoryChange( dirPath, eventType, filename?.toString() ?? null, options.fileFilter ); }); // Set up error handling watcher.on('error', (error) => { if (errorCallback) { errorCallback(error); } }); // Store watcher state this.watchers.set(dirPath, { watcher, lastMtime: stats.mtime, changeCallback, errorCallback, isDirectory: true, fileFilter: options.fileFilter, }); } /** * Stops watching a specific file or directory * * @param path - Path to stop watching */ stopWatching(path: string): void { const watcherState = this.watchers.get(path); if (watcherState) { // Clear any pending debounce timeout if (watcherState.debounceTimeout) { clearTimeout(watcherState.debounceTimeout); } // Close the watcher watcherState.watcher.close(); // Remove from map this.watchers.delete(path); } } /** * Stops all watchers and cleans up resources */ destroy(): void { for (const [path] of this.watchers) { this.stopWatching(path); } } /** * Handles file change events with debouncing */ private handleFileChange( filePath: string, _eventType: string, _filename: string | null ): void { const watcherState = this.watchers.get(filePath); if (!watcherState) { return; } // Clear any existing timeout if (watcherState.debounceTimeout) { clearTimeout(watcherState.debounceTimeout); } // Set up debounced change detection watcherState.debounceTimeout = setTimeout(async () => { await this.checkFileChange(filePath, watcherState); }, this.debounceDelayMs); } /** * Handles directory change events with debouncing and filtering */ private handleDirectoryChange( dirPath: string, _eventType: string, filename: string | null, fileFilter?: (filename: string) => boolean ): void { const watcherState = this.watchers.get(dirPath); if (!watcherState) { return; } // Apply file filter if provided and filename is available if (fileFilter && filename && !fileFilter(filename)) { return; } // Clear any existing timeout if (watcherState.debounceTimeout) { clearTimeout(watcherState.debounceTimeout); } // Set up debounced change detection watcherState.debounceTimeout = setTimeout(async () => { await this.checkDirectoryChange(dirPath, watcherState); }, this.debounceDelayMs); } /** * Checks if file has actually changed by comparing modification times */ private async checkFileChange( filePath: string, watcherState: WatcherState ): Promise<void> { const statResult = await safeExecuteAsync( async () => await fs.stat(filePath), 'Failed to check file modification time' ); if (statResult.isErr()) { if (watcherState.errorCallback) { watcherState.errorCallback(statResult.error); } return; } const stats = statResult.value; if (!stats.isFile()) { if (watcherState.errorCallback) { watcherState.errorCallback( new Error(`Path is no longer a file: ${filePath}`) ); } return; } // Check if modification time has actually changed if (stats.mtime.getTime() !== watcherState.lastMtime.getTime()) { const oldMtime = watcherState.lastMtime; watcherState.lastMtime = stats.mtime; // Trigger callback with old and new modification times watcherState.changeCallback(filePath, stats.mtime, oldMtime); } } /** * Checks if directory has actually changed by comparing modification times */ private async checkDirectoryChange( dirPath: string, watcherState: WatcherState ): Promise<void> { const statResult = await safeExecuteAsync( async () => await fs.stat(dirPath), 'Failed to check directory modification time' ); if (statResult.isErr()) { if (watcherState.errorCallback) { watcherState.errorCallback(statResult.error); } return; } const stats = statResult.value; if (!stats.isDirectory()) { if (watcherState.errorCallback) { watcherState.errorCallback( new Error(`Path is no longer a directory: ${dirPath}`) ); } return; } // Check if modification time has actually changed if (stats.mtime.getTime() !== watcherState.lastMtime.getTime()) { const oldMtime = watcherState.lastMtime; watcherState.lastMtime = stats.mtime; // Trigger callback with old and new modification times watcherState.changeCallback(dirPath, stats.mtime, oldMtime); } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/yhosok/tbls-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server