@jantimon/glob-watch
Version:
High-performance file watcher with multiple backend options
239 lines (208 loc) • 6.64 kB
text/typescript
import {
WatchCallback,
WatchOptions,
DestroyFunction,
FileChanges,
FileInfo,
} from "../types.ts";
import path from "node:path";
import fs from "node:fs";
import { watch as fastGlobWatch } from "./fast-glob.ts";
/**
* Watch for file changes using Node.js native fs.watch API
* with fast-glob for initial file discovery as fallback if watchman is not available
*/
export async function watch(
patterns: string | string[],
callback: WatchCallback,
options: WatchOptions = {},
): Promise<DestroyFunction> {
// State variables (captured in closure)
const watchers = new Set<fs.FSWatcher>();
const fileInfoMap = new Map<string, FileInfo>();
const watchDirs = new Set<string>();
const cwd = options.cwd || process.cwd();
// Create ignore matcher
const ignoreMatcher = await createIgnoreMatcher(options.ignore);
// Get initial file list using fast-glob
const initialScan = await new Promise<FileChanges>((resolve) => {
// Create a temporary callback that captures the initial file list
const tempCallback = (changes: FileChanges) => {
resolve(changes);
};
// Use fast-glob watcher to get initial file list
fastGlobWatch(patterns, tempCallback, options);
});
// Store initial files in our map
for (const [filePath, fileInfo] of initialScan.added) {
fileInfoMap.set(filePath, fileInfo);
}
// Call callback with initial files
await callback(initialScan);
// Extract directories from file paths
const watchDirectories = new Set<string>();
for (const filePath of fileInfoMap.keys()) {
const absPath = options.absolute ? filePath : path.resolve(cwd, filePath);
const dirPath = path.dirname(absPath);
watchDirectories.add(dirPath);
}
// Create directory watchers
for (const dirPath of watchDirectories) {
try {
setupDirectoryWatcher(dirPath);
} catch (error) {
console.error(`Error watching directory ${dirPath}:`, error);
}
}
/**
* Handle file change events
*/
function handleFileChange(filePath: string, eventType: string): void {
const absolutePath = path.resolve(cwd, filePath);
// Skip hidden files if dot option is not enabled
if (!options.dot && path.basename(filePath).startsWith(".")) {
return;
}
// Skip ignored files
if (ignoreMatcher(filePath)) {
return;
}
const changes: FileChanges = {
added: new Map<string, FileInfo>(),
deleted: new Map<string, FileInfo>(),
changed: new Map<string, FileInfo>(),
};
try {
// Check if file exists
const exists = fs.existsSync(absolutePath);
const isNewFile = !fileInfoMap.has(filePath);
const stats = exists && fs.statSync(absolutePath);
if (stats) {
const isDir = stats.isDirectory();
// Create file info
const fileInfo: FileInfo = {
name: path.basename(filePath),
path: filePath,
exists: true,
};
// If it's a directory, watch it too
if (isDir && !watchDirs.has(filePath)) {
setupDirectoryWatcher(filePath);
}
if (isDir && options.onlyFiles) {
// Skip directories if onlyFiles option is enabled
return;
}
// Add requested fields
if (options.fields) {
if (options.fields.includes("type")) {
fileInfo.type = isDir ? "d" : "f";
}
if (options.fields.includes("size")) {
fileInfo.size = stats.size;
}
if (options.fields.includes("mtime")) {
fileInfo.mtime = stats.mtimeMs;
}
}
if (isNewFile) {
// New file
fileInfoMap.set(filePath, fileInfo);
changes.added.set(filePath, fileInfo);
} else {
// Changed file
fileInfoMap.set(filePath, fileInfo);
changes.changed.set(filePath, fileInfo);
}
} else if (!isNewFile) {
// File was deleted
const fileInfo = fileInfoMap.get(filePath)!;
fileInfoMap.delete(filePath);
changes.deleted.set(filePath, fileInfo);
}
// Notify callback if there are changes
if (
changes.added.size > 0 ||
changes.changed.size > 0 ||
changes.deleted.size > 0
) {
callback(changes);
}
} catch (error) {
console.error(`Error handling file change for ${filePath}:`, error);
}
}
/**
* Set up watcher for a directory
*/
function setupDirectoryWatcher(dirPath: string): void {
// Skip if already watching this directory
if (watchDirs.has(dirPath)) {
return;
}
try {
const watcher = fs.watch(
dirPath,
{ recursive: false },
(eventType, filename) => {
if (!filename) return;
const fullPath = path.join(dirPath, filename);
const filePath = options.absolute
? fullPath
: path.relative(cwd, fullPath);
handleFileChange(filePath, eventType);
},
);
watchers.add(watcher);
watchDirs.add(dirPath);
// Also watch subdirectories if they exist
watchSubdirectories(dirPath);
} catch (error) {
console.error(`Failed to watch directory ${dirPath}:`, error);
}
}
/**
* Watch subdirectories recursively
*/
function watchSubdirectories(dirPath: string): void {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && !entry.isSymbolicLink()) {
// Only watch non-symlink directories
const subDirPath = path.join(dirPath, entry.name);
setupDirectoryWatcher(subDirPath);
}
}
} catch (error) {
console.error(`Error reading directory ${dirPath}:`, error);
}
}
/**
* Clean up all watchers
*/
function destroy(): void {
for (const watcher of watchers) {
watcher.close();
}
watchers.clear();
watchDirs.clear();
fileInfoMap.clear();
}
// Return destroy function
return destroy;
}
/**
* Creates a matcher function for ignore patterns
*/
async function createIgnoreMatcher(ignore?: string | string[]) {
if (!ignore) {
return () => false;
}
const micromatch = await import("micromatch");
const patterns = Array.isArray(ignore) ? ignore : [ignore];
// Precompile matchers for each pattern
const matchers = patterns.map((pattern) => micromatch.matcher(pattern));
// Create a single matcher function that checks all patterns
return (filePath: string) => matchers.some((matcher) => matcher(filePath));
}