| /* | ||
| MIT License http://www.opensource.org/licenses/mit-license.php | ||
| Author Haijie Xie @hai-x | ||
| */ | ||
| "use strict"; | ||
| // Based on https://github.com/fitzgen/glob-to-regexp (MIT) | ||
| // Specialized for watchpack: `extended` and `globstar` always enabled. | ||
| // Returns the regexp source without `^`/`$` anchors. | ||
| const CC_EXCLAMATION = 33; // "!" | ||
| const CC_DOLLAR = 36; // "$" | ||
| const CC_LEFT_PARENTHESIS = 40; // "(" | ||
| const CC_RIGHT_PARENTHESIS = 41; // ")" | ||
| const CC_ASTERISK = 42; // "*" | ||
| const CC_PLUS = 43; // "+" | ||
| const CC_COMMA = 44; // "," | ||
| const CC_DOT = 46; // "." | ||
| const CC_SLASH = 47; // "/" | ||
| const CC_EQUAL = 61; // "=" | ||
| const CC_QUESTION_MARK = 63; // "?" | ||
| const CC_LEFT_BRACKET = 91; // "[" | ||
| const CC_RIGHT_BRACKET = 93; // "]" | ||
| const CC_CARET = 94; // "^" | ||
| const CC_LEFT_BRACE = 123; // "{" | ||
| const CC_PIPE = 124; // "|" | ||
| const CC_RIGHT_BRACE = 125; // "}" | ||
| /** | ||
| * @param {string} glob glob pattern | ||
| * @returns {string} regexp source without anchors | ||
| */ | ||
| module.exports = (glob) => { | ||
| if (typeof glob !== "string") { | ||
| throw new TypeError("Expected a string"); | ||
| } | ||
| const len = glob.length; | ||
| let reStr = ""; | ||
| let inGroup = false; | ||
| // Start of the current run of literal characters, copied with one slice | ||
| let literalStart = 0; | ||
| for (let i = 0; i < len; i++) { | ||
| const cc = glob.charCodeAt(i); | ||
| const tokenStart = i; | ||
| let mapped; | ||
| switch (cc) { | ||
| case CC_SLASH: | ||
| mapped = "\\/"; | ||
| break; | ||
| case CC_DOLLAR: | ||
| mapped = "\\$"; | ||
| break; | ||
| case CC_CARET: | ||
| mapped = "\\^"; | ||
| break; | ||
| case CC_PLUS: | ||
| mapped = "\\+"; | ||
| break; | ||
| case CC_DOT: | ||
| mapped = "\\."; | ||
| break; | ||
| case CC_LEFT_PARENTHESIS: | ||
| mapped = "\\("; | ||
| break; | ||
| case CC_RIGHT_PARENTHESIS: | ||
| mapped = "\\)"; | ||
| break; | ||
| case CC_EQUAL: | ||
| mapped = "\\="; | ||
| break; | ||
| case CC_EXCLAMATION: | ||
| mapped = "\\!"; | ||
| break; | ||
| case CC_PIPE: | ||
| mapped = "\\|"; | ||
| break; | ||
| case CC_QUESTION_MARK: | ||
| mapped = "."; | ||
| break; | ||
| case CC_LEFT_BRACKET: | ||
| mapped = "["; | ||
| break; | ||
| case CC_RIGHT_BRACKET: | ||
| mapped = "]"; | ||
| break; | ||
| case CC_LEFT_BRACE: | ||
| inGroup = true; | ||
| mapped = "("; | ||
| break; | ||
| case CC_RIGHT_BRACE: | ||
| inGroup = false; | ||
| mapped = ")"; | ||
| break; | ||
| case CC_COMMA: | ||
| mapped = inGroup ? "|" : "\\,"; | ||
| break; | ||
| case CC_ASTERISK: { | ||
| const atStart = i === 0; | ||
| const afterSlash = !atStart && glob.charCodeAt(i - 1) === CC_SLASH; | ||
| let starCount = 1; | ||
| while (i + 1 < len && glob.charCodeAt(i + 1) === CC_ASTERISK) { | ||
| starCount++; | ||
| i++; | ||
| } | ||
| const atEnd = i + 1 === len; | ||
| const beforeSlash = !atEnd && glob.charCodeAt(i + 1) === CC_SLASH; | ||
| if ( | ||
| starCount > 1 && | ||
| (atStart || afterSlash) && | ||
| (atEnd || beforeSlash) | ||
| ) { | ||
| // Globstar segment, matches zero or more path segments | ||
| mapped = "((?:[^/]*(?:\\/|$))*)"; | ||
| i++; // Move over the "/" | ||
| } else { | ||
| // Not a globstar, matches one path segment | ||
| mapped = "([^/]*)"; | ||
| } | ||
| break; | ||
| } | ||
| default: | ||
| // Literal character, extend the current run | ||
| continue; | ||
| } | ||
| if (literalStart < tokenStart) { | ||
| reStr += glob.slice(literalStart, tokenStart); | ||
| } | ||
| reStr += mapped; | ||
| literalStart = i + 1; | ||
| } | ||
| if (literalStart < len) { | ||
| reStr += literalStart === 0 ? glob : glob.slice(literalStart); | ||
| } | ||
| return reStr; | ||
| }; |
| declare function _exports(glob: string): string; | ||
| export = _exports; |
+182
-25
@@ -31,3 +31,3 @@ /* | ||
| const { WATCHPACK_POLLING } = process.env; | ||
| const { WATCHPACK_POLLING, WATCHPACK_RETRIES } = process.env; | ||
| const FORCE_POLLING = | ||
@@ -39,2 +39,17 @@ // @ts-expect-error avoid additional checks | ||
| // Number of retries (and delay between retries, in ms) when an fs operation | ||
| // returns EBUSY. EBUSY is transient on Windows when an AV scanner, indexer, | ||
| // or another process briefly locks a file; retrying avoids incorrectly | ||
| // reporting the file as removed (see webpack/watchpack#223, #44). | ||
| // | ||
| // Configurable via `WATCHPACK_RETRIES` env var: an integer >= 0, or "false" | ||
| // to disable retries entirely. Unset / invalid values fall back to 3. | ||
| const BUSY_RETRIES = (() => { | ||
| if (WATCHPACK_RETRIES === undefined) return 3; | ||
| if (WATCHPACK_RETRIES === "false") return 0; | ||
| const n = Number(WATCHPACK_RETRIES); | ||
| return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 3; | ||
| })(); | ||
| const BUSY_RETRY_DELAY = 100; | ||
| /** | ||
@@ -83,2 +98,34 @@ * @param {string} str string | ||
| /** | ||
| * Call `fs.lstat` with retries on EBUSY. Transient EBUSY errors are common | ||
| * on Windows when another process (AV scanner, indexer, editor) holds an | ||
| * open handle on the file. See webpack/watchpack#223, #44. | ||
| * | ||
| * The retry count is taken from the `WATCHPACK_RETRIES` env var (default | ||
| * 3, set to "0" or "false" to disable retrying). The hot path is a single | ||
| * `fs.lstat` call with one inline callback; the timer and the recursive | ||
| * call are only scheduled when an EBUSY is actually observed. | ||
| * @param {string} target target path | ||
| * @param {{ closed: boolean }} watcher owning watcher (checked between retries) | ||
| * @param {(err: NodeJS.ErrnoException | null, stats: import("fs").Stats) => void} callback callback | ||
| * @param {number=} remaining retries remaining (defaults to `BUSY_RETRIES`) | ||
| */ | ||
| function lstatWithRetry(target, watcher, callback, remaining = BUSY_RETRIES) { | ||
| fs.lstat(target, (err, stats) => { | ||
| if ( | ||
| err && | ||
| /** @type {NodeJS.ErrnoException} */ (err).code === "EBUSY" && | ||
| remaining > 0 && | ||
| !watcher.closed | ||
| ) { | ||
| setTimeout( | ||
| () => lstatWithRetry(target, watcher, callback, remaining - 1), | ||
| BUSY_RETRY_DELAY, | ||
| ); | ||
| return; | ||
| } | ||
| callback(err, stats); | ||
| }); | ||
| } | ||
| /** | ||
| * @typedef {object} FileWatcherEvents | ||
@@ -173,2 +220,4 @@ * @property {(type: EventType) => void} initial-missing initial missing event | ||
| this.directories = new Map(); | ||
| /** @type {Map<string, Watcher<FileWatcherEvents>>} */ | ||
| this._symlinkTargetWatchers = new Map(); | ||
| this.lastWatchEvent = 0; | ||
@@ -434,2 +483,6 @@ this.initialScan = true; | ||
| } | ||
| for (const w of this._symlinkTargetWatchers.values()) { | ||
| w.close(); | ||
| } | ||
| this._symlinkTargetWatchers.clear(); | ||
| } | ||
@@ -558,3 +611,3 @@ } | ||
| this._activeEvents.set(filename, false); | ||
| fs.lstat(target, (err, stats) => { | ||
| lstatWithRetry(target, this, (err, stats) => { | ||
| if (this.closed) return; | ||
@@ -568,2 +621,5 @@ if (this._activeEvents.get(filename) === true) { | ||
| // EPERM happens when the containing directory doesn't exist | ||
| // EBUSY happens when another process has the file locked (e.g. | ||
| // Windows AV scanner). lstatWithRetry already retried before | ||
| // giving up here. | ||
| if (err) { | ||
@@ -585,2 +641,9 @@ if ( | ||
| if (!stats) { | ||
| // On EBUSY we keep the tracked state: the file is likely still | ||
| // there and a later event or scan will reconcile. Emitting a | ||
| // remove here would make the watch appear to stop after a | ||
| // transient lock (webpack/watchpack#223, #44). | ||
| if (err && err.code === "EBUSY" && BUSY_RETRIES > 0) { | ||
| return; | ||
| } | ||
| this.setMissing(target, false, eventType); | ||
@@ -735,3 +798,16 @@ } else if (stats.isDirectory()) { | ||
| if (err) { | ||
| if (err.code === "ENOENT" || err.code === "EPERM") { | ||
| // Mirror the lstat error handling below: treat permission / | ||
| // invalid-argument / no-device errors on the directory itself | ||
| // as removed rather than logging "Watchpack Error (initial | ||
| // scan)". These surface for unreadable mounts (WSL `/mnt/c`, | ||
| // fuse mounts), unmounted devices (`/efi`), and libuv's | ||
| // post-Node 22.17 `EINVAL` on protected Windows paths | ||
| // (see #187). | ||
| if ( | ||
| err.code === "ENOENT" || | ||
| err.code === "EPERM" || | ||
| err.code === "EACCES" || | ||
| err.code === "ENODEV" || | ||
| (err.code === "EINVAL" && IS_WIN) | ||
| ) { | ||
| this.onDirectoryRemoved("scan readdir failed"); | ||
@@ -813,3 +889,3 @@ } else { | ||
| for (const itemPath of itemPaths) { | ||
| fs.lstat(itemPath, (err2, stats) => { | ||
| lstatWithRetry(itemPath, this, (err2, stats) => { | ||
| if (this.closed) return; | ||
@@ -822,6 +898,18 @@ if (err2) { | ||
| err2.code === "EBUSY" || | ||
| err2.code === "ENODEV" || | ||
| // TODO https://github.com/libuv/libuv/pull/4566 | ||
| (err2.code === "EINVAL" && IS_WIN) | ||
| ) { | ||
| this.setMissing(itemPath, initial, `scan (${err2.code})`); | ||
| // readdir saw the entry but we can't stat it due to a | ||
| // transient lock — keep the previously-known entry instead | ||
| // of incorrectly flagging it as missing. | ||
| if ( | ||
| !( | ||
| err2.code === "EBUSY" && | ||
| BUSY_RETRIES > 0 && | ||
| this.files.has(itemPath) | ||
| ) | ||
| ) { | ||
| this.setMissing(itemPath, initial, `scan (${err2.code})`); | ||
| } | ||
| } else { | ||
@@ -833,25 +921,89 @@ this.onScanError(err2); | ||
| } | ||
| if (stats.isFile() || stats.isSymbolicLink()) { | ||
| if (stats.mtime) { | ||
| ensureFsAccuracy(+stats.mtime); | ||
| /** | ||
| * @param {string | null} realPath resolved real path for an outside-dir symlink target | ||
| */ | ||
| const apply = (realPath) => { | ||
| if (stats.isFile() || stats.isSymbolicLink()) { | ||
| if (stats.mtime) ensureFsAccuracy(+stats.mtime); | ||
| this.setFileTime( | ||
| itemPath, | ||
| +stats.mtime || +stats.ctime || 1, | ||
| initial, | ||
| true, | ||
| "scan (file)", | ||
| ); | ||
| if (realPath && !this._symlinkTargetWatchers.has(itemPath)) { | ||
| const w = this.watcherManager.watchFile(realPath, Date.now()); | ||
| if (w) { | ||
| w.on("change", (mtime, type, wInitial) => { | ||
| if (wInitial) return; | ||
| this.setFileTime(itemPath, mtime, false, false, type); | ||
| }); | ||
| this._symlinkTargetWatchers.set(itemPath, w); | ||
| } | ||
| } | ||
| } else if ( | ||
| stats.isDirectory() && | ||
| (!initial || !this.directories.has(itemPath)) | ||
| ) { | ||
| this.setDirectory( | ||
| itemPath, | ||
| +stats.birthtime || 1, | ||
| initial, | ||
| "scan (dir)", | ||
| ); | ||
| } | ||
| this.setFileTime( | ||
| itemPath, | ||
| +stats.mtime || +stats.ctime || 1, | ||
| initial, | ||
| true, | ||
| "scan (file)", | ||
| ); | ||
| } else if ( | ||
| stats.isDirectory() && | ||
| (!initial || !this.directories.has(itemPath)) | ||
| itemFinished(); | ||
| }; | ||
| if ( | ||
| this.options.followSymlinks && | ||
| this.nestedWatching && | ||
| stats.isSymbolicLink() | ||
| ) { | ||
| this.setDirectory( | ||
| itemPath, | ||
| +stats.birthtime || 1, | ||
| initial, | ||
| "scan (dir)", | ||
| ); | ||
| fs.realpath(itemPath, (err3, realPath) => { | ||
| if (this.closed) return; | ||
| if ( | ||
| err3 || | ||
| !realPath || | ||
| withoutCase(path.dirname(realPath)) === withoutCase(this.path) | ||
| ) { | ||
| apply(null); | ||
| return; | ||
| } | ||
| // Cycle protection: when the symlink's target is the symlink | ||
| // path itself or one of its ancestors, descending would | ||
| // create an unbounded chain of `DirectoryWatcher`s as we walk | ||
| // back into territory we are already watching. Treat the | ||
| // symlink as a plain entry instead so the symlink itself is | ||
| // still tracked but no recursion happens. | ||
| const rel = path.relative(realPath, itemPath); | ||
| if (!path.isAbsolute(rel) && !rel.startsWith("..")) { | ||
| apply(null); | ||
| return; | ||
| } | ||
| fs.stat(realPath, (err4, targetStats) => { | ||
| if (this.closed) return; | ||
| if (err4 || !targetStats) { | ||
| apply(null); | ||
| } else if (targetStats.isFile()) { | ||
| apply(realPath); | ||
| } else if (targetStats.isDirectory()) { | ||
| // Treat a symlink whose target is a directory as a | ||
| // nested watched directory so files inside the target | ||
| // propagate change events to the symlink path. | ||
| this.setDirectory( | ||
| itemPath, | ||
| +targetStats.birthtime || +stats.birthtime || 1, | ||
| initial, | ||
| "scan (dir)", | ||
| ); | ||
| itemFinished(); | ||
| } else { | ||
| apply(null); | ||
| } | ||
| }); | ||
| }); | ||
| return; | ||
| } | ||
| itemFinished(); | ||
| apply(null); | ||
| }); | ||
@@ -954,2 +1106,3 @@ } | ||
| close() { | ||
| if (this.closed) return; | ||
| this.closed = true; | ||
@@ -968,2 +1121,6 @@ this.initialScan = false; | ||
| } | ||
| for (const w of this._symlinkTargetWatchers.values()) { | ||
| w.close(); | ||
| } | ||
| this._symlinkTargetWatchers.clear(); | ||
| if (this.parentWatcher) { | ||
@@ -970,0 +1127,0 @@ this.parentWatcher.close(); |
+55
-15
@@ -8,5 +8,5 @@ /* | ||
| const { EventEmitter } = require("events"); | ||
| const globToRegExp = require("glob-to-regexp"); | ||
| const LinkResolver = require("./LinkResolver"); | ||
| const getWatcherManager = require("./getWatcherManager"); | ||
| const globToRegExp = require("./util/globToRegExp"); | ||
| const watchEventSource = require("./watchEventSource"); | ||
@@ -49,4 +49,5 @@ | ||
| /** @typedef {{ safeTime: number }} OnlySafeTimeEntry */ | ||
| // eslint-disable-next-line jsdoc/ts-no-empty-object-type | ||
| /** @typedef {{}} ExistenceOnlyTimeEntry */ | ||
| /** @typedef {{ }} ExistenceOnlyTimeEntry */ | ||
| /** @typedef {Map<string, Entry | OnlySafeTimeEntry | ExistenceOnlyTimeEntry | null>} TimeInfoEntries */ | ||
@@ -65,6 +66,4 @@ /** @typedef {Set<string>} Changes */ | ||
| for (const ww of watchers) { | ||
| const w = ww.watcher; | ||
| if (!set.has(w.directoryWatcher)) { | ||
| set.add(w.directoryWatcher); | ||
| } | ||
| // Set.add is already idempotent, so skip the redundant has() probe. | ||
| set.add(ww.watcher.directoryWatcher); | ||
| } | ||
@@ -81,7 +80,18 @@ } | ||
| } | ||
| const { source } = globToRegExp(ignored, { globstar: true, extended: true }); | ||
| return `${source.slice(0, -1)}(?:$|\\/)`; | ||
| return `^${globToRegExp(ignored)}(?:$|\\/)`; | ||
| }; | ||
| /** | ||
| * Normalizes path separators for regex testing. `String.prototype.replace` | ||
| * always allocates a new string, even when the pattern finds nothing; for | ||
| * POSIX paths (the common case) that allocation is pure overhead. Check for | ||
| * a backslash with `indexOf` first so we skip the copy on paths that are | ||
| * already normalized. | ||
| * @param {string} item item | ||
| * @returns {string} item with backslashes normalized to forward slashes | ||
| */ | ||
| const normalizeSeparators = (item) => | ||
| item.includes("\\") ? item.replace(/\\/g, "/") : item; | ||
| /** | ||
| * @param {Ignored=} ignored ignored | ||
@@ -92,8 +102,13 @@ * @returns {(item: string) => boolean} ignored to function | ||
| if (Array.isArray(ignored)) { | ||
| const stringRegexps = ignored.map((i) => stringToRegexp(i)).filter(Boolean); | ||
| const stringRegexps = | ||
| /** @type {string[]} */ | ||
| (ignored.map((i) => stringToRegexp(i)).filter(Boolean)); | ||
| if (stringRegexps.length === 0) { | ||
| return () => false; | ||
| } | ||
| const regexp = new RegExp(stringRegexps.join("|")); | ||
| return (item) => regexp.test(item.replace(/\\/g, "/")); | ||
| const regexp = | ||
| stringRegexps.length === 1 | ||
| ? new RegExp(stringRegexps[0]) | ||
| : new RegExp(stringRegexps.join("|")); | ||
| return (item) => regexp.test(normalizeSeparators(item)); | ||
| } else if (typeof ignored === "string") { | ||
@@ -105,5 +120,5 @@ const stringRegexp = stringToRegexp(ignored); | ||
| const regexp = new RegExp(stringRegexp); | ||
| return (item) => regexp.test(item.replace(/\\/g, "/")); | ||
| return (item) => regexp.test(normalizeSeparators(item)); | ||
| } else if (ignored instanceof RegExp) { | ||
| return (item) => ignored.test(item.replace(/\\/g, "/")); | ||
| return (item) => ignored.test(normalizeSeparators(item)); | ||
| } else if (typeof ignored === "function") { | ||
@@ -472,4 +487,6 @@ return ignored; | ||
| for (const w of directoryWatchers) { | ||
| // getTimes() returns a prototype-less object, so for...in is safe | ||
| // and avoids the throwaway array that Object.keys would allocate. | ||
| const times = w.getTimes(); | ||
| for (const file of Object.keys(times)) obj[file] = times[file]; | ||
| for (const file in times) obj[file] = times[file]; | ||
| } | ||
@@ -561,2 +578,25 @@ return obj; | ||
| module.exports = Watchpack; | ||
| /** | ||
| * @template A | ||
| * @template B | ||
| * @param {A} obj input a | ||
| * @param {B} exports input b | ||
| * @returns {A & B} merged | ||
| */ | ||
| const mergeExports = (obj, exports) => { | ||
| const descriptors = Object.getOwnPropertyDescriptors(exports); | ||
| Object.defineProperties(obj, descriptors); | ||
| return /** @type {A & B} */ (Object.freeze(obj)); | ||
| }; | ||
| /** @typedef {typeof Watchpack & { util: { readonly globToRegExp: typeof globToRegExp } }} WatchpackExports */ | ||
| module.exports = /** @type {WatchpackExports} */ ( | ||
| mergeExports(Watchpack, { | ||
| util: { | ||
| get globToRegExp() { | ||
| return globToRegExp; | ||
| }, | ||
| }, | ||
| }) | ||
| ); |
+56
-35
@@ -70,42 +70,63 @@ /* | ||
| } | ||
| // Reduce until limit reached | ||
| while (currentCount > limit) { | ||
| // Select node that helps reaching the limit most effectively without overmerging | ||
| const overLimit = currentCount - limit; | ||
| let bestNode; | ||
| let bestCost = Infinity; | ||
| // Reduce until limit reached. When no reduction is needed at all, skip | ||
| // building the candidate set entirely to avoid paying for the setup on the | ||
| // common fast path. | ||
| if (currentCount > limit) { | ||
| // Pre-filter candidate nodes so the inner selection loop skips structural | ||
| // non-candidates entirely. `children` length and parent presence are | ||
| // fixed after tree construction; only `entries` can change (it can only | ||
| // decrease), so a node that fails the `entries` check in a later round | ||
| // is simply skipped via `continue`. When we merge a subtree we drop the | ||
| // descendants from the candidate set to keep it shrinking over | ||
| // iterations. | ||
| /** @type {Set<TreeNode<T>>} */ | ||
| const candidates = new Set(); | ||
| for (const node of treeMap.values()) { | ||
| if (node.entries <= 1 || !node.children || !node.parent) continue; | ||
| if (!node.parent || !node.children) continue; | ||
| if (node.children.length === 0) continue; | ||
| if (node.children.length === 1 && !node.value) continue; | ||
| // Try to select the node with has just a bit more entries than we need to reduce | ||
| // When just a bit more is over 30% over the limit, | ||
| // also consider just a bit less entries then we need to reduce | ||
| const cost = | ||
| node.entries - 1 >= overLimit | ||
| ? node.entries - 1 - overLimit | ||
| : overLimit - node.entries + 1 + limit * 0.3; | ||
| if (cost < bestCost) { | ||
| bestNode = node; | ||
| bestCost = cost; | ||
| } | ||
| candidates.add(node); | ||
| } | ||
| if (!bestNode) break; | ||
| // Merge all children | ||
| const reduction = bestNode.entries - 1; | ||
| bestNode.active = true; | ||
| bestNode.entries = 1; | ||
| currentCount -= reduction; | ||
| let { parent } = bestNode; | ||
| while (parent) { | ||
| parent.entries -= reduction; | ||
| parent = parent.parent; | ||
| } | ||
| const queue = new Set(bestNode.children); | ||
| for (const node of queue) { | ||
| node.active = false; | ||
| node.entries = 0; | ||
| if (node.children) { | ||
| for (const child of node.children) queue.add(child); | ||
| const costBias = limit * 0.3; | ||
| while (currentCount > limit) { | ||
| // Select node that helps reaching the limit most effectively without overmerging | ||
| const overLimit = currentCount - limit; | ||
| let bestNode; | ||
| let bestCost = Infinity; | ||
| for (const node of candidates) { | ||
| if (node.entries <= 1) continue; | ||
| // Try to select the node with has just a bit more entries than we need to reduce | ||
| // When just a bit more is over 30% over the limit, | ||
| // also consider just a bit less entries then we need to reduce | ||
| const diff = node.entries - 1 - overLimit; | ||
| const cost = diff >= 0 ? diff : -diff + costBias; | ||
| if (cost < bestCost) { | ||
| bestNode = node; | ||
| bestCost = cost; | ||
| // A cost of 0 means the merge reduces exactly to the limit; | ||
| // no further candidate can improve on that, so stop scanning. | ||
| if (cost === 0) break; | ||
| } | ||
| } | ||
| if (!bestNode) break; | ||
| // Merge all children | ||
| const reduction = bestNode.entries - 1; | ||
| bestNode.active = true; | ||
| bestNode.entries = 1; | ||
| candidates.delete(bestNode); | ||
| currentCount -= reduction; | ||
| let { parent } = bestNode; | ||
| while (parent) { | ||
| parent.entries -= reduction; | ||
| parent = parent.parent; | ||
| } | ||
| const queue = new Set(bestNode.children); | ||
| for (const node of queue) { | ||
| node.active = false; | ||
| node.entries = 0; | ||
| candidates.delete(node); | ||
| if (node.children) { | ||
| for (const child of node.children) queue.add(child); | ||
| } | ||
| } | ||
| } | ||
@@ -112,0 +133,0 @@ } |
+18
-10
@@ -64,2 +64,5 @@ /* | ||
| function createHandleChangeEvent(watcher, filePath, handleChangeEvent) { | ||
| // path.basename(filePath) is invariant for the lifetime of the watcher, | ||
| // so compute it once rather than on every dispatched event. | ||
| const ownBasename = path.basename(filePath); | ||
| return (type, filename) => { | ||
@@ -72,3 +75,3 @@ // TODO: After Node.js v22, fs.watch(dir) and deleting a dir will trigger the rename change event. | ||
| path.isAbsolute(filename) && | ||
| path.basename(filename) === path.basename(filePath) | ||
| path.basename(filename) === ownBasename | ||
| ) { | ||
@@ -434,12 +437,17 @@ if (!IS_OSX) { | ||
| } | ||
| let current = filePath; | ||
| for (;;) { | ||
| const recursiveWatcher = recursiveWatchers.get(current); | ||
| if (recursiveWatcher !== undefined) { | ||
| recursiveWatcher.add(filePath, watcher); | ||
| return watcher; | ||
| // Only platforms with recursive fs.watch ever populate recursiveWatchers, | ||
| // so skip the entire parent walk when the map is empty (always the case | ||
| // on Linux and the common case before the watcher limit is reached). | ||
| if (recursiveWatchers.size !== 0) { | ||
| let current = filePath; | ||
| for (;;) { | ||
| const recursiveWatcher = recursiveWatchers.get(current); | ||
| if (recursiveWatcher !== undefined) { | ||
| recursiveWatcher.add(filePath, watcher); | ||
| return watcher; | ||
| } | ||
| const parent = path.dirname(current); | ||
| if (parent === current) break; | ||
| current = parent; | ||
| } | ||
| const parent = path.dirname(current); | ||
| if (parent === current) break; | ||
| current = parent; | ||
| } | ||
@@ -446,0 +454,0 @@ // Queue up watcher for creation |
+17
-23
| { | ||
| "name": "watchpack", | ||
| "version": "2.5.1", | ||
| "version": "2.5.2", | ||
| "description": "", | ||
@@ -27,8 +27,8 @@ "homepage": "https://github.com/webpack/watchpack", | ||
| "lint:code": "eslint --cache .", | ||
| "lint:types": "tsc", | ||
| "lint:types-test": "tsc -p tsconfig.types.test.json", | ||
| "lint:types": "tsc --noEmit", | ||
| "lint:types-test": "tsc -p tsconfig.types.test.json --noEmit", | ||
| "lint:declarations": "npm run fix:declarations && git diff --exit-code ./types", | ||
| "fix": "npm run fix:code && npm run fix:declarations", | ||
| "fix:code": "npm run lint:code -- --fix", | ||
| "fix:declarations": "tsc --noEmit false --declaration --emitDeclarationOnly --outDir types && npm run fmt -- ./types", | ||
| "fix:declarations": "tsc && npm run fmt -- ./types", | ||
| "fmt": "npm run fmt:base -- --log-level warn --write", | ||
@@ -42,31 +42,25 @@ "fmt:check": "npm run fmt:base -- --check", | ||
| "test:watch": "npm run test:base -- --watch", | ||
| "test:coverage": "npm run test:base -- --collectCoverageFrom=\"lib/**/*.js\" --coverage" | ||
| "test:coverage": "npm run test:base -- --collectCoverageFrom=\"lib/**/*.js\" --coverage", | ||
| "benchmark": "node --max-old-space-size=4096 --hash-seed=1 --random-seed=1 --no-opt --predictable --predictable-gc-schedule --interpreted-frames-native-stack --allow-natives-syntax --expose-gc --no-concurrent-sweeping ./benchmark/run.mjs", | ||
| "version": "changeset version", | ||
| "release": "changeset publish" | ||
| }, | ||
| "dependencies": { | ||
| "glob-to-regexp": "^0.4.1", | ||
| "graceful-fs": "^4.1.2" | ||
| }, | ||
| "devDependencies": { | ||
| "@eslint/js": "^9.28.0", | ||
| "@eslint/markdown": "^7.5.1", | ||
| "@stylistic/eslint-plugin": "^5.6.1", | ||
| "@changesets/cli": "^2.30.0", | ||
| "@changesets/get-github-info": "^0.8.0", | ||
| "@codspeed/core": "^5.2.0", | ||
| "@types/glob-to-regexp": "^0.4.4", | ||
| "@types/graceful-fs": "^4.1.9", | ||
| "@types/jest": "^27.5.1", | ||
| "@types/jest": "^30.0.0", | ||
| "@types/node": "^24.10.4", | ||
| "eslint": "^9.39.2", | ||
| "eslint-config-prettier": "^10.1.8", | ||
| "eslint-config-webpack": "^4.7.3", | ||
| "eslint-plugin-import": "^2.32.0", | ||
| "eslint-plugin-jest": "^29.5.0", | ||
| "eslint-plugin-jsdoc": "^61.5.0", | ||
| "eslint-plugin-n": "^17.23.1", | ||
| "eslint-plugin-prettier": "^5.5.4", | ||
| "eslint-plugin-unicorn": "^62.0.0", | ||
| "globals": "^16.5.0", | ||
| "jest": "^27.5.1", | ||
| "eslint-config-webpack": "^4.9.3", | ||
| "jest": "^30.3.0", | ||
| "prettier": "^3.7.4", | ||
| "rimraf": "^2.6.2", | ||
| "typescript": "^5.9.3", | ||
| "write-file-atomic": "^3.0.1" | ||
| "tinybench": "^6.0.0", | ||
| "typescript": "^6.0.2", | ||
| "write-file-atomic": "^8.0.0" | ||
| }, | ||
@@ -73,0 +67,0 @@ "engines": { |
+9
-0
@@ -134,1 +134,10 @@ # watchpack | ||
| ``` | ||
| ## Environment variables | ||
| - `WATCHPACK_POLLING`: when set, overrides the `poll` option (see above). | ||
| - `WATCHPACK_RETRIES`: number of times to retry `fs.lstat` when it returns | ||
| `EBUSY` (default: `3`). Useful on Windows where anti-virus scanners, | ||
| indexers or editors briefly lock files — without retries watchpack would | ||
| see a spurious `remove` and stop tracking the file. Set to `0` or | ||
| `"false"` to disable retrying. |
@@ -51,2 +51,4 @@ export = DirectoryWatcher; | ||
| directories: Map<string, Watcher<DirectoryWatcherEvents> | boolean>; | ||
| /** @type {Map<string, Watcher<FileWatcherEvents>>} */ | ||
| _symlinkTargetWatchers: Map<string, Watcher<FileWatcherEvents>>; | ||
| lastWatchEvent: number; | ||
@@ -53,0 +55,0 @@ initialScan: boolean; |
+121
-113
@@ -1,2 +0,121 @@ | ||
| export = Watchpack; | ||
| declare namespace _exports { | ||
| export { | ||
| WatcherManager, | ||
| DirectoryWatcher, | ||
| DirectoryWatcherEvents, | ||
| FileWatcherEvents, | ||
| EventMap, | ||
| Watcher, | ||
| IgnoredFunction, | ||
| Ignored, | ||
| WatcherOptions, | ||
| WatchOptions, | ||
| NormalizedWatchOptions, | ||
| EventType, | ||
| Entry, | ||
| OnlySafeTimeEntry, | ||
| ExistenceOnlyTimeEntry, | ||
| TimeInfoEntries, | ||
| Changes, | ||
| Removals, | ||
| Aggregated, | ||
| WatchMethodOptions, | ||
| Times, | ||
| WatchpackEvents, | ||
| WatchpackExports, | ||
| }; | ||
| } | ||
| declare const _exports: WatchpackExports; | ||
| export = _exports; | ||
| type WatcherManager = import("./getWatcherManager").WatcherManager; | ||
| type DirectoryWatcher = import("./DirectoryWatcher"); | ||
| type DirectoryWatcherEvents = | ||
| import("./DirectoryWatcher").DirectoryWatcherEvents; | ||
| type FileWatcherEvents = import("./DirectoryWatcher").FileWatcherEvents; | ||
| type EventMap = Record<string, (...args: any[]) => any>; | ||
| type Watcher<T extends EventMap> = import("./DirectoryWatcher").Watcher<T>; | ||
| type IgnoredFunction = (item: string) => boolean; | ||
| type Ignored = string[] | RegExp | string | IgnoredFunction; | ||
| type WatcherOptions = { | ||
| /** | ||
| * true when need to resolve symlinks and watch symlink and real file, otherwise false | ||
| */ | ||
| followSymlinks?: boolean | undefined; | ||
| /** | ||
| * ignore some files from watching (glob pattern or regexp) | ||
| */ | ||
| ignored?: Ignored | undefined; | ||
| /** | ||
| * true when need to enable polling mode for watching, otherwise false | ||
| */ | ||
| poll?: (number | boolean) | undefined; | ||
| }; | ||
| type WatchOptions = WatcherOptions & { | ||
| aggregateTimeout?: number; | ||
| }; | ||
| type NormalizedWatchOptions = { | ||
| /** | ||
| * true when need to resolve symlinks and watch symlink and real file, otherwise false | ||
| */ | ||
| followSymlinks: boolean; | ||
| /** | ||
| * ignore some files from watching (glob pattern or regexp) | ||
| */ | ||
| ignored: IgnoredFunction; | ||
| /** | ||
| * true when need to enable polling mode for watching, otherwise false | ||
| */ | ||
| poll?: (number | boolean) | undefined; | ||
| }; | ||
| type EventType = | ||
| | `scan (${string})` | ||
| | "change" | ||
| | "rename" | ||
| | `watch ${string}` | ||
| | `directory-removed ${string}`; | ||
| type Entry = { | ||
| safeTime: number; | ||
| timestamp: number; | ||
| accuracy: number; | ||
| }; | ||
| type OnlySafeTimeEntry = { | ||
| safeTime: number; | ||
| }; | ||
| type ExistenceOnlyTimeEntry = {}; | ||
| type TimeInfoEntries = Map< | ||
| string, | ||
| Entry | OnlySafeTimeEntry | ExistenceOnlyTimeEntry | null | ||
| >; | ||
| type Changes = Set<string>; | ||
| type Removals = Set<string>; | ||
| type Aggregated = { | ||
| changes: Changes; | ||
| removals: Removals; | ||
| }; | ||
| type WatchMethodOptions = { | ||
| files?: Iterable<string>; | ||
| directories?: Iterable<string>; | ||
| missing?: Iterable<string>; | ||
| startTime?: number; | ||
| }; | ||
| type Times = Record<string, number>; | ||
| type WatchpackEvents = { | ||
| /** | ||
| * change event | ||
| */ | ||
| change: (file: string, mtime: number, type: EventType) => void; | ||
| /** | ||
| * remove event | ||
| */ | ||
| remove: (file: string, type: EventType) => void; | ||
| /** | ||
| * aggregated event | ||
| */ | ||
| aggregated: (changes: Changes, removals: Removals) => void; | ||
| }; | ||
| type WatchpackExports = typeof Watchpack & { | ||
| util: { | ||
| readonly globToRegExp: typeof globToRegExp; | ||
| }; | ||
| }; | ||
| /** | ||
@@ -105,28 +224,3 @@ * @typedef {object} WatchpackEvents | ||
| } | ||
| declare namespace Watchpack { | ||
| export { | ||
| WatcherManager, | ||
| DirectoryWatcher, | ||
| DirectoryWatcherEvents, | ||
| FileWatcherEvents, | ||
| EventMap, | ||
| Watcher, | ||
| IgnoredFunction, | ||
| Ignored, | ||
| WatcherOptions, | ||
| WatchOptions, | ||
| NormalizedWatchOptions, | ||
| EventType, | ||
| Entry, | ||
| OnlySafeTimeEntry, | ||
| ExistenceOnlyTimeEntry, | ||
| TimeInfoEntries, | ||
| Changes, | ||
| Removals, | ||
| Aggregated, | ||
| WatchMethodOptions, | ||
| Times, | ||
| WatchpackEvents, | ||
| }; | ||
| } | ||
| import globToRegExp = require("./util/globToRegExp"); | ||
| import { EventEmitter } from "events"; | ||
@@ -177,87 +271,1 @@ declare class WatchpackFileWatcher { | ||
| } | ||
| type WatcherManager = import("./getWatcherManager").WatcherManager; | ||
| type DirectoryWatcher = import("./DirectoryWatcher"); | ||
| type DirectoryWatcherEvents = | ||
| import("./DirectoryWatcher").DirectoryWatcherEvents; | ||
| type FileWatcherEvents = import("./DirectoryWatcher").FileWatcherEvents; | ||
| type EventMap = Record<string, (...args: any[]) => any>; | ||
| type Watcher<T extends EventMap> = import("./DirectoryWatcher").Watcher<T>; | ||
| type IgnoredFunction = (item: string) => boolean; | ||
| type Ignored = string[] | RegExp | string | IgnoredFunction; | ||
| type WatcherOptions = { | ||
| /** | ||
| * true when need to resolve symlinks and watch symlink and real file, otherwise false | ||
| */ | ||
| followSymlinks?: boolean | undefined; | ||
| /** | ||
| * ignore some files from watching (glob pattern or regexp) | ||
| */ | ||
| ignored?: Ignored | undefined; | ||
| /** | ||
| * true when need to enable polling mode for watching, otherwise false | ||
| */ | ||
| poll?: (number | boolean) | undefined; | ||
| }; | ||
| type WatchOptions = WatcherOptions & { | ||
| aggregateTimeout?: number; | ||
| }; | ||
| type NormalizedWatchOptions = { | ||
| /** | ||
| * true when need to resolve symlinks and watch symlink and real file, otherwise false | ||
| */ | ||
| followSymlinks: boolean; | ||
| /** | ||
| * ignore some files from watching (glob pattern or regexp) | ||
| */ | ||
| ignored: IgnoredFunction; | ||
| /** | ||
| * true when need to enable polling mode for watching, otherwise false | ||
| */ | ||
| poll?: (number | boolean) | undefined; | ||
| }; | ||
| type EventType = | ||
| | `scan (${string})` | ||
| | "change" | ||
| | "rename" | ||
| | `watch ${string}` | ||
| | `directory-removed ${string}`; | ||
| type Entry = { | ||
| safeTime: number; | ||
| timestamp: number; | ||
| accuracy: number; | ||
| }; | ||
| type OnlySafeTimeEntry = { | ||
| safeTime: number; | ||
| }; | ||
| type ExistenceOnlyTimeEntry = {}; | ||
| type TimeInfoEntries = Map< | ||
| string, | ||
| Entry | OnlySafeTimeEntry | ExistenceOnlyTimeEntry | null | ||
| >; | ||
| type Changes = Set<string>; | ||
| type Removals = Set<string>; | ||
| type Aggregated = { | ||
| changes: Changes; | ||
| removals: Removals; | ||
| }; | ||
| type WatchMethodOptions = { | ||
| files?: Iterable<string>; | ||
| directories?: Iterable<string>; | ||
| missing?: Iterable<string>; | ||
| startTime?: number; | ||
| }; | ||
| type Times = Record<string, number>; | ||
| type WatchpackEvents = { | ||
| /** | ||
| * change event | ||
| */ | ||
| change: (file: string, mtime: number, type: EventType) => void; | ||
| /** | ||
| * remove event | ||
| */ | ||
| remove: (file: string, type: EventType) => void; | ||
| /** | ||
| * aggregated event | ||
| */ | ||
| aggregated: (changes: Changes, removals: Removals) => void; | ||
| }; |
@@ -1,2 +0,2 @@ | ||
| declare const _exports: typeof import("./index"); | ||
| declare const _exports: import("./index").WatchpackExports; | ||
| export = _exports; |
108498
13.35%1
-50%14
-36.36%19
11.76%3273
12.51%143
6.72%- Removed
- Removed