Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
Original file line number Diff line number Diff line change
Expand Up @@ -1837,6 +1837,10 @@ added:
* `overflow` {string} Either `'ignore'` or `'throw'` when there are more events to be
queued than `maxQueue` allows. `'ignore'` means overflow events are dropped and a
warning is emitted, while `'throw'` means to throw an exception. **Default:** `'ignore'`.
* `ignore` {string|RegExp|Function|Array} Pattern(s) to ignore. Strings are
glob patterns (using [`minimatch`][]), RegExp patterns are tested against
the filename, and functions receive the filename and return `true` to
ignore. **Default:** `undefined`.
* Returns: {AsyncIterator} of objects with the properties:
* `eventType` {string} The type of change
* `filename` {string|Buffer|null} The name of the file changed.
Expand Down Expand Up @@ -4804,6 +4808,10 @@ changes:
* `encoding` {string} Specifies the character encoding to be used for the
filename passed to the listener. **Default:** `'utf8'`.
* `signal` {AbortSignal} allows closing the watcher with an AbortSignal.
* `ignore` {string|RegExp|Function|Array} Pattern(s) to ignore. Strings are
glob patterns (using [`minimatch`][]), RegExp patterns are tested against
the filename, and functions receive the filename and return `true` to
ignore. **Default:** `undefined`.
* `listener` {Function|undefined} **Default:** `undefined`
* `eventType` {string}
* `filename` {string|Buffer|null}
Expand Down Expand Up @@ -8764,6 +8772,7 @@ the file contents.
[`fsPromises.utimes()`]: #fspromisesutimespath-atime-mtime
[`inotify(7)`]: https://man7.org/linux/man-pages/man7/inotify.7.html
[`kqueue(2)`]: https://www.freebsd.org/cgi/man.cgi?query=kqueue&sektion=2
[`minimatch`]: https://github.com/isaacs/minimatch
[`util.promisify()`]: util.md#utilpromisifyoriginal
[bigints]: https://tc39.github.io/proposal-bigint
[caveats]: #caveats
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const { normalizeEncoding } = require('internal/util');
const {
isAsyncFunction,
isArrayBufferView,
isRegExp,
} = require('internal/util/types');
const { signals } = internalBinding('constants').os;

Expand Down Expand Up @@ -575,6 +576,38 @@ const validateLinkHeaderValue = hideStackFrames((hints) => {
);
});

/**
* Validates a single ignore option element (string, RegExp, or Function).
* @param {*} value
* @param {string} name
*/
const validateIgnoreOptionElement = hideStackFrames((value, name) => {
if (typeof value === 'string') {
if (value.length === 0)
throw new ERR_INVALID_ARG_VALUE(name, value, 'must be a non-empty string');
return;
}
if (isRegExp(value)) return;
if (typeof value === 'function') return;
throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp', 'Function'], value);
});

/**
* Validates the ignore option for fs.watch.
* @param {*} value
* @param {string} name
*/
const validateIgnoreOption = hideStackFrames((value, name) => {
if (value == null) return;
if (ArrayIsArray(value)) {
for (let i = 0; i < value.length; i++) {
validateIgnoreOptionElement(value[i], `${name}[${i}]`);
}
return;
}
validateIgnoreOptionElement(value, name);
});

// 1. Returns false for undefined and NaN
// 2. Returns true for finite numbers
// 3. Throws ERR_INVALID_ARG_TYPE for non-numbers
Expand Down Expand Up @@ -628,6 +661,7 @@ module.exports = {
validateDictionary,
validateEncoding,
validateFunction,
validateIgnoreOption,
validateInt32,
validateInteger,
validateNumber,
Expand Down
Loading
Loading