Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions packages/fs-core/src/Link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LinkEvent>();

vol: Superblock;
Expand Down
4 changes: 4 additions & 0 deletions packages/fs-core/src/Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeEvent>();

// i-node number.
Expand Down
15 changes: 12 additions & 3 deletions packages/fs-fsa-to-node/src/FsaNodeFs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
};

Expand Down
19 changes: 16 additions & 3 deletions packages/fs-fsa-to-node/src/FsaNodeFsWatcher.ts
Original file line number Diff line number Diff line change
@@ -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[],
Expand All @@ -15,14 +16,17 @@ 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;
protected closed: boolean = false;
protected encoding: BufferEncoding = 'utf8';
protected filename: string = '';
protected basename: string = '';
private _ignore: ((filename: string) => boolean) | undefined;

constructor(
protected readonly Observer: fsa.IFileSystemObserverConstructable,
Expand All @@ -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));
Expand All @@ -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;
}
Comment thread
streamich marked this conversation as resolved.
this.close();
const wrapped = new Error(`watch ${this.filename} ${code ?? ''}`.trimEnd());
(wrapped as any).code = code;
this.emit('error', wrapped);
Expand Down Expand Up @@ -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));
}

Expand Down
152 changes: 152 additions & 0 deletions packages/fs-fsa-to-node/src/__tests__/watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
3 changes: 2 additions & 1 deletion packages/fs-node-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
1 change: 1 addition & 0 deletions packages/fs-node-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './consts/AMODE';
export * from './consts/FLAG';
export * from './path';
export * from './encoding';
export * from './watchIgnore';
13 changes: 8 additions & 5 deletions packages/fs-node-utils/src/types/FsCallbackApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,14 @@ export interface FsCallbackApi {
unlink: (path: misc.PathLike, callback: misc.TCallback<void>) => 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>) => 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;
};
Comment thread
streamich marked this conversation as resolved.
watchFile: {
(path: misc.PathLike, listener: (curr: misc.IStats, prev: misc.IStats) => void): misc.IStatWatcher;
(
Expand Down
27 changes: 27 additions & 0 deletions packages/fs-node-utils/src/watchIgnore.ts
Original file line number Diff line number Diff line change
@@ -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);
};
23 changes: 16 additions & 7 deletions packages/fs-node/src/FsPromises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Comment thread
streamich marked this conversation as resolved.
Outdated
if (signal.aborted) {
this.finish(newAbortError(signal));
return;
}
options.signal.addEventListener('abort', () => {
this.finish();
signal.addEventListener('abort', () => {
this.finish(newAbortError(signal));
});
}
}
Expand Down
Loading
Loading