@allurereport/directory-watcher
Version:
File system watcher for directories
278 lines (277 loc) • 9.45 kB
JavaScript
import console from "node:console";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { opendir, realpath, stat } from "node:fs/promises";
import { join } from "node:path";
import { setImmediate, setTimeout } from "node:timers/promises";
export const isFileNotFoundError = (e) => e instanceof Error && "code" in e && e.code === "ENOENT";
export const difference = (before, after) => {
const added = new Set();
const deleted = new Set(before);
for (const value of after) {
if (!deleted.has(value)) {
added.add(value);
}
else {
deleted.delete(value);
}
}
return [added, deleted];
};
export const findMatching = async (watchDirectory, existingResults, match, maximumDepth = 5) => {
try {
const dir = await opendir(watchDirectory);
for await (const dirent of dir) {
const path = join(dirent.parentPath ?? dirent.path, dirent.name);
if (dirent.name.at(0) === "." || dirent.name === "node_modules") {
continue;
}
if (existingResults.has(path)) {
continue;
}
if (match(dirent)) {
existingResults.add(path);
continue;
}
if (dirent.isDirectory() && maximumDepth > 0) {
await findMatching(path, existingResults, match, maximumDepth - 1);
}
}
}
catch (e) {
if (isFileNotFoundError(e)) {
existingResults.clear();
return;
}
console.error("can't read directory", e);
}
};
const findFiles = async (watchDirectory, existingResults, onNewFile, recursive) => {
try {
const dir = await opendir(watchDirectory, { recursive });
for await (const dirent of dir) {
if (dirent.isDirectory()) {
continue;
}
const path = join(dirent.parentPath ?? dirent.path, dirent.name);
if (existingResults.has(path)) {
continue;
}
try {
await onNewFile(path, dirent);
existingResults.add(path);
}
catch (e) {
if (!isFileNotFoundError(e)) {
console.error("can't process file", path, e);
}
}
}
}
catch (e) {
if (isFileNotFoundError(e)) {
existingResults.clear();
return;
}
console.error("can't read directory", e);
}
};
const singleIteration = async (callback, ...ac) => {
return setImmediate(undefined, { signal: AbortSignal.any(ac.map((c) => c.signal)) })
.then(() => callback())
.catch((err) => {
if (err.name === "AbortError") {
return;
}
console.error("can't execute callback", err);
});
};
const repeatedIteration = async (indexInterval, callback, ...ac) => {
return setTimeout(indexInterval, undefined, { signal: AbortSignal.any(ac.map((c) => c.signal)) })
.then(() => callback())
.then(() => repeatedIteration(indexInterval, callback, ...ac));
};
const noop = async () => { };
const watch = (initialCallback, iterationCallback, doneCallback, options = {}) => {
const { indexDelay = 300, abortController: haltAc = new AbortController() } = options;
const gracefulShutdownAc = new AbortController();
const init = singleIteration(initialCallback, haltAc);
const timeout = init
.then(() => repeatedIteration(indexDelay, iterationCallback, haltAc, gracefulShutdownAc))
.catch((err) => {
if (err.name === "AbortError") {
return;
}
console.error("can't execute callback", err);
})
.then(() => singleIteration(doneCallback, haltAc));
return {
abort: async (immediately = false) => {
if (immediately) {
haltAc.abort();
}
else {
gracefulShutdownAc.abort();
}
await timeout;
},
initialScan: async () => {
await init;
},
watchEnd: async () => {
await timeout;
},
};
};
export const newFilesInDirectoryWatcher = (directory, onNewFile, options = {}) => {
const { recursive = true, ignoreInitial = false, ...rest } = options;
const indexedFiles = new Set();
const initialCallback = async () => {
await findFiles(directory, indexedFiles, ignoreInitial ? noop : onNewFile, recursive);
};
const iterationCallback = async () => {
await findFiles(directory, indexedFiles, onNewFile, recursive);
};
return watch(initialCallback, iterationCallback, iterationCallback, rest);
};
export const allureResultsDirectoriesWatcher = (directory, update, options = {}) => {
let previousAllureResults = new Set();
const callback = async () => {
const currentAllureResults = new Set();
await findMatching(directory, currentAllureResults, (dirent) => dirent.isDirectory() && dirent.name === "allure-results");
const [added, deleted] = difference(previousAllureResults, currentAllureResults);
await update(added, deleted);
previousAllureResults = currentAllureResults;
};
return watch(callback, callback, callback, options);
};
const calculateInfo = async (file) => {
try {
const stats = await stat(file);
if (!stats.isFile()) {
return null;
}
const size = stats.size;
const mtimeMs = stats.mtimeMs;
const timestamp = Date.now();
return { size, mtimeMs, timestamp };
}
catch (e) {
if (isFileNotFoundError(e)) {
return null;
}
throw e;
}
};
const waitUntilFileStopChanging = async (file, info, options) => {
const start = Date.now();
const { maxWait, minWait } = options;
const prev = { ...info };
while (true) {
const now = Date.now();
if (now - start > maxWait) {
return false;
}
const sinceChange = now - prev.timestamp;
if (sinceChange < minWait) {
await setTimeout(Math.min(0, maxWait, minWait - sinceChange + 1));
}
const current = await calculateInfo(file);
if (!current) {
return false;
}
const sameSize = current.size === prev.size;
const sameMtimeMs = current.mtimeMs === prev.mtimeMs;
if (sameSize && sameMtimeMs) {
return true;
}
prev.size = current.size;
prev.mtimeMs = current.mtimeMs;
prev.timestamp = current.timestamp;
}
};
export const delayedFileProcessingWatcher = (processFile, options = {}) => {
const { minProcessingDelay = 200, maxProcessingDelay = 10000, ...rest } = options;
const files = new Map();
const success = new Set();
const errors = new Set();
const addFile = async (file) => {
try {
const filePath = await realpath(file, { encoding: "utf-8" });
const info = await calculateInfo(filePath);
if (!info) {
return;
}
files.set(filePath, info);
}
catch (e) {
if (isFileNotFoundError(e)) {
return;
}
throw e;
}
};
const callback = async () => {
for (const [file, info] of files) {
const now = Date.now();
const sinceChange = now - info.timestamp;
if (sinceChange < minProcessingDelay) {
continue;
}
try {
await processFile(file);
}
catch (e) {
if (!isFileNotFoundError(e)) {
console.log(`could not process file ${file}`, e);
}
errors.add(file);
}
files.delete(file);
success.add(file);
}
};
const doneCallback = async () => {
for (const [file, info] of files) {
const waitedSuccessfully = await waitUntilFileStopChanging(file, info, {
minWait: minProcessingDelay,
maxWait: maxProcessingDelay,
});
if (waitedSuccessfully) {
try {
await processFile(file);
}
catch (e) {
if (!isFileNotFoundError(e)) {
console.log(`could not process file ${file}`, e);
}
errors.add(file);
}
files.delete(file);
success.add(file);
}
else {
console.error(`can't process file ${file}: file deleted or contents keep changing`);
errors.add(file);
}
}
};
const watcher = watch(callback, callback, doneCallback, rest);
return {
...watcher,
addFile,
};
};
export const md5File = async (path) => {
return new Promise((resolve, reject) => {
const output = createHash("md5");
const input = createReadStream(path);
input.on("error", (err) => {
reject(err);
});
output.once("readable", () => {
resolve(output.read().toString("hex"));
});
input.pipe(output);
});
};