From c8c6da0aaba461af359070060b14a9b157f24aa7 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 12:10:01 +0200 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20improve=20`.watch(?= =?UTF-8?q?)`=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/fs-fsa-to-node/src/FsaNodeFs.ts | 2 +- packages/fs-node-utils/src/types/FsCallbackApi.ts | 13 ++++++++----- packages/fs-node/src/__tests__/volume.test.ts | 10 +++++----- packages/fs-node/src/volume.ts | 10 +++++++--- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/fs-fsa-to-node/src/FsaNodeFs.ts b/packages/fs-fsa-to-node/src/FsaNodeFs.ts index ba301213d..bfd817a96 100644 --- a/packages/fs-fsa-to-node/src/FsaNodeFs.ts +++ b/packages/fs-fsa-to-node/src/FsaNodeFs.ts @@ -886,7 +886,7 @@ export class FsaNodeFs extends FsaNodeCore implements FsCallbackApi, FsSynchrono } }; - public readonly watch = ( + public readonly watch: FsCallbackApi['watch'] = ( path: misc.PathLike, options?: opts.IWatchOptions | string | ((eventType: string, filename: string) => void), listener?: (eventType: string, filename: string) => void, diff --git a/packages/fs-node-utils/src/types/FsCallbackApi.ts b/packages/fs-node-utils/src/types/FsCallbackApi.ts index 5fc1a96f8..231e1d554 100644 --- a/packages/fs-node-utils/src/types/FsCallbackApi.ts +++ b/packages/fs-node-utils/src/types/FsCallbackApi.ts @@ -144,11 +144,14 @@ export interface FsCallbackApi { unlink: (path: misc.PathLike, callback: misc.TCallback) => void; unwatchFile: (path: misc.PathLike, listener?: (curr: misc.IStats, prev: misc.IStats) => void) => void; utimes: (path: misc.PathLike, atime: misc.TTime, mtime: misc.TTime, callback: misc.TCallback) => void; - watch: ( - path: misc.PathLike, - options?: opts.IWatchOptions | string, - listener?: (eventType: string, filename: string) => void, - ) => misc.IFSWatcher; + watch: { + (path: misc.PathLike, listener?: (eventType: string, filename: string) => void): misc.IFSWatcher; + ( + path: misc.PathLike, + options: opts.IWatchOptions | string | undefined, + listener?: (eventType: string, filename: string) => void, + ): misc.IFSWatcher; + }; watchFile: { (path: misc.PathLike, listener: (curr: misc.IStats, prev: misc.IStats) => void): misc.IStatWatcher; ( diff --git a/packages/fs-node/src/__tests__/volume.test.ts b/packages/fs-node/src/__tests__/volume.test.ts index d3cad3f32..ec27bb207 100644 --- a/packages/fs-node/src/__tests__/volume.test.ts +++ b/packages/fs-node/src/__tests__/volume.test.ts @@ -1371,7 +1371,7 @@ describe('volume', () => { const mockCallback = jest.fn(); const writtenContent = 'hello world'; - const watcher = vol.watch('/tmp/foo.js', mockCallback as any); + const watcher = vol.watch('/tmp/foo.js', mockCallback); try { vol.writeFileSync('/tmp/foo.js', writtenContent); @@ -1389,7 +1389,7 @@ describe('volume', () => { const mockCallback = jest.fn(); const writtenContent = 'hello world'; - const watcher = vol.watch('/tmp/foo-dir', mockCallback as any); + const watcher = vol.watch('/tmp/foo-dir', mockCallback); try { vol.writeFileSync('/tmp/foo-dir/foo.js', writtenContent); @@ -1406,7 +1406,7 @@ describe('volume', () => { const vol = Volume.fromJSON({ '/1': null }); const mockCallback = jest.fn(); - const watcher = vol.watch('/', mockCallback as any); + const watcher = vol.watch('/', mockCallback); try { expect(() => vol.renameSync('/1', '/2')).not.toThrow(); @@ -1510,7 +1510,7 @@ describe('volume', () => { const vol = Volume.fromJSON({ '/foo.txt': 'a' }); const listener = jest.fn(); const onClose = jest.fn(); - const watcher = vol.watch('/foo.txt', listener as any); + const watcher = vol.watch('/foo.txt', listener); watcher.on('close', onClose); watcher.close(); watcher.close(); @@ -1534,7 +1534,7 @@ describe('volume', () => { it('returns the watcher and still delivers events while unref-ed', () => { const vol = Volume.fromJSON({ '/foo.txt': 'a' }); const listener = jest.fn(); - const watcher = vol.watch('/foo.txt', listener as any); + const watcher = vol.watch('/foo.txt', listener); try { expect(watcher.unref()).toBe(watcher); vol.writeFileSync('/foo.txt', 'b'); diff --git a/packages/fs-node/src/volume.ts b/packages/fs-node/src/volume.ts index f167a2c7b..549734bc6 100644 --- a/packages/fs-node/src/volume.ts +++ b/packages/fs-node/src/volume.ts @@ -1424,11 +1424,15 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { return new this.WriteStream(path, options); } - // watch(path: PathLike): FSWatcher; - // watch(path: PathLike, options?: IWatchOptions | string): FSWatcher; + watch(path: PathLike, listener?: (eventType: string, filename: string) => void): FSWatcher; watch( path: PathLike, - options?: IWatchOptions | string, + options: IWatchOptions | string | undefined, + listener?: (eventType: string, filename: string) => void, + ): FSWatcher; + watch( + path: PathLike, + options?: IWatchOptions | string | ((eventType: string, filename: string) => void), listener?: (eventType: string, filename: string) => void, ): FSWatcher { const filename = pathToFilename(path); From baff16dd7b9807676d003a325a6e8a52956aaeb7 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 12:11:45 +0200 Subject: [PATCH 02/14] =?UTF-8?q?ci:=20=F0=9F=8E=A1=20add=20Node.js=20v26?= =?UTF-8?q?=20in=20test=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ac7c1c279..8f0eff761 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macOS-latest] - node-version: [22.x, 24.x] + node-version: [22.x, 24.x, 26.x] runs-on: ${{ matrix.os }} steps: From a99c1f96d1b1868b2c1921540b82024aa76082f4 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 12:13:40 +0200 Subject: [PATCH 03/14] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20create=20a=20s?= =?UTF-8?q?hared=20watch-ignore=20matcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/fs-node-utils/package.json | 3 ++- packages/fs-node-utils/src/index.ts | 1 + packages/fs-node-utils/src/watchIgnore.ts | 27 +++++++++++++++++++ packages/fs-node/src/volume.ts | 33 ++++++----------------- yarn.lock | 1 + 5 files changed, 39 insertions(+), 26 deletions(-) create mode 100644 packages/fs-node-utils/src/watchIgnore.ts diff --git a/packages/fs-node-utils/package.json b/packages/fs-node-utils/package.json index a05ea7ea6..65a7bc2c2 100644 --- a/packages/fs-node-utils/package.json +++ b/packages/fs-node-utils/package.json @@ -83,6 +83,7 @@ "tslib": "2" }, "dependencies": { - "@jsonjoy.com/fs-node-builtins": "workspace:*" + "@jsonjoy.com/fs-node-builtins": "workspace:*", + "glob-to-regex.js": "^1.0.1" } } diff --git a/packages/fs-node-utils/src/index.ts b/packages/fs-node-utils/src/index.ts index a1e22bf7c..250ed1d2e 100644 --- a/packages/fs-node-utils/src/index.ts +++ b/packages/fs-node-utils/src/index.ts @@ -4,3 +4,4 @@ export * from './consts/AMODE'; export * from './consts/FLAG'; export * from './path'; export * from './encoding'; +export * from './watchIgnore'; diff --git a/packages/fs-node-utils/src/watchIgnore.ts b/packages/fs-node-utils/src/watchIgnore.ts new file mode 100644 index 000000000..71f96c3bc --- /dev/null +++ b/packages/fs-node-utils/src/watchIgnore.ts @@ -0,0 +1,27 @@ +import { toRegex } from 'glob-to-regex.js'; +import type { TWatchIgnorePattern } from './types/options'; + +/** Converts a single `watch()` `ignore` pattern into a filename predicate. */ +export const watchIgnorePatternToMatcher = (pattern: TWatchIgnorePattern): ((filename: string) => boolean) => { + if (typeof pattern === 'function') return pattern; + if (pattern instanceof RegExp) return filename => pattern.test(filename); + if (typeof pattern === 'string') { + const regex = toRegex(pattern); + return filename => regex.test(filename); + } + throw new TypeError( + 'The "options.ignore" property must be of type string, RegExp, function, or an array thereof. ' + + `Received ${typeof pattern}`, + ); +}; + +/** Converts the `watch()` `ignore` option (pattern or array of patterns) into a filename predicate. */ +export const watchIgnoreToMatcher = ( + ignore: TWatchIgnorePattern | TWatchIgnorePattern[], +): ((filename: string) => boolean) => { + if (Array.isArray(ignore)) { + const matchers = ignore.map(watchIgnorePatternToMatcher); + return filename => matchers.some(matcher => matcher(filename)); + } + return watchIgnorePatternToMatcher(ignore); +}; diff --git a/packages/fs-node/src/volume.ts b/packages/fs-node/src/volume.ts index 549734bc6..e330af268 100644 --- a/packages/fs-node/src/volume.ts +++ b/packages/fs-node/src/volume.ts @@ -28,14 +28,20 @@ import { type IProcess, } from '@jsonjoy.com/fs-core'; import { isWin } from '@jsonjoy.com/fs-core/lib/util'; -import { toRegex } from 'glob-to-regex.js'; import Stats from './Stats'; import Dirent from './Dirent'; import StatFs from './StatFs'; import { Buffer, bufferAllocUnsafe, bufferFrom } from '@jsonjoy.com/fs-node-builtins/lib/internal/buffer'; import setTimeoutUnref, { TSetTimeout } from '@jsonjoy.com/fs-node-utils/lib/setTimeoutUnref'; import { Readable, Writable } from '@jsonjoy.com/fs-node-builtins/lib/stream'; -import { constants, TEncodingExtended, TDataOut, strToEncoding, ENCODING_UTF8 } from '@jsonjoy.com/fs-node-utils'; +import { + constants, + TEncodingExtended, + TDataOut, + strToEncoding, + ENCODING_UTF8, + watchIgnoreToMatcher, +} from '@jsonjoy.com/fs-node-utils'; import { EventEmitter } from '@jsonjoy.com/fs-node-builtins/lib/events'; import { FileHandle } from './FileHandle'; import { inherits } from '@jsonjoy.com/fs-node-builtins/lib/util'; @@ -2147,29 +2153,6 @@ FsWriteStream.prototype.destroySoon = FsWriteStream.prototype.end; // ---------------------------------------- FSWatcher -const watchIgnorePatternToMatcher = (pattern: opts.TWatchIgnorePattern): ((filename: string) => boolean) => { - if (typeof pattern === 'function') return pattern; - if (pattern instanceof RegExp) return filename => pattern.test(filename); - if (typeof pattern === 'string') { - const regex = toRegex(pattern); - return filename => regex.test(filename); - } - throw new TypeError( - 'The "options.ignore" property must be of type string, RegExp, function, or an array thereof. ' + - `Received ${typeof pattern}`, - ); -}; - -const watchIgnoreToMatcher = ( - ignore: opts.TWatchIgnorePattern | opts.TWatchIgnorePattern[], -): ((filename: string) => boolean) => { - if (Array.isArray(ignore)) { - const matchers = ignore.map(watchIgnorePatternToMatcher); - return filename => matchers.some(matcher => matcher(filename)); - } - return watchIgnorePatternToMatcher(ignore); -}; - export class FSWatcher extends EventEmitter { _vol: Volume; _filename: string = ''; diff --git a/yarn.lock b/yarn.lock index 80139e53b..bc54b314e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -912,6 +912,7 @@ __metadata: "@jsonjoy.com/fs-node-builtins": "workspace:*" "@types/jest": "npm:^29.0.0" "@types/node": "npm:^20.0.0" + glob-to-regex.js: "npm:^1.0.1" jest: "npm:^29.0.0" rimraf: "npm:^5.0.0" ts-jest: "npm:^29.4.2" From 2005ea835081145d5d7d3e0bc569c95efbeb220e Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 12:18:12 +0200 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20`signal,=20t?= =?UTF-8?q?hrowIfNoEntry,=20ignore`=20support=20for=20FSA=20watcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/fs-fsa-to-node/src/FsaNodeFs.ts | 13 +- .../fs-fsa-to-node/src/FsaNodeFsWatcher.ts | 19 ++- .../src/__tests__/watch.test.ts | 152 ++++++++++++++++++ 3 files changed, 179 insertions(+), 5 deletions(-) diff --git a/packages/fs-fsa-to-node/src/FsaNodeFs.ts b/packages/fs-fsa-to-node/src/FsaNodeFs.ts index bfd817a96..69e137f5d 100644 --- a/packages/fs-fsa-to-node/src/FsaNodeFs.ts +++ b/packages/fs-fsa-to-node/src/FsaNodeFs.ts @@ -898,11 +898,20 @@ export class FsaNodeFs extends FsaNodeCore implements FsCallbackApi, FsSynchrono } const watchOpts: opts.IWatchOptions = typeof options === 'string' ? { encoding: options as BufferEncoding } : options || {}; - const { persistent = true, recursive = false, encoding = 'utf8' } = watchOpts; + const { persistent = true, recursive = false, encoding = 'utf8', signal, throwIfNoEntry, ignore } = watchOpts; const Observer = this.getFileSystemObserverOrThrow(); const watcher = new FsaNodeFsWatcher(Observer, (folder, name) => this.getFileOrDir(folder, name, 'watch')); - watcher.start(filename, persistent, recursive, encoding as BufferEncoding); + watcher.start(filename, persistent, recursive, encoding as BufferEncoding, ignore, throwIfNoEntry); if (listener) watcher.addListener('change', listener); + if (signal) { + if (signal.aborted) { + watcher.close(); + } else { + const onAbort = () => watcher.close(); + signal.addEventListener('abort', onAbort, { once: true }); + watcher.once('close', () => signal.removeEventListener('abort', onAbort)); + } + } return watcher; }; diff --git a/packages/fs-fsa-to-node/src/FsaNodeFsWatcher.ts b/packages/fs-fsa-to-node/src/FsaNodeFsWatcher.ts index 3f5aada55..fa7b9b117 100644 --- a/packages/fs-fsa-to-node/src/FsaNodeFsWatcher.ts +++ b/packages/fs-fsa-to-node/src/FsaNodeFsWatcher.ts @@ -1,9 +1,10 @@ import { EventEmitter } from '@jsonjoy.com/fs-node-builtins/lib/events'; -import { strToEncoding } from '@jsonjoy.com/fs-node-utils'; +import { strToEncoding, watchIgnoreToMatcher } from '@jsonjoy.com/fs-node-utils'; import { pathToLocation } from './util'; import { FsaToNodeConstants } from './constants'; import type * as fsa from '@jsonjoy.com/fs-fsa'; import type * as misc from '@jsonjoy.com/fs-node-utils/lib/types/misc'; +import type * as opts from '@jsonjoy.com/fs-node-utils/lib/types/options'; export type FsaNodeWatchHandleResolver = ( folder: string[], @@ -15,7 +16,9 @@ export type FsaNodeWatchHandleResolver = ( * `FileSystemObserver`. The observer backend is asynchronous, so startup * errors (e.g. the watched path does not exist) are emitted as an `'error'` * event on the watcher, instead of being thrown synchronously the way the - * Node.js API does. + * Node.js API does. For the same reason `throwIfNoEntry: false` suppresses + * the asynchronous ENOENT `'error'` event, leaving a never-started watcher + * whose `close()` is a silent no-op, mirroring what `fs.watch` returns. */ export class FsaNodeFsWatcher extends EventEmitter implements misc.IFSWatcher { protected observer: fsa.IFileSystemObserver | undefined; @@ -23,6 +26,7 @@ export class FsaNodeFsWatcher extends EventEmitter implements misc.IFSWatcher { protected encoding: BufferEncoding = 'utf8'; protected filename: string = ''; protected basename: string = ''; + private _ignore: ((filename: string) => boolean) | undefined; constructor( protected readonly Observer: fsa.IFileSystemObserverConstructable, @@ -36,9 +40,12 @@ export class FsaNodeFsWatcher extends EventEmitter implements misc.IFSWatcher { persistent: boolean = true, recursive: boolean = false, encoding: BufferEncoding = 'utf8', + ignore?: opts.TWatchIgnorePattern | opts.TWatchIgnorePattern[], + throwIfNoEntry?: boolean, ): void { this.filename = String(path); this.encoding = encoding; + this._ignore = ignore === undefined ? undefined : watchIgnoreToMatcher(ignore); const [folder, name] = pathToLocation(this.filename); this.basename = name; const observer = new this.Observer(records => this.onRecords(records)); @@ -51,8 +58,13 @@ export class FsaNodeFsWatcher extends EventEmitter implements misc.IFSWatcher { }) .catch(error => { if (this.closed) return; - this.close(); const code = error && typeof error === 'object' ? error.code : undefined; + if (throwIfNoEntry === false && code === 'ENOENT') { + this.closed = true; + this.observer = undefined; + return; + } + this.close(); const wrapped = new Error(`watch ${this.filename} ${code ?? ''}`.trimEnd()); (wrapped as any).code = code; this.emit('error', wrapped); @@ -84,6 +96,7 @@ export class FsaNodeFsWatcher extends EventEmitter implements misc.IFSWatcher { private _emit(type: 'rename' | 'change', steps: string[]): void { const filename = steps.length ? steps.join(FsaToNodeConstants.Separator) : this.basename; + if (this._ignore && this._ignore(filename)) return; this.emit('change', type, strToEncoding(filename, this.encoding)); } diff --git a/packages/fs-fsa-to-node/src/__tests__/watch.test.ts b/packages/fs-fsa-to-node/src/__tests__/watch.test.ts index e3609c976..7fa61067b 100644 --- a/packages/fs-fsa-to-node/src/__tests__/watch.test.ts +++ b/packages/fs-fsa-to-node/src/__tests__/watch.test.ts @@ -164,4 +164,156 @@ onlyOnNode20('FsaNodeFs.watch()', () => { expect(events).toEqual([]); expect(closed).toBe(1); }); + + describe('signal option', () => { + test('aborting the signal closes the watcher and emits "close"', async () => { + const { fs, dir } = setup(); + const events: unknown[] = []; + const controller = new AbortController(); + let closed = 0; + const watcher = fs.watch('/', { signal: controller.signal }, (...args) => events.push(args)); + watcher.on('close', () => closed++); + await tick(); + controller.abort(); + await until(() => closed >= 1); + await dir.getFileHandle('file.txt', { create: true }); + await tick(5); + expect(events).toEqual([]); + }); + + test('returns an already-closed watcher when the signal is pre-aborted', async () => { + const { fs, dir } = setup(); + const events: unknown[] = []; + const controller = new AbortController(); + controller.abort(); + let closed = 0; + const watcher = fs.watch('/', { signal: controller.signal }, (...args) => events.push(args)); + watcher.on('close', () => closed++); + await until(() => closed >= 1); + await dir.getFileHandle('file.txt', { create: true }); + await tick(5); + expect(events).toEqual([]); + }); + + test('aborting after a manual close() does not emit "close" twice', async () => { + const { fs } = setup(); + const controller = new AbortController(); + let closed = 0; + const watcher = fs.watch('/', { signal: controller.signal }); + watcher.on('close', () => closed++); + await tick(); + watcher.close(); + await until(() => closed >= 1); + controller.abort(); + await tick(5); + expect(closed).toBe(1); + }); + }); + + describe('throwIfNoEntry option', () => { + test('emits "error" for a missing path when true', async () => { + const { fs } = setup(); + const errors: any[] = []; + const watcher = fs.watch('/missing.txt', { throwIfNoEntry: true }); + watcher.on('error', error => errors.push(error)); + await until(() => errors.length >= 1); + expect(errors[0].code).toBe('ENOENT'); + }); + + test('suppresses the ENOENT "error" when false and leaves an inert watcher', async () => { + const { fs, dir } = setup(); + const events: unknown[] = []; + const errors: unknown[] = []; + let closed = 0; + const watcher = fs.watch('/missing.txt', { throwIfNoEntry: false }, (...args) => events.push(args)); + watcher.on('error', error => errors.push(error)); + watcher.on('close', () => closed++); + await tick(5); + await dir.getFileHandle('missing.txt', { create: true }); + await tick(5); + watcher.close(); + await tick(5); + expect(errors).toEqual([]); + expect(events).toEqual([]); + expect(closed).toBe(0); + }); + + test('suppresses only ENOENT; other errors are still emitted', async () => { + const { fs, dir } = setup(); + await dir.getFileHandle('file.txt', { create: true }); + const errors: any[] = []; + const watcher = fs.watch('/file.txt/sub', { throwIfNoEntry: false }); + watcher.on('error', error => errors.push(error)); + await until(() => errors.length >= 1); + expect(errors[0].code).toBe('ENOTDIR'); + }); + }); + + describe('ignore option', () => { + test('filters events by glob string pattern', async () => { + const { fs, dir } = setup(); + const events: [string, unknown][] = []; + const watcher = fs.watch('/', { ignore: '*.log' }, (eventType, filename) => events.push([eventType, filename])); + await tick(); + await dir.getFileHandle('a.log', { create: true }); + await tick(5); + expect(events).toEqual([]); + await dir.getFileHandle('a.txt', { create: true }); + await until(() => events.length >= 1); + expect(events).toEqual([['rename', 'a.txt']]); + watcher.close(); + }); + + test('accepts an array mixing glob, RegExp, and function patterns', async () => { + const { fs, dir } = setup(); + const events: [string, unknown][] = []; + const ignore = ['*.log', /^skip/, (filename: string) => filename.endsWith('.tmp')]; + const watcher = fs.watch('/', { ignore }, (eventType, filename) => events.push([eventType, filename])); + await tick(); + await dir.getFileHandle('a.log', { create: true }); + await dir.getFileHandle('skip.txt', { create: true }); + await dir.getFileHandle('b.tmp', { create: true }); + await tick(5); + expect(events).toEqual([]); + await dir.getFileHandle('keep.txt', { create: true }); + await until(() => events.length >= 1); + expect(events).toEqual([['rename', 'keep.txt']]); + watcher.close(); + }); + + test('matches relative paths in recursive mode', async () => { + const { fs, dir } = setup(); + const subdir = await dir.getDirectoryHandle('subdir', { create: true }); + const events: [string, unknown][] = []; + const watcher = fs.watch('/', { recursive: true, ignore: '**/*.tmp' }, (eventType, filename) => + events.push([eventType, filename]), + ); + await tick(); + await subdir.getFileHandle('a.tmp', { create: true }); + await tick(5); + expect(events).toEqual([]); + await subdir.getFileHandle('a.txt', { create: true }); + await until(() => events.length >= 1); + expect(events).toEqual([['rename', 'subdir/a.txt']]); + watcher.close(); + }); + + test('filters each half of a rename independently', async () => { + const { fs, core, dir } = setup(); + await dir.getFileHandle('a.txt', { create: true }); + const events: [string, unknown][] = []; + const watcher = fs.watch('/', { ignore: '*.bak' }, (eventType, filename) => events.push([eventType, filename])); + await tick(); + core.rename('/a.txt', '/a.bak'); + await until(() => events.length >= 1); + await tick(5); + expect(events).toEqual([['rename', 'a.txt']]); + watcher.close(); + }); + + test('throws TypeError for an invalid pattern type', () => { + const { fs } = setup(); + expect(() => fs.watch('/', { ignore: 42 as any })).toThrow(TypeError); + }); + }); }); From 051579cad8d19781421e21660f4ba2207f67c6d9 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 12:23:38 +0200 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20on=20abort=20signa?= =?UTF-8?q?l=20feed=20in=20an=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/fs-node/src/FsPromises.ts | 23 +++++--- .../fs-node/src/__tests__/promises.test.ts | 54 +++++++++++++++---- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/packages/fs-node/src/FsPromises.ts b/packages/fs-node/src/FsPromises.ts index 1480138c8..5398a4249 100644 --- a/packages/fs-node/src/FsPromises.ts +++ b/packages/fs-node/src/FsPromises.ts @@ -4,6 +4,14 @@ import type * as opts from '@jsonjoy.com/fs-node-utils/lib/types/options'; import type * as misc from '@jsonjoy.com/fs-node-utils/lib/types/misc'; import type { FsCallbackApi, FsPromisesApi } from '@jsonjoy.com/fs-node-utils'; +const newAbortError = (signal: AbortSignal): Error => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + (error as any).code = 'ABORT_ERR'; + (error as any).cause = signal.reason; + return error; +}; + // AsyncIterator implementation for promises.watch class FSWatchAsyncIterator implements AsyncIterableIterator<{ eventType: string; filename: string | Buffer }> { private watcher: any; @@ -28,15 +36,16 @@ class FSWatchAsyncIterator implements AsyncIterableIterator<{ eventType: string; // 'error', both are accepted here with the same semantics. this.overflow = overflow === 'throw' ? 'error' : overflow; this.startWatching(); - - // Handle AbortSignal - if (options.signal) { - if (options.signal.aborted) { - this.finish(); + // Aborting rejects the pending (or first future) next() call with an + // AbortError and discards queued events. + const signal = options.signal; + if (signal) { + if (signal.aborted) { + this.finish(newAbortError(signal)); return; } - options.signal.addEventListener('abort', () => { - this.finish(); + signal.addEventListener('abort', () => { + this.finish(newAbortError(signal)); }); } } diff --git a/packages/fs-node/src/__tests__/promises.test.ts b/packages/fs-node/src/__tests__/promises.test.ts index d59a69387..49c310427 100644 --- a/packages/fs-node/src/__tests__/promises.test.ts +++ b/packages/fs-node/src/__tests__/promises.test.ts @@ -851,22 +851,61 @@ describe('Promises API', () => { expect(events[0].filename).toBe('foo'); }); - it('Supports AbortSignal', async () => { + it('rejects the first next() with AbortError when the signal is pre-aborted', async () => { const vol = new Volume(); const { promises } = vol; vol.fromJSON({ '/foo': 'bar', }); - const abortController = new AbortController(); + abortController.abort(); const watcher = promises.watch('/foo', { signal: abortController.signal }); + const iterator = watcher[Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toMatchObject({ + name: 'AbortError', + code: 'ABORT_ERR', + message: 'The operation was aborted', + }); + const after = await iterator.next(); + expect(after.done).toBe(true); + }); - // Abort immediately - abortController.abort(); + it('rejects a pending next() with AbortError when the signal aborts', async () => { + const vol = new Volume(); + const { promises } = vol; + vol.fromJSON({ + '/foo': 'bar', + }); + const abortController = new AbortController(); + const watcher = promises.watch('/foo', { signal: abortController.signal }); + const iterator = watcher[Symbol.asyncIterator](); + const pending = iterator.next(); + setTimeout(() => abortController.abort(), 5); + await expect(pending).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }); + const after = await iterator.next(); + expect(after.done).toBe(true); + }); + it('abort discards queued events and carries the abort reason as cause', async () => { + const vol = new Volume(); + const { promises } = vol; + vol.mkdirSync('/dir'); + const abortController = new AbortController(); + const watcher = promises.watch('/dir', { signal: abortController.signal }); + vol.writeFileSync('/dir/a.txt', '1'); + const reason = new Error('stop watching'); + abortController.abort(reason); const iterator = watcher[Symbol.asyncIterator](); - const result = await iterator.next(); - expect(result.done).toBe(true); + let error: any; + try { + await iterator.next(); + } catch (err) { + error = err; + } + expect(error.name).toBe('AbortError'); + expect(error.cause).toBe(reason); + const after = await iterator.next(); + expect(after.done).toBe(true); }); it('Handles overflow with ignore strategy: keeps oldest events, drops newest', async () => { @@ -876,18 +915,15 @@ describe('Promises API', () => { const emitWarning = jest.spyOn(process, 'emitWarning').mockImplementation(() => {}); try { const watcher = promises.watch('/dir', { maxQueue: 1, overflow: 'ignore' }); - vol.writeFileSync('/dir/a.txt', '1'); vol.writeFileSync('/dir/b.txt', '1'); vol.writeFileSync('/dir/c.txt', '1'); - const iterator = watcher[Symbol.asyncIterator](); const result1 = await iterator.next(); expect(result1.done).toBe(false); expect(result1.value).toEqual({ eventType: 'rename', filename: 'a.txt' }); expect(emitWarning).toBeCalledTimes(5); expect(emitWarning).toBeCalledWith('fs.watch maxQueue exceeded'); - if (iterator.return) { await iterator.return(); } From 7f04c451929f307a73419ba5e049268e7ff0cfcb Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 12:29:11 +0200 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20deprecate=20old=20?= =?UTF-8?q?`.changes`=20reactivity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/fs-core/src/Link.ts | 4 ++++ packages/fs-core/src/Node.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/fs-core/src/Link.ts b/packages/fs-core/src/Link.ts index dfbb8b302..f4a215639 100644 --- a/packages/fs-core/src/Link.ts +++ b/packages/fs-core/src/Link.ts @@ -15,6 +15,10 @@ const { S_IFREG } = constants; * Represents a hard link that points to an i-node `node`. */ export class Link { + /** + * @deprecated Subscribe to the volume-level `Superblock.changes` bus or use + * `CoreWatcher` instead. + */ public readonly changes = new FanOut(); vol: Superblock; diff --git a/packages/fs-core/src/Node.ts b/packages/fs-core/src/Node.ts index ed16dc709..6de0c0f3d 100644 --- a/packages/fs-core/src/Node.ts +++ b/packages/fs-core/src/Node.ts @@ -18,6 +18,10 @@ const EMPTY_BUFFER = bufferAllocUnsafe(0); * Node in a file system (like i-node, v-node). */ export class Node { + /** + * @deprecated Subscribe to the volume-level `Superblock.changes` bus or use + * `CoreWatcher` instead. + */ public readonly changes = new FanOut(); // i-node number. From e47548a139f94228181b51d7016b64b04d6030d9 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 12:43:12 +0200 Subject: [PATCH 07/14] =?UTF-8?q?ci:=20=F0=9F=8E=A1=20add=20support=20for?= =?UTF-8?q?=20Node.js=20v26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/checks.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8f0eff761..90efb37de 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -29,8 +29,12 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + # Corepack is no longer bundled with Node.js (removed in v25), so install + # it into the setup-node tool dir - name: Enable corepack - run: corepack enable + run: | + npm install -g corepack + corepack enable - name: install run: yarn - name: run build From 9feca05ec883198ea93449f67b9dd28b4877c83c Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 12:44:35 +0200 Subject: [PATCH 08/14] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20add=20watche?= =?UTF-8?q?r=20README=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 5f8324ad1..d9d45dcb0 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,32 @@ npm i memfs - [Code reference](https://streamich.github.io/memfs/) - [Test coverage](https://streamich.github.io/memfs/coverage/lcov-report/) +## Watching + +All file-watching APIs are implemented on both sides of the `fs` adn File System API divide: + +| Feature | Package | Notes | +| -------------------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `fs.watch` — `recursive`, `encoding`, `signal`, `throwIfNoEntry`, `ignore` | [`@jsonjoy.com/fs-node`](packages/fs-node) | `'error'`/`'close'` events, `ref()`/`unref()` | +| `fs.watchFile` / `fs.unwatchFile` | [`@jsonjoy.com/fs-node`](packages/fs-node) | polling, `bigint`, Node's reappearance semantics | +| `fs.promises.watch` async iterator | [`@jsonjoy.com/fs-node`](packages/fs-node) | `maxQueue`, `overflow`, `AbortError` on abort | +| [`FileSystemObserver`][observer] | [`@jsonjoy.com/fs-fsa`](packages/fs-fsa) | deterministic OPFS profile: precise records, real `"moved"` records, microtask batching | +| `fs.watch`/`fs.watchFile` over a real FSA directory | [`@jsonjoy.com/fs-fsa-to-node`](packages/fs-fsa-to-node) | works over OPFS in Chrome 133+ via the native observer | +| `FileSystemObserver` over any `fs` | [`@jsonjoy.com/fs-node-to-fsa`](packages/fs-node-to-fsa) | best-effort profile: renames stat-classified, no `"moved"` records | + +Intentional divergences from real watchers (the in-memory backend is deterministic, so platform +unreliability is not emulated): + +- Paths are watched semantically rather than by inode: after delete-and-recreate, memfs reports + events for the recreated entry, while a real POSIX watcher keeps watching the dead inode. +- Exactly one event per logical operation — no platform duplicate events. +- `filename` is never `null`. +- The Node-style `FSWatcher` delivers events synchronously inside the mutating operation, whereas + real Node defers to the event loop; the `FileSystemObserver` batches per microtask, as the spec + requires. + +[observer]: https://developer.mozilla.org/en-US/docs/Web/API/FileSystemObserver + ## Demos - [Git in browser, which writes to a real folder](demo/git-fsa/README.md) From 47d63dbe3b6a9a2cb1afec3716c342f59d591a36 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 13:05:37 +0200 Subject: [PATCH 09/14] =?UTF-8?q?ci:=20=F0=9F=8E=A1=20try=20another=20Node?= =?UTF-8?q?.js=20v26=20corepack=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/checks.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 90efb37de..0a76a5cb2 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -32,9 +32,11 @@ jobs: # Corepack is no longer bundled with Node.js (removed in v25), so install # it into the setup-node tool dir - name: Enable corepack + shell: bash run: | npm install -g corepack - corepack enable + corepack enable --install-directory "$(dirname "$(command -v node)")" + yarn --version - name: install run: yarn - name: run build From 1c1035e4a91ba2e5f8e6b685565777ec8caa4c65 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 13:11:41 +0200 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20=F0=9F=90=9B=20improve=20abort=20s?= =?UTF-8?q?ignal=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/fs-node/src/FsPromises.ts | 19 +++++++------ .../fs-node/src/__tests__/promises.test.ts | 28 +++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/fs-node/src/FsPromises.ts b/packages/fs-node/src/FsPromises.ts index 5398a4249..8bf1a8fde 100644 --- a/packages/fs-node/src/FsPromises.ts +++ b/packages/fs-node/src/FsPromises.ts @@ -19,7 +19,7 @@ class FSWatchAsyncIterator implements AsyncIterableIterator<{ eventType: string; private resolveQueue: Array<{ resolve: Function; reject: Function }> = []; private finished = false; private error?: Error; - private abortController?: AbortController; + private onAbort?: () => void; private maxQueue: number; private overflow: 'ignore' | 'error'; @@ -35,24 +35,22 @@ class FSWatchAsyncIterator implements AsyncIterableIterator<{ eventType: string; // Node's docs name the value 'throw' while its implementation validates // 'error', both are accepted here with the same semantics. this.overflow = overflow === 'throw' ? 'error' : overflow; - this.startWatching(); - // Aborting rejects the pending (or first future) next() call with an - // AbortError and discards queued events. const signal = options.signal; if (signal) { if (signal.aborted) { this.finish(newAbortError(signal)); return; } - signal.addEventListener('abort', () => { - this.finish(newAbortError(signal)); - }); + this.onAbort = () => this.finish(newAbortError(signal)); + signal.addEventListener('abort', this.onAbort); } + this.startWatching(); } private startWatching() { + const { signal, ...watchOptions } = this.options; try { - this.watcher = this.fs.watch(this.path, this.options, (eventType: string, filename: string) => { + this.watcher = this.fs.watch(this.path, watchOptions, (eventType: string, filename: string) => { this.enqueueEvent({ eventType, filename }); }); } catch (error) { @@ -94,6 +92,11 @@ class FSWatchAsyncIterator implements AsyncIterableIterator<{ eventType: string; this.error = error; if (error) this.eventQueue.length = 0; + if (this.onAbort) { + this.options.signal!.removeEventListener('abort', this.onAbort); + this.onAbort = undefined; + } + if (this.watcher) { this.watcher.close(); this.watcher = null; diff --git a/packages/fs-node/src/__tests__/promises.test.ts b/packages/fs-node/src/__tests__/promises.test.ts index 49c310427..c82540b1c 100644 --- a/packages/fs-node/src/__tests__/promises.test.ts +++ b/packages/fs-node/src/__tests__/promises.test.ts @@ -1,3 +1,4 @@ +import { getEventListeners } from 'events'; import { promisify } from '@jsonjoy.com/fs-node-builtins/lib/util'; import { Volume } from '../volume'; import { Readable } from '@jsonjoy.com/fs-node-builtins/lib/stream'; @@ -908,6 +909,33 @@ describe('Promises API', () => { expect(after.done).toBe(true); }); + it('a pre-aborted signal never starts the watcher, so a missing path reports AbortError, not ENOENT', async () => { + const vol = new Volume(); + const { promises } = vol; + const abortController = new AbortController(); + abortController.abort(); + const watcher = promises.watch('/missing.txt', { signal: abortController.signal }); + const iterator = watcher[Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }); + }); + + it('removes the abort listener when the iterator finishes without abort', async () => { + const vol = new Volume(); + const { promises } = vol; + vol.fromJSON({ + '/foo': 'bar', + }); + const abortController = new AbortController(); + const watcher = promises.watch('/foo', { signal: abortController.signal }); + const iterator = watcher[Symbol.asyncIterator](); + expect(getEventListeners(abortController.signal, 'abort').length).toBe(1); + await iterator.return!(); + expect(getEventListeners(abortController.signal, 'abort').length).toBe(0); + abortController.abort(); + const after = await iterator.next(); + expect(after.done).toBe(true); + }); + it('Handles overflow with ignore strategy: keeps oldest events, drops newest', async () => { const vol = new Volume(); const { promises } = vol; From 8ee3d43bfd23ab3f08f74255ca67055fc22b1d02 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 13:17:04 +0200 Subject: [PATCH 11/14] =?UTF-8?q?ci:=20=F0=9F=8E=A1=20another=20attempt=20?= =?UTF-8?q?at=20making=20Node=20v26=20work=20with=20corepack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/checks.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0a76a5cb2..474267738 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -15,7 +15,7 @@ permissions: jobs: test: - name: Test on Node.js v${{ matrix.node-version }} + name: Test on Node.js v${{ matrix.node-version }} on ${{ matrix.os }} strategy: fail-fast: false matrix: @@ -34,7 +34,7 @@ jobs: - name: Enable corepack shell: bash run: | - npm install -g corepack + npm install -g --force corepack corepack enable --install-directory "$(dirname "$(command -v node)")" yarn --version - name: install From f0c1b2535fb224dd8f20e0f486b815c17a82aee0 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 13:47:49 +0200 Subject: [PATCH 12/14] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20fix=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d9d45dcb0..3df2ff7a0 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ npm i memfs ## Watching -All file-watching APIs are implemented on both sides of the `fs` adn File System API divide: +All file-watching APIs are implemented on both sides of the `fs` and File System API divide: | Feature | Package | Notes | | -------------------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------- | From 5058f8616ca39b4ad8509cf29bcad404908231d9 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 13:48:04 +0200 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20=F0=9F=90=9B=20disconnect=20observ?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/fs-fsa-to-node/src/FsaNodeFsWatcher.ts | 1 + .../fs-fsa-to-node/src/__tests__/watch.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/fs-fsa-to-node/src/FsaNodeFsWatcher.ts b/packages/fs-fsa-to-node/src/FsaNodeFsWatcher.ts index fa7b9b117..d9f3ff121 100644 --- a/packages/fs-fsa-to-node/src/FsaNodeFsWatcher.ts +++ b/packages/fs-fsa-to-node/src/FsaNodeFsWatcher.ts @@ -61,6 +61,7 @@ export class FsaNodeFsWatcher extends EventEmitter implements misc.IFSWatcher { const code = error && typeof error === 'object' ? error.code : undefined; if (throwIfNoEntry === false && code === 'ENOENT') { this.closed = true; + observer.disconnect(); this.observer = undefined; return; } diff --git a/packages/fs-fsa-to-node/src/__tests__/watch.test.ts b/packages/fs-fsa-to-node/src/__tests__/watch.test.ts index 7fa61067b..11bdf97a2 100644 --- a/packages/fs-fsa-to-node/src/__tests__/watch.test.ts +++ b/packages/fs-fsa-to-node/src/__tests__/watch.test.ts @@ -247,6 +247,21 @@ onlyOnNode20('FsaNodeFs.watch()', () => { await until(() => errors.length >= 1); expect(errors[0].code).toBe('ENOTDIR'); }); + + test('disconnects the never-started observer when the ENOENT is suppressed', async () => { + const { dir, FileSystemObserver } = fsa({ mode: 'readwrite' }); + let disconnects = 0; + class SpyObserver extends FileSystemObserver { + disconnect(): void { + disconnects++; + super.disconnect(); + } + } + const fs = new FsaNodeFs(dir, undefined, { FileSystemObserver: SpyObserver }); + fs.watch('/missing.txt', { throwIfNoEntry: false }); + await until(() => disconnects >= 1); + expect(disconnects).toBe(1); + }); }); describe('ignore option', () => { From d2ad15d591ace86482abd9edafa09b9ef20b90b0 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 8 Jul 2026 14:10:23 +0200 Subject: [PATCH 14/14] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20improve=20callback?= =?UTF-8?q?=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/fs-fsa-to-node/src/FsaNodeFs.ts | 6 +++--- packages/fs-node-utils/src/types/FsCallbackApi.ts | 5 +++++ packages/fs-node/src/FsPromises.ts | 2 +- packages/fs-node/src/volume.ts | 9 +++++++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/fs-fsa-to-node/src/FsaNodeFs.ts b/packages/fs-fsa-to-node/src/FsaNodeFs.ts index 69e137f5d..7ed33a8a3 100644 --- a/packages/fs-fsa-to-node/src/FsaNodeFs.ts +++ b/packages/fs-fsa-to-node/src/FsaNodeFs.ts @@ -888,12 +888,12 @@ export class FsaNodeFs extends FsaNodeCore implements FsCallbackApi, FsSynchrono public readonly watch: FsCallbackApi['watch'] = ( path: misc.PathLike, - options?: opts.IWatchOptions | string | ((eventType: string, filename: string) => void), - listener?: (eventType: string, filename: string) => void, + options?: opts.IWatchOptions | string | ((eventType: string, filename: string & Buffer) => void), + listener?: (eventType: string, filename: string & Buffer) => void, ): misc.IFSWatcher => { const filename = util.pathToFilename(path); if (typeof options === 'function') { - listener = options as (eventType: string, filename: string) => void; + listener = options as (eventType: string, filename: string & Buffer) => void; options = undefined; } const watchOpts: opts.IWatchOptions = diff --git a/packages/fs-node-utils/src/types/FsCallbackApi.ts b/packages/fs-node-utils/src/types/FsCallbackApi.ts index 231e1d554..32d916ba8 100644 --- a/packages/fs-node-utils/src/types/FsCallbackApi.ts +++ b/packages/fs-node-utils/src/types/FsCallbackApi.ts @@ -146,6 +146,11 @@ export interface FsCallbackApi { utimes: (path: misc.PathLike, atime: misc.TTime, mtime: misc.TTime, callback: misc.TCallback) => void; watch: { (path: misc.PathLike, listener?: (eventType: string, filename: string) => void): misc.IFSWatcher; + ( + path: misc.PathLike, + options: 'buffer' | (opts.IWatchOptions & { encoding: 'buffer' }), + listener?: (eventType: string, filename: Buffer) => void, + ): misc.IFSWatcher; ( path: misc.PathLike, options: opts.IWatchOptions | string | undefined, diff --git a/packages/fs-node/src/FsPromises.ts b/packages/fs-node/src/FsPromises.ts index 8bf1a8fde..42971608f 100644 --- a/packages/fs-node/src/FsPromises.ts +++ b/packages/fs-node/src/FsPromises.ts @@ -50,7 +50,7 @@ class FSWatchAsyncIterator implements AsyncIterableIterator<{ eventType: string; private startWatching() { const { signal, ...watchOptions } = this.options; try { - this.watcher = this.fs.watch(this.path, watchOptions, (eventType: string, filename: string) => { + this.watcher = this.fs.watch(this.path, watchOptions, (eventType: string, filename: string | Buffer) => { this.enqueueEvent({ eventType, filename }); }); } catch (error) { diff --git a/packages/fs-node/src/volume.ts b/packages/fs-node/src/volume.ts index e330af268..1b65ee5e1 100644 --- a/packages/fs-node/src/volume.ts +++ b/packages/fs-node/src/volume.ts @@ -1431,6 +1431,11 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { } watch(path: PathLike, listener?: (eventType: string, filename: string) => void): FSWatcher; + watch( + path: PathLike, + options: 'buffer' | (IWatchOptions & { encoding: 'buffer' }), + listener?: (eventType: string, filename: Buffer) => void, + ): FSWatcher; watch( path: PathLike, options: IWatchOptions | string | undefined, @@ -1438,8 +1443,8 @@ export class Volume implements FsCallbackApi, FsSynchronousApi { ): FSWatcher; watch( path: PathLike, - options?: IWatchOptions | string | ((eventType: string, filename: string) => void), - listener?: (eventType: string, filename: string) => void, + options?: IWatchOptions | string | ((eventType: string, filename: string & Buffer) => void), + listener?: (eventType: string, filename: string & Buffer) => void, ): FSWatcher { const filename = pathToFilename(path); let givenOptions: typeof options | null = options;