fs: add ignore option to fs.watch by mcollina · Pull Request #61433 · nodejs/node · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/api/fs.md
3 changes: 2 additions & 1 deletion lib/fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -2521,7 +2521,8 @@ function watch(filename, options, listener) {
watcher[watchers.kFSWatchStart](path,
options.persistent,
options.recursive,
options.encoding);
options.encoding,
options.ignore);
}

if (listener) {
Expand Down
18 changes: 14 additions & 4 deletions lib/internal/fs/recursive_watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ const {
},
} = require('internal/errors');
const { getValidatedPath } = require('internal/fs/utils');
const { kFSWatchStart, StatWatcher } = require('internal/fs/watchers');
const { createIgnoreMatcher, kFSWatchStart, StatWatcher } = require('internal/fs/watchers');
const { kEmptyObject } = require('internal/util');
const { validateBoolean, validateAbortSignal } = require('internal/validators');
const { validateBoolean, validateAbortSignal, validateIgnoreOption } = require('internal/validators');
const {
basename: pathBasename,
join: pathJoin,
Expand All @@ -44,13 +44,14 @@ class FSWatcher extends EventEmitter {
#symbolicFiles = new SafeSet();
#rootPath = pathResolve();
#watchingFile = false;
#ignoreMatcher = null;

constructor(options = kEmptyObject) {
super();

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

const { persistent, recursive, signal, encoding } = options;
const { persistent, recursive, signal, encoding, ignore } = options;

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

validateIgnoreOption(ignore, 'options.ignore');
this.#ignoreMatcher = createIgnoreMatcher(ignore);

this.#options = { persistent, recursive, signal, encoding };
}

Expand Down Expand Up @@ -118,9 +122,15 @@ class FSWatcher extends EventEmitter {
}

const f = pathJoin(folder, file.name);
const relativePath = pathRelative(this.#rootPath, f);

// Skip watching ignored paths entirely to avoid kernel resource pressure
if (this.#ignoreMatcher?.(relativePath)) {
continue;
}

if (!this.#files.has(f)) {
this.emit('change', 'rename', pathRelative(this.#rootPath, f));
this.emit('change', 'rename', relativePath);

if (file.isSymbolicLink()) {
this.#symbolicFiles.add(f);
Expand Down
72 changes: 71 additions & 1 deletion lib/internal/fs/watchers.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
'use strict';

const {
ArrayIsArray,
ArrayPrototypePush,
ArrayPrototypeShift,
Error,
FunctionPrototypeCall,
ObjectDefineProperty,
ObjectSetPrototypeOf,
PromiseWithResolvers,
RegExpPrototypeExec,
Symbol,
} = primordials;

Expand All @@ -22,6 +24,9 @@ const {

const {
kEmptyObject,
getLazy,
isWindows,
isMacOS,
} = require('internal/util');

const {
Expand All @@ -48,6 +53,7 @@ const { toNamespacedPath } = require('path');
const {
validateAbortSignal,
validateBoolean,
validateIgnoreOption,
validateObject,
validateUint32,
validateInteger,
Expand All @@ -60,6 +66,8 @@ const {
},
} = require('buffer');

const { isRegExp } = require('internal/util/types');

const assert = require('internal/assert');

const kOldStatus = Symbol('kOldStatus');
Expand All @@ -71,6 +79,50 @@ const KFSStatWatcherRefCount = Symbol('KFSStatWatcherRefCount');
const KFSStatWatcherMaxRefCount = Symbol('KFSStatWatcherMaxRefCount');
const kFSStatWatcherAddOrCleanRef = Symbol('kFSStatWatcherAddOrCleanRef');

const lazyMinimatch = getLazy(() => require('internal/deps/minimatch/index'));

/**
* Creates an ignore matcher function from the ignore option.
* @param {string | RegExp | Function | Array} ignore - The ignore patterns
* @returns {Function | null} A function that returns true if filename should be ignored
*/
function createIgnoreMatcher(ignore) {
if (ignore == null) return null;
const matchers = ArrayIsArray(ignore) ? ignore : [ignore];
const compiled = [];

for (let i = 0; i < matchers.length; i++) {
const matcher = matchers[i];
if (typeof matcher === 'string') {
const mm = new (lazyMinimatch().Minimatch)(matcher, {
__proto__: null,
nocase: isWindows || isMacOS,
windowsPathsNoEscape: true,
nonegate: true,
nocomment: true,
optimizationLevel: 2,
platform: process.platform,
// matchBase allows patterns without slashes to match the basename
// e.g., '*.log' matches 'subdir/file.log'
matchBase: true,
});
ArrayPrototypePush(compiled, (filename) => mm.match(filename));
} else if (isRegExp(matcher)) {
ArrayPrototypePush(compiled, (filename) => RegExpPrototypeExec(matcher, filename) !== null);
} else {
// Function
ArrayPrototypePush(compiled, matcher);
}
}

return (filename) => {
for (let i = 0; i < compiled.length; i++) {
if (compiled[i](filename)) return true;
}
return false;
};
}

function emitStop(self) {
self.emit('stop');
}
Expand Down Expand Up @@ -199,6 +251,7 @@ function FSWatcher() {

this._handle = new FSEvent();
this._handle[owner_symbol] = this;
this._ignoreMatcher = null;

this._handle.onchange = (status, eventType, filename) => {
// TODO(joyeecheung): we may check self._handle.initialized here
Expand All @@ -219,6 +272,10 @@ function FSWatcher() {
error.filename = filename;
this.emit('error', error);
} else {
// Filter events if ignore matcher is set and filename is available
if (filename != null && this._ignoreMatcher?.(filename)) {
return;
}
this.emit('change', eventType, filename);
}
};
Expand All @@ -235,7 +292,8 @@ ObjectSetPrototypeOf(FSWatcher, EventEmitter);
FSWatcher.prototype[kFSWatchStart] = function(filename,
persistent,
recursive,
encoding) {
encoding,
ignore) {
if (this._handle === null) { // closed
return;
}
Expand All @@ -246,6 +304,10 @@ FSWatcher.prototype[kFSWatchStart] = function(filename,

filename = getValidatedPath(filename, 'filename');

// Validate and create the ignore matcher
validateIgnoreOption(ignore, 'options.ignore');
this._ignoreMatcher = createIgnoreMatcher(ignore);

const err = this._handle.start(toNamespacedPath(filename),
persistent,
recursive,
Expand Down Expand Up @@ -319,13 +381,15 @@ async function* watch(filename, options = kEmptyObject) {
maxQueue = 2048,
overflow = 'ignore',
signal,
ignore,
} = options;

validateBoolean(persistent, 'options.persistent');
validateBoolean(recursive, 'options.recursive');
validateInteger(maxQueue, 'options.maxQueue');
validateOneOf(overflow, 'options.overflow', ['ignore', 'error']);
validateAbortSignal(signal, 'options.signal');
validateIgnoreOption(ignore, 'options.ignore');

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

const handle = new FSEvent();
const ignoreMatcher = createIgnoreMatcher(ignore);
let { promise, resolve } = PromiseWithResolvers();
const queue = [];
const oncancel = () => {
Expand All @@ -361,6 +426,10 @@ async function* watch(filename, options = kEmptyObject) {
resolve();
return;
}
// Filter events if ignore matcher is set and filename is available
if (filename != null && ignoreMatcher?.(filename)) {
return;
}
if (queue.length < maxQueue) {
ArrayPrototypePush(queue, { __proto__: null, eventType, filename });
resolve();
Expand Down Expand Up @@ -409,6 +478,7 @@ async function* watch(filename, options = kEmptyObject) {
}

module.exports = {
createIgnoreMatcher,
FSWatcher,
StatWatcher,
kFSWatchStart,
Expand Down
34 changes: 34 additions & 0 deletions lib/internal/validators.js
Loading
Loading