Skip to content

Commit 45315c3

Browse files
committed
fs: add ignore option to fs.watch
Add an `ignore` option to `fs.watch()` to filter filesystem events. Supports string globs, RegExp, functions, or arrays of these.
1 parent 637bda0 commit 45315c3

File tree

7 files changed

+777
-10
lines changed

7 files changed

+777
-10
lines changed

lib/fs.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2521,7 +2521,8 @@ function watch(filename, options, listener) {
25212521
watcher[watchers.kFSWatchStart](path,
25222522
options.persistent,
25232523
options.recursive,
2524-
options.encoding);
2524+
options.encoding,
2525+
options.ignore);
25252526
}
25262527

25272528
if (listener) {

lib/internal/fs/recursive_watch.js

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ const {
1717
},
1818
} = require('internal/errors');
1919
const { getValidatedPath } = require('internal/fs/utils');
20-
const { kFSWatchStart, StatWatcher } = require('internal/fs/watchers');
20+
const { createIgnoreMatcher, kFSWatchStart, StatWatcher } = require('internal/fs/watchers');
2121
const { kEmptyObject } = require('internal/util');
22-
const { validateBoolean, validateAbortSignal } = require('internal/validators');
22+
const { validateBoolean, validateAbortSignal, validateIgnoreOption } = require('internal/validators');
2323
const {
2424
basename: pathBasename,
2525
join: pathJoin,
@@ -44,13 +44,14 @@ class FSWatcher extends EventEmitter {
4444
#symbolicFiles = new SafeSet();
4545
#rootPath = pathResolve();
4646
#watchingFile = false;
47+
#ignoreMatcher = null;
4748

4849
constructor(options = kEmptyObject) {
4950
super();
5051

5152
assert(typeof options === 'object');
5253

53-
const { persistent, recursive, signal, encoding } = options;
54+
const { persistent, recursive, signal, encoding, ignore } = options;
5455

5556
// TODO(anonrig): Add non-recursive support to non-native-watcher for IBMi & AIX support.
5657
if (recursive != null) {
@@ -72,6 +73,9 @@ class FSWatcher extends EventEmitter {
7273
}
7374
}
7475

76+
validateIgnoreOption(ignore, 'options.ignore');
77+
this.#ignoreMatcher = createIgnoreMatcher(ignore);
78+
7579
this.#options = { persistent, recursive, signal, encoding };
7680
}
7781

@@ -92,6 +96,14 @@ class FSWatcher extends EventEmitter {
9296
this.emit('close');
9397
}
9498

99+
#emitChange(eventType, filename) {
100+
// Filter events if ignore matcher is set and filename is available
101+
if (filename != null && this.#ignoreMatcher?.(filename)) {
102+
return;
103+
}
104+
this.emit('change', eventType, filename);
105+
}
106+
95107
#unwatchFiles(file) {
96108
this.#symbolicFiles.delete(file);
97109

@@ -120,7 +132,7 @@ class FSWatcher extends EventEmitter {
120132
const f = pathJoin(folder, file.name);
121133

122134
if (!this.#files.has(f)) {
123-
this.emit('change', 'rename', pathRelative(this.#rootPath, f));
135+
this.#emitChange('rename', pathRelative(this.#rootPath, f));
124136

125137
if (file.isSymbolicLink()) {
126138
this.#symbolicFiles.add(f);
@@ -178,20 +190,20 @@ class FSWatcher extends EventEmitter {
178190
this.#files.delete(file);
179191
this.#watchers.delete(file);
180192
watcher.close();
181-
this.emit('change', 'rename', pathRelative(this.#rootPath, file));
193+
this.#emitChange('rename', pathRelative(this.#rootPath, file));
182194
this.#unwatchFiles(file);
183195
} else if (file === this.#rootPath && this.#watchingFile) {
184196
// This case will only be triggered when watching a file with fs.watch
185-
this.emit('change', 'change', pathBasename(file));
197+
this.#emitChange('change', pathBasename(file));
186198
} else if (this.#symbolicFiles.has(file)) {
187199
// Stats from watchFile does not return correct value for currentStats.isSymbolicLink()
188200
// Since it is only valid when using fs.lstat(). Therefore, check the existing symbolic files.
189-
this.emit('change', 'rename', pathRelative(this.#rootPath, file));
201+
this.#emitChange('rename', pathRelative(this.#rootPath, file));
190202
} else if (currentStats.isDirectory()) {
191203
this.#watchFolder(file);
192204
} else {
193205
// Watching a directory will trigger a change event for child files)
194-
this.emit('change', 'change', pathRelative(this.#rootPath, file));
206+
this.#emitChange('change', pathRelative(this.#rootPath, file));
195207
}
196208
});
197209
this.#watchers.set(file, watcher);

lib/internal/fs/watchers.js

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
'use strict';
22

33
const {
4+
ArrayIsArray,
45
ArrayPrototypePush,
56
ArrayPrototypeShift,
67
Error,
78
FunctionPrototypeCall,
89
ObjectDefineProperty,
910
ObjectSetPrototypeOf,
1011
PromiseWithResolvers,
12+
RegExpPrototypeExec,
1113
Symbol,
1214
} = primordials;
1315

@@ -22,6 +24,8 @@ const {
2224

2325
const {
2426
kEmptyObject,
27+
isWindows,
28+
isMacOS,
2529
} = require('internal/util');
2630

2731
const {
@@ -48,6 +52,7 @@ const { toNamespacedPath } = require('path');
4852
const {
4953
validateAbortSignal,
5054
validateBoolean,
55+
validateIgnoreOption,
5156
validateObject,
5257
validateUint32,
5358
validateInteger,
@@ -60,6 +65,8 @@ const {
6065
},
6166
} = require('buffer');
6267

68+
const { isRegExp } = require('internal/util/types');
69+
6370
const assert = require('internal/assert');
6471

6572
const kOldStatus = Symbol('kOldStatus');
@@ -71,6 +78,54 @@ const KFSStatWatcherRefCount = Symbol('KFSStatWatcherRefCount');
7178
const KFSStatWatcherMaxRefCount = Symbol('KFSStatWatcherMaxRefCount');
7279
const kFSStatWatcherAddOrCleanRef = Symbol('kFSStatWatcherAddOrCleanRef');
7380

81+
let minimatch;
82+
function lazyMinimatch() {
83+
minimatch ??= require('internal/deps/minimatch/index');
84+
return minimatch;
85+
}
86+
87+
/**
88+
* Creates an ignore matcher function from the ignore option.
89+
* @param {string | RegExp | Function | Array} ignore - The ignore patterns
90+
* @returns {Function | null} A function that returns true if filename should be ignored
91+
*/
92+
function createIgnoreMatcher(ignore) {
93+
if (ignore == null) return null;
94+
const matchers = ArrayIsArray(ignore) ? ignore : [ignore];
95+
const compiled = [];
96+
97+
for (let i = 0; i < matchers.length; i++) {
98+
const matcher = matchers[i];
99+
if (typeof matcher === 'string') {
100+
const mm = new (lazyMinimatch().Minimatch)(matcher, {
101+
__proto__: null,
102+
nocase: isWindows || isMacOS,
103+
windowsPathsNoEscape: true,
104+
nonegate: true,
105+
nocomment: true,
106+
optimizationLevel: 2,
107+
platform: process.platform,
108+
// matchBase allows patterns without slashes to match the basename
109+
// e.g., '*.log' matches 'subdir/file.log'
110+
matchBase: true,
111+
});
112+
ArrayPrototypePush(compiled, (filename) => mm.match(filename));
113+
} else if (isRegExp(matcher)) {
114+
ArrayPrototypePush(compiled, (filename) => RegExpPrototypeExec(matcher, filename) !== null);
115+
} else {
116+
// Function
117+
ArrayPrototypePush(compiled, matcher);
118+
}
119+
}
120+
121+
return (filename) => {
122+
for (let i = 0; i < compiled.length; i++) {
123+
if (compiled[i](filename)) return true;
124+
}
125+
return false;
126+
};
127+
}
128+
74129
function emitStop(self) {
75130
self.emit('stop');
76131
}
@@ -199,6 +254,7 @@ function FSWatcher() {
199254

200255
this._handle = new FSEvent();
201256
this._handle[owner_symbol] = this;
257+
this._ignoreMatcher = null;
202258

203259
this._handle.onchange = (status, eventType, filename) => {
204260
// TODO(joyeecheung): we may check self._handle.initialized here
@@ -219,6 +275,10 @@ function FSWatcher() {
219275
error.filename = filename;
220276
this.emit('error', error);
221277
} else {
278+
// Filter events if ignore matcher is set and filename is available
279+
if (filename != null && this._ignoreMatcher?.(filename)) {
280+
return;
281+
}
222282
this.emit('change', eventType, filename);
223283
}
224284
};
@@ -235,7 +295,8 @@ ObjectSetPrototypeOf(FSWatcher, EventEmitter);
235295
FSWatcher.prototype[kFSWatchStart] = function(filename,
236296
persistent,
237297
recursive,
238-
encoding) {
298+
encoding,
299+
ignore) {
239300
if (this._handle === null) { // closed
240301
return;
241302
}
@@ -246,6 +307,10 @@ FSWatcher.prototype[kFSWatchStart] = function(filename,
246307

247308
filename = getValidatedPath(filename, 'filename');
248309

310+
// Validate and create the ignore matcher
311+
validateIgnoreOption(ignore, 'options.ignore');
312+
this._ignoreMatcher = createIgnoreMatcher(ignore);
313+
249314
const err = this._handle.start(toNamespacedPath(filename),
250315
persistent,
251316
recursive,
@@ -319,13 +384,15 @@ async function* watch(filename, options = kEmptyObject) {
319384
maxQueue = 2048,
320385
overflow = 'ignore',
321386
signal,
387+
ignore,
322388
} = options;
323389

324390
validateBoolean(persistent, 'options.persistent');
325391
validateBoolean(recursive, 'options.recursive');
326392
validateInteger(maxQueue, 'options.maxQueue');
327393
validateOneOf(overflow, 'options.overflow', ['ignore', 'error']);
328394
validateAbortSignal(signal, 'options.signal');
395+
validateIgnoreOption(ignore, 'options.ignore');
329396

330397
if (encoding && !isEncoding(encoding)) {
331398
const reason = 'is invalid encoding';
@@ -336,6 +403,7 @@ async function* watch(filename, options = kEmptyObject) {
336403
throw new AbortError(undefined, { cause: signal.reason });
337404

338405
const handle = new FSEvent();
406+
const ignoreMatcher = createIgnoreMatcher(ignore);
339407
let { promise, resolve } = PromiseWithResolvers();
340408
const queue = [];
341409
const oncancel = () => {
@@ -361,6 +429,10 @@ async function* watch(filename, options = kEmptyObject) {
361429
resolve();
362430
return;
363431
}
432+
// Filter events if ignore matcher is set and filename is available
433+
if (filename != null && ignoreMatcher?.(filename)) {
434+
return;
435+
}
364436
if (queue.length < maxQueue) {
365437
ArrayPrototypePush(queue, { __proto__: null, eventType, filename });
366438
resolve();
@@ -409,6 +481,7 @@ async function* watch(filename, options = kEmptyObject) {
409481
}
410482

411483
module.exports = {
484+
createIgnoreMatcher,
412485
FSWatcher,
413486
StatWatcher,
414487
kFSWatchStart,

lib/internal/validators.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const { normalizeEncoding } = require('internal/util');
3535
const {
3636
isAsyncFunction,
3737
isArrayBufferView,
38+
isRegExp,
3839
} = require('internal/util/types');
3940
const { signals } = internalBinding('constants').os;
4041

@@ -575,6 +576,38 @@ const validateLinkHeaderValue = hideStackFrames((hints) => {
575576
);
576577
});
577578

579+
/**
580+
* Validates a single ignore option element (string, RegExp, or Function).
581+
* @param {*} value
582+
* @param {string} name
583+
*/
584+
const validateIgnoreOptionElement = hideStackFrames((value, name) => {
585+
if (typeof value === 'string') {
586+
if (value.length === 0)
587+
throw new ERR_INVALID_ARG_VALUE(name, value, 'must be a non-empty string');
588+
return;
589+
}
590+
if (isRegExp(value)) return;
591+
if (typeof value === 'function') return;
592+
throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp', 'Function'], value);
593+
});
594+
595+
/**
596+
* Validates the ignore option for fs.watch.
597+
* @param {*} value
598+
* @param {string} name
599+
*/
600+
const validateIgnoreOption = hideStackFrames((value, name) => {
601+
if (value == null) return;
602+
if (ArrayIsArray(value)) {
603+
for (let i = 0; i < value.length; i++) {
604+
validateIgnoreOptionElement(value[i], `${name}[${i}]`);
605+
}
606+
return;
607+
}
608+
validateIgnoreOptionElement(value, name);
609+
});
610+
578611
// 1. Returns false for undefined and NaN
579612
// 2. Returns true for finite numbers
580613
// 3. Throws ERR_INVALID_ARG_TYPE for non-numbers
@@ -628,6 +661,7 @@ module.exports = {
628661
validateDictionary,
629662
validateEncoding,
630663
validateFunction,
664+
validateIgnoreOption,
631665
validateInt32,
632666
validateInteger,
633667
validateNumber,

0 commit comments

Comments
 (0)