From cfb25e20ed29045767bcfd855ffa1d8d46ee8d18 Mon Sep 17 00:00:00 2001 From: Nathan Whitaker Date: Mon, 27 Apr 2026 22:44:49 +0000 Subject: [PATCH 1/2] fix(ext/node): support `ignore` option in `fs.watch` and `fs.promises.watch` Implement the `ignore` option for `fs.watch` (sync) and `fs.promises.watch` (async iterable). Accepts a string (minimatch glob), `RegExp`, function, or array of those. Watch events whose filename matches any pattern are filtered out. Mirrors Node's `createIgnoreMatcher` / `validateIgnoreOption` from https://github.com/nodejs/node/blob/main/lib/internal/fs/watchers.js, reusing the minimatch dep already vendored at `ext:deno_node/deps/minimatch.js`. Enables 14 node compat tests: - `test-fs-promises-watch-ignore-function.mjs` - `test-fs-promises-watch-ignore-glob.mjs` - `test-fs-promises-watch-ignore-invalid.mjs` - `test-fs-promises-watch-ignore-mixed.mjs` - `test-fs-promises-watch-ignore-regexp.mjs` - `test-fs-watch-ignore-function.js` - `test-fs-watch-ignore-glob.js` - `test-fs-watch-ignore-invalid.js` - `test-fs-watch-ignore-mixed.js` - `test-fs-watch-ignore-recursive-glob.js` - `test-fs-watch-ignore-recursive-glob-subdirectories.js` - `test-fs-watch-ignore-recursive-mixed.js` - `test-fs-watch-ignore-recursive-regexp.js` - `test-fs-watch-ignore-regexp.js` Supersedes #33606 (which only covered the promise version). --- ext/node/polyfills/fs.ts | 128 +++++++++++++++++++++++++++++---- tests/node_compat/config.jsonc | 14 ++++ 2 files changed, 129 insertions(+), 13 deletions(-) diff --git a/ext/node/polyfills/fs.ts b/ext/node/polyfills/fs.ts index 7d7c490126d04b..1ee323929e43a0 100644 --- a/ext/node/polyfills/fs.ts +++ b/ext/node/polyfills/fs.ts @@ -3129,10 +3129,99 @@ function asyncIterableToCallback( next(); } +// Mirrors Node's `validateIgnoreOption` / +// `createIgnoreMatcher` from `lib/internal/fs/watchers.js`. +// Accepts a string (minimatch glob), RegExp, function, or array of those. +// Returns a function `(filename) => boolean` (or `null` if `ignore` is nullish). +type IgnoreOption = + | string + | RegExp + | ((filename: string) => boolean) + | (string | RegExp | ((filename: string) => boolean))[] + | undefined + | null; + +let _lazyMinimatch: any = null; +function getMinimatch() { + _lazyMinimatch ??= core.createLazyLoader("ext:deno_node/deps/minimatch.js"); + return _lazyMinimatch(); +} + +function validateIgnoreOptionElement(value: unknown, name: string) { + if (typeof value === "string") { + if (value.length === 0) { + throw new ERR_INVALID_ARG_VALUE( + name, + value, + "must be a non-empty string", + ); + } + return; + } + if (value instanceof RegExp) return; + if (typeof value === "function") return; + throw new ERR_INVALID_ARG_TYPE( + name, + ["string", "RegExp", "Function"], + value, + ); +} + +function validateIgnoreOption(value: unknown, name: string) { + if (value == null) return; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + validateIgnoreOptionElement(value[i], `${name}[${i}]`); + } + return; + } + validateIgnoreOptionElement(value, name); +} + +function createIgnoreMatcher( + ignore: IgnoreOption, +): ((filename: string) => boolean) | null { + if (ignore == null) return null; + const matchers = Array.isArray(ignore) ? ignore : [ignore]; + const compiled: Array<(filename: string) => boolean> = []; + + for (let i = 0; i < matchers.length; i++) { + const matcher = matchers[i]; + if (typeof matcher === "string") { + const { Minimatch } = getMinimatch().default; + const mm = new Minimatch(matcher, { + nocase: isMacOS || isWindows, + windowsPathsNoEscape: true, + nonegate: true, + nocomment: true, + optimizationLevel: 2, + platform: isWindows ? "win32" : "posix", + // Allow patterns without slashes to match the basename + // e.g. '*.log' matches 'subdir/file.log'. + matchBase: true, + }); + compiled.push((filename: string) => mm.match(filename)); + } else if (matcher instanceof RegExp) { + compiled.push((filename: string) => matcher.test(filename)); + } else { + // Function + compiled.push(matcher as (filename: string) => boolean); + } + } + + return (filename: string) => { + for (let i = 0; i < compiled.length; i++) { + if (compiled[i](filename)) return true; + } + return false; + }; +} + type watchOptions = { persistent?: boolean; recursive?: boolean; encoding?: string; + ignore?: IgnoreOption; }; type watchListener = (eventType: string, filename: string) => void; @@ -3171,6 +3260,8 @@ function watch( const watchPath = getValidatedPath(filename).toString(); const recursive = options?.recursive || false; + validateIgnoreOption(options?.ignore, "options.ignore"); + const ignoreMatcher = createIgnoreMatcher(options?.ignore); const iterator: Deno.FsWatcher = Deno.watchFs(watchPath, { recursive, }); @@ -3187,6 +3278,9 @@ function watch( const filename = recursive ? relative(resolvedWatchPath, val.paths[0]) : basename(val.paths[0]); + if (ignoreMatcher !== null && ignoreMatcher(filename)) { + return; + } fsWatcher.emit( "change", convertDenoFsEventToNodeFsEvent(val.kind), @@ -3227,12 +3321,15 @@ function watchPromise( recursive?: boolean; encoding?: string; signal?: AbortSignal; + ignore?: IgnoreOption; }, ): AsyncIterable<{ eventType: string; filename: string | Buffer | null }> { // deno-lint-ignore prefer-primordials const watchPath = getValidatedPath(filename).toString(); const recursive = options?.recursive ?? false; + validateIgnoreOption(options?.ignore, "options.ignore"); + const ignoreMatcher = createIgnoreMatcher(options?.ignore); const watcher = Deno.watchFs(watchPath, { recursive, }); @@ -3255,20 +3352,25 @@ function watchPromise( async next(): Promise< IteratorResult<{ eventType: string; filename: string | Buffer | null }> > { - // deno-lint-ignore prefer-primordials - const iterResult = await fsIterable.next(); - if (iterResult.done) return iterResult; + while (true) { + // deno-lint-ignore prefer-primordials + const iterResult = await fsIterable.next(); + if (iterResult.done) return iterResult; - const eventType = convertDenoFsEventToNodeFsEvent( - iterResult.value.kind, - ); - const fname = recursive - ? relative(resolvedWatchPath, iterResult.value.paths[0]) - : basename(iterResult.value.paths[0]); - return { - value: { eventType, filename: fname }, - done: false, - }; + const eventType = convertDenoFsEventToNodeFsEvent( + iterResult.value.kind, + ); + const fname = recursive + ? relative(resolvedWatchPath, iterResult.value.paths[0]) + : basename(iterResult.value.paths[0]); + if (ignoreMatcher !== null && ignoreMatcher(fname)) { + continue; + } + return { + value: { eventType, filename: fname }, + done: false, + }; + } }, // deno-lint-ignore no-explicit-any return(value?: any): Promise> { diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 93a58f911a2cc9..6a16e5ee9d8703 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -1089,6 +1089,11 @@ "parallel/test-fs-promises-readfile-empty.js": {}, "parallel/test-fs-promises-readfile-with-fd.js": {}, "parallel/test-fs-promises-statfs-validate-path.js": {}, + "parallel/test-fs-promises-watch-ignore-function.mjs": {}, + "parallel/test-fs-promises-watch-ignore-glob.mjs": {}, + "parallel/test-fs-promises-watch-ignore-invalid.mjs": {}, + "parallel/test-fs-promises-watch-ignore-mixed.mjs": {}, + "parallel/test-fs-promises-watch-ignore-regexp.mjs": {}, "parallel/test-fs-promises-watch-iterator.js": {}, "parallel/test-fs-promises-write-optional-params.js": {}, "parallel/test-fs-promises-writefile-typedarray.js": {}, @@ -1190,6 +1195,15 @@ "parallel/test-fs-utimes-y2K38.js": {}, "parallel/test-fs-utimes.js": {}, "parallel/test-fs-watch-file-enoent-after-deletion.js": {}, + "parallel/test-fs-watch-ignore-function.js": {}, + "parallel/test-fs-watch-ignore-glob.js": {}, + "parallel/test-fs-watch-ignore-invalid.js": {}, + "parallel/test-fs-watch-ignore-mixed.js": {}, + "parallel/test-fs-watch-ignore-recursive-glob-subdirectories.js": {}, + "parallel/test-fs-watch-ignore-recursive-glob.js": {}, + "parallel/test-fs-watch-ignore-recursive-mixed.js": {}, + "parallel/test-fs-watch-ignore-recursive-regexp.js": {}, + "parallel/test-fs-watch-ignore-regexp.js": {}, "parallel/test-fs-watch-recursive-add-file-to-existing-subfolder.js": {}, "parallel/test-fs-watch-recursive-add-folder.js": {}, "parallel/test-fs-watch-recursive-delete.js": {}, From 012384bd267770a0f587c0495418ddadfdbb0e95 Mon Sep 17 00:00:00 2001 From: Nathan Whitaker Date: Mon, 27 Apr 2026 23:24:21 +0000 Subject: [PATCH 2/2] fix(ext/node): use primordials in fs.watch ignore matchers Replaces `instanceof RegExp` with `ObjectPrototypeIsPrototypeOf(RegExpPrototype, ...)`, `Array.isArray` with `ArrayIsArray`, RegExp.test with `RegExpPrototypeTest`, and `Array.push` with `ArrayPrototypePush`, plus the `any` lint-ignore on the lazy-loader cell. --- ext/node/polyfills/fs.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/ext/node/polyfills/fs.ts b/ext/node/polyfills/fs.ts index 1ee323929e43a0..92d920257c7546 100644 --- a/ext/node/polyfills/fs.ts +++ b/ext/node/polyfills/fs.ts @@ -155,6 +155,7 @@ import { core, primordials } from "ext:core/mod.js"; const { ArrayBufferIsView, + ArrayIsArray, BigInt, DatePrototypeGetTime, DateUTC, @@ -175,6 +176,8 @@ const { Promise, PromisePrototypeThen, PromiseResolve, + RegExpPrototype, + RegExpPrototypeTest, SafeMap, StringPrototypeToString, SymbolAsyncIterator, @@ -3141,6 +3144,7 @@ type IgnoreOption = | undefined | null; +// deno-lint-ignore no-explicit-any let _lazyMinimatch: any = null; function getMinimatch() { _lazyMinimatch ??= core.createLazyLoader("ext:deno_node/deps/minimatch.js"); @@ -3158,7 +3162,7 @@ function validateIgnoreOptionElement(value: unknown, name: string) { } return; } - if (value instanceof RegExp) return; + if (ObjectPrototypeIsPrototypeOf(RegExpPrototype, value)) return; if (typeof value === "function") return; throw new ERR_INVALID_ARG_TYPE( name, @@ -3169,7 +3173,7 @@ function validateIgnoreOptionElement(value: unknown, name: string) { function validateIgnoreOption(value: unknown, name: string) { if (value == null) return; - if (Array.isArray(value)) { + if (ArrayIsArray(value)) { for (let i = 0; i < value.length; i++) { validateIgnoreOptionElement(value[i], `${name}[${i}]`); } @@ -3182,7 +3186,7 @@ function createIgnoreMatcher( ignore: IgnoreOption, ): ((filename: string) => boolean) | null { if (ignore == null) return null; - const matchers = Array.isArray(ignore) ? ignore : [ignore]; + const matchers = ArrayIsArray(ignore) ? ignore : [ignore]; const compiled: Array<(filename: string) => boolean> = []; for (let i = 0; i < matchers.length; i++) { @@ -3200,12 +3204,19 @@ function createIgnoreMatcher( // e.g. '*.log' matches 'subdir/file.log'. matchBase: true, }); - compiled.push((filename: string) => mm.match(filename)); - } else if (matcher instanceof RegExp) { - compiled.push((filename: string) => matcher.test(filename)); + ArrayPrototypePush( + compiled, + // deno-lint-ignore prefer-primordials + (filename: string) => mm.match(filename), + ); + } else if (ObjectPrototypeIsPrototypeOf(RegExpPrototype, matcher)) { + ArrayPrototypePush( + compiled, + (filename: string) => RegExpPrototypeTest(matcher as RegExp, filename), + ); } else { // Function - compiled.push(matcher as (filename: string) => boolean); + ArrayPrototypePush(compiled, matcher as (filename: string) => boolean); } }