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
12 changes: 9 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ 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:
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 All @@ -29,8 +29,14 @@ 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
shell: bash
run: |
npm install -g --force corepack
corepack enable --install-directory "$(dirname "$(command -v node)")"
yarn --version
- name: install
run: yarn
- name: run build
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` and 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)
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
21 changes: 15 additions & 6 deletions packages/fs-fsa-to-node/src/FsaNodeFs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,23 +886,32 @@ 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,
options?: opts.IWatchOptions | string | ((eventType: string, filename: string & Buffer) => void),
listener?: (eventType: string, filename: string & Buffer) => void,
Comment thread
streamich marked this conversation as resolved.
): 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;
Comment thread
streamich marked this conversation as resolved.
options = undefined;
}
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
20 changes: 17 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,14 @@ 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;
observer.disconnect();
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 +97,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
167 changes: 167 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,171 @@ 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');
});

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', () => {
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';
Loading
Loading