UNPKG

@allurereport/directory-watcher

Version:

File system watcher for directories

278 lines (277 loc) 9.45 kB
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); }); };