@jantimon/glob-watch
Version:
High-performance file watcher with multiple backend options
279 lines (246 loc) • 7.42 kB
text/typescript
import {
WatchCallback,
WatchOptions,
DestroyFunction,
FileChanges,
FileInfo,
} from "../types.ts";
import path from "node:path";
import type {
Client,
Expression,
FileChange,
SubscriptionConfig,
SubscriptionResponse,
} from "fb-watchman";
import { logError } from "../errors.ts";
export const watch = async (
patterns: string | string[],
callback: WatchCallback,
options: WatchOptions = {},
): Promise<DestroyFunction> => {
let client: Client;
try {
client = await createWatchmanClient();
} catch (error) {
logError("Failed to create Watchman client", error);
// Fall back to native watcher
const { watch } = await import("./native.ts");
return watch(patterns, callback, options);
}
const cwd = options.cwd || process.cwd();
const patternArray = Array.isArray(patterns) ? patterns : [patterns];
// Track existing files
const existingFiles = new Map<string, FileInfo>();
// Get the watch root
const watchProjectRoot = await new Promise<string>((resolve, reject) => {
client.command(["watch-project", cwd], (error, resp) => {
if (error) {
reject(error);
return;
}
resolve(resp.watch);
});
});
// Helper function to create a file info object
const createFileInfo = (file: FileChange, rootPath: string): FileInfo => {
const relativePath = file.name;
const info: FileInfo = {
name: path.basename(relativePath),
path: options.absolute
? path.resolve(rootPath, relativePath)
: relativePath,
};
if ("exists" in file) {
info.exists = file.exists;
}
if ("type" in file) {
info.type = file.type;
}
// Add requested fields
if (options.fields) {
if (options.fields.includes("size") && "size" in file) {
info.size = file.size;
}
if (options.fields.includes("mtime") && "mtime_ms" in file) {
info.mtime =
typeof file.mtime_ms === "number" ? file.mtime_ms : undefined;
}
}
return info;
};
// Setup subscription
const subscriptionName =
"glob-watch-" + Math.random().toString(36).substring(2, 15);
// Create file filters based on options
const fileFilters: Expression[] = [];
// Only files or only directories filter
if (options.onlyDirectories) {
fileFilters.push(["type", "d"]);
} else if (options.onlyFiles) {
fileFilters.push(["type", "f"]);
}
// Process patterns into watchman expressions
const matchExpressions = patternArray.map(
(pattern): Expression =>
[
"match",
pattern,
"wholename",
{ includedotfiles: options.dot },
] as const as any,
);
// Process ignore patterns
const ignoreExpressions: Expression[] = [];
if (options.ignore) {
const ignorePatterns = Array.isArray(options.ignore)
? options.ignore
: [options.ignore];
for (const pattern of ignorePatterns) {
ignoreExpressions.push(["not", ["match", pattern, "wholename"]]);
}
}
// Build the final expression
let expression: Expression = [
"allof",
...fileFilters,
["anyof", ...matchExpressions],
...ignoreExpressions,
];
// Determine fields to request from watchman
const requestFields: (keyof FileChange)[] = ["name", "exists", "type"];
if (options.fields) {
if (options.fields.includes("size")) {
requestFields.push("size");
}
if (options.fields.includes("mtime")) {
requestFields.push("mtime_ms");
}
}
// Setup the subscription
await new Promise<void>((resolve, reject) => {
client.command(
[
"subscribe",
watchProjectRoot,
subscriptionName,
{
expression: expression,
fields: requestFields,
relative_root: path.relative(watchProjectRoot, cwd),
} satisfies SubscriptionConfig,
],
(error) => {
if (error) {
reject(error);
return;
}
resolve();
},
);
});
// Process initial file list
await new Promise<void>((resolve) => {
const initialRun = (resp: SubscriptionResponse) => {
if (resp.subscription !== subscriptionName) {
return;
}
client.removeListener("subscription", initialRun);
const changes: FileChanges = {
added: new Map<string, FileInfo>(),
deleted: new Map<string, FileInfo>(),
changed: new Map<string, FileInfo>(),
};
// Process each file
resp.files.forEach((file) => {
const fileInfo = createFileInfo(file, cwd);
// Add to existing files map
if (file.exists !== false) {
existingFiles.set(fileInfo.path, fileInfo);
changes.added.set(fileInfo.path, fileInfo);
}
});
// Call the callback with initial files
callback(changes);
resolve();
};
// Set up initial run handler
client.on("subscription", initialRun);
});
// Set up ongoing change handler
client.on("subscription", (resp) => {
if (resp.subscription !== subscriptionName) {
return;
}
const changes: FileChanges = {
added: new Map<string, FileInfo>(),
deleted: new Map<string, FileInfo>(),
changed: new Map<string, FileInfo>(),
};
// Process each file change
resp.files.forEach((file) => {
const fileInfo = createFileInfo(file, cwd);
const fileExists = file.exists !== false;
const existingFile = existingFiles.get(fileInfo.path);
if (file.type === "l") {
// Ignore symbolic links if followSymbolicLinks is explicitly false
return;
}
if (!existingFile && fileExists) {
// New file
existingFiles.set(fileInfo.path, fileInfo);
changes.added.set(fileInfo.path, fileInfo);
} else if (existingFile && !fileExists) {
// Deleted file
existingFiles.delete(fileInfo.path);
changes.deleted.set(fileInfo.path, fileInfo);
} else if (existingFile && fileExists) {
// Changed file
existingFiles.set(fileInfo.path, fileInfo);
changes.changed.set(fileInfo.path, fileInfo);
}
});
// Only call callback if there are changes
if (
changes.added.size > 0 ||
changes.deleted.size > 0 ||
changes.changed.size > 0
) {
callback(changes);
}
});
// Return destroy function
return () => {
client.end();
};
};
/**
* Create a Watchman client and verify its capabilities
*/
async function createWatchmanClient(): Promise<Client> {
// Import fb-watchman dynamically to handle the case when it's not installed
const { default: watchman } = await import("fb-watchman");
const client = new watchman.Client();
return new Promise<Client>((resolve, reject) => {
const errorHandler = (error: Error) => {
client.removeListener("error", errorHandler);
client.removeListener("connect", connectHandler);
reject(error);
};
const connectHandler = () => {
client.removeListener("error", errorHandler);
client.removeListener("connect", connectHandler);
resolve(client);
};
client.on("error", errorHandler);
client.on("connect", connectHandler);
client.capabilityCheck(
{ optional: [], required: ["relative_root"] },
(error: Error | null) => {
if (error) {
errorHandler(error);
}
},
);
});
}