Skip to content

Commit 5a3feb7

Browse files
authored
Merge pull request #1267 from streamich/node-to-fsa-watcher-bridge
Node-to-FSA watcher bridge
2 parents 14cac3a + 17bad78 commit 5a3feb7

7 files changed

Lines changed: 489 additions & 5 deletions

File tree

packages/fs-fsa-to-node/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,34 @@ console.log(content); // 'Hello, World!'
3232
- Supports file operations: read, write, append, truncate
3333
- Supports directory operations: mkdir, readdir, rmdir
3434
- Includes read and write streams
35+
- Supports `fs.watch` and `fs.watchFile`
36+
37+
## Watching
38+
39+
`fs.watch` is powered by a [`FileSystemObserver`][observer]: the constructor
40+
passed through the `FileSystemObserver` option is used when provided, otherwise
41+
the global one — shipped natively in Chrome 133+, which makes `fs.watch` work
42+
over real OPFS in the browser.
43+
44+
```ts
45+
const fs = new FsaNodeFs(dirHandle, undefined, { FileSystemObserver });
46+
47+
const watcher = fs.watch('/', { recursive: true }, (eventType, filename) => {
48+
console.log(eventType, filename);
49+
});
50+
```
51+
52+
Divergences from Node.js:
53+
54+
- The FSA backend is asynchronous, so startup errors (e.g. a missing path) are
55+
emitted as an `'error'` event on the returned watcher instead of being
56+
thrown synchronously. The `ignore`, `signal`, and `throwIfNoEntry` options
57+
are not supported, and `persistent` is a no-op.
58+
- `fs.watchFile` polls the file at `interval` — comparing the handle's
59+
`File#lastModified` and size — since the FSA API exposes no stat change
60+
notifications.
61+
62+
[observer]: https://developer.mozilla.org/en-US/docs/Web/API/FileSystemObserver
3563

3664
## License
3765

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { memfs } from 'memfs';
2+
import { nodeToFsa, NodeFileSystemObserver } from '@jsonjoy.com/fs-node-to-fsa';
3+
import { FsaNodeFs } from '../FsaNodeFs';
4+
import { onlyOnNode20 } from './util';
5+
6+
const tick = (ms: number = 1) => new Promise(r => setTimeout(r, ms));
7+
8+
const until = async (check: () => boolean | Promise<boolean>, pollInterval: number = 1) => {
9+
do {
10+
if (await check()) return;
11+
await tick(pollInterval);
12+
} while (true);
13+
};
14+
15+
/**
16+
* Chains both bridges: a memfs volume is exposed as FSA through
17+
* `fs-node-to-fsa` (including its `NodeFileSystemObserver`), and that FSA is
18+
* exposed back as a Node.js `fs` API through `fs-fsa-to-node`, so `fs.watch`
19+
* events flow across the whole stack.
20+
*/
21+
const setup = () => {
22+
const { fs: mfs } = memfs({ mountpoint: null });
23+
const dir = nodeToFsa(mfs, '/mountpoint', { mode: 'readwrite' });
24+
class Observer extends NodeFileSystemObserver {
25+
constructor(callback: ConstructorParameters<typeof NodeFileSystemObserver>[1]) {
26+
super(mfs as any, callback);
27+
}
28+
}
29+
const fs = new FsaNodeFs(dir, undefined, { FileSystemObserver: Observer });
30+
return { fs, mfs };
31+
};
32+
33+
onlyOnNode20('fs.watch() across both bridges', () => {
34+
test('file creation arrives as a "rename" event', async () => {
35+
const { fs, mfs } = setup();
36+
const events: [string, unknown][] = [];
37+
const watcher = fs.watch('/', (eventType, filename) => events.push([eventType, filename]));
38+
await tick();
39+
mfs.writeFileSync('/mountpoint/file.txt', 'hello');
40+
await until(() => events.length >= 1);
41+
expect(events[0]).toEqual(['rename', 'file.txt']);
42+
watcher.close();
43+
});
44+
45+
test('file modification arrives as a "change" event', async () => {
46+
const { fs, mfs } = setup();
47+
mfs.writeFileSync('/mountpoint/file.txt', '');
48+
const events: [string, unknown][] = [];
49+
const watcher = fs.watch('/', (eventType, filename) => events.push([eventType, filename]));
50+
await tick();
51+
mfs.appendFileSync('/mountpoint/file.txt', 'more');
52+
await until(() => events.length >= 1);
53+
expect(events[0]).toEqual(['change', 'file.txt']);
54+
watcher.close();
55+
});
56+
57+
test('file deletion arrives as a "rename" event', async () => {
58+
const { fs, mfs } = setup();
59+
mfs.writeFileSync('/mountpoint/file.txt', 'x');
60+
const events: [string, unknown][] = [];
61+
const watcher = fs.watch('/', (eventType, filename) => events.push([eventType, filename]));
62+
await tick();
63+
mfs.unlinkSync('/mountpoint/file.txt');
64+
await until(() => events.length >= 1);
65+
expect(events[0]).toEqual(['rename', 'file.txt']);
66+
watcher.close();
67+
});
68+
69+
test('a rename arrives as two "rename" events, one per path', async () => {
70+
const { fs, mfs } = setup();
71+
mfs.writeFileSync('/mountpoint/a.txt', 'x');
72+
const events: [string, unknown][] = [];
73+
const watcher = fs.watch('/', (eventType, filename) => events.push([eventType, filename]));
74+
await tick();
75+
mfs.renameSync('/mountpoint/a.txt', '/mountpoint/b.txt');
76+
await until(() => events.length >= 2);
77+
expect(events.every(([eventType]) => eventType === 'rename')).toBe(true);
78+
expect(events.map(([, filename]) => filename).sort()).toEqual(['a.txt', 'b.txt']);
79+
watcher.close();
80+
});
81+
82+
test('recursive watch reports nested paths', async () => {
83+
const { fs, mfs } = setup();
84+
mfs.mkdirSync('/mountpoint/sub');
85+
const events: [string, unknown][] = [];
86+
const watcher = fs.watch('/', { recursive: true }, (eventType, filename) => events.push([eventType, filename]));
87+
await tick();
88+
mfs.writeFileSync('/mountpoint/sub/deep.txt', 'x');
89+
await until(() => events.length >= 1);
90+
expect(events[0]).toEqual(['rename', 'sub/deep.txt']);
91+
watcher.close();
92+
});
93+
});

packages/fs-node-to-fsa/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,28 @@ for await (const [name, handle] of dir.entries()) {
2222
}
2323
```
2424

25+
## FileSystemObserver
26+
27+
`NodeFileSystemObserver` implements the [`FileSystemObserver` proposal][observer]
28+
on top of the Node.js `fs.watch` API. It is a best-effort implementation, per
29+
the proposal's allowance for local file systems: `rename` events are classified
30+
into `"appeared"`/`"disappeared"` records by stat-ing the path, and no
31+
`"moved"` records are ever produced.
32+
33+
```ts
34+
import { nodeToFsa, NodeFileSystemObserver } from '@jsonjoy.com/fs-node-to-fsa';
35+
import * as fs from 'fs';
36+
37+
const dir = nodeToFsa(fs, '/path/to/directory', { mode: 'readwrite' });
38+
const observer = new NodeFileSystemObserver(fs, records => console.log(records));
39+
await observer.observe(dir, { recursive: true });
40+
```
41+
2542
## Reference
2643

2744
- [`nodeToFsa(fs, path, ctx)`](src/index.ts) - Converts a Node.js `fs` module to a `FileSystemDirectoryHandle`.
45+
- [`NodeFileSystemObserver`](src/NodeFileSystemObserver.ts) - A `FileSystemObserver` implementation backed by `fs.watch`.
2846

2947
[node-fs]: https://nodejs.org/api/fs.html
3048
[fsa]: https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API
49+
[observer]: https://developer.mozilla.org/en-US/docs/Web/API/FileSystemObserver
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { FileSystemChangeRecord } from '@jsonjoy.com/fs-fsa';
2+
import { NodeFileSystemDirectoryHandle } from './NodeFileSystemDirectoryHandle';
3+
import { NodeFileSystemFileHandle } from './NodeFileSystemFileHandle';
4+
import { newNotAllowedError, newNotFoundError } from './util';
5+
import type {
6+
IFileSystemChangeRecord,
7+
IFileSystemDirectoryHandle,
8+
IFileSystemFileHandle,
9+
IFileSystemHandle,
10+
IFileSystemObserver,
11+
IFileSystemObserverObserveOptions,
12+
IFileSystemSyncAccessHandle,
13+
} from '@jsonjoy.com/fs-fsa';
14+
import type { FsCallbackApi } from '@jsonjoy.com/fs-node-utils';
15+
import type * as misc from '@jsonjoy.com/fs-node-utils/lib/types/misc';
16+
import type { NodeFsaContext, NodeFsaFs } from './types';
17+
18+
export type NodeFsaWatchFs = NodeFsaFs & Pick<FsCallbackApi, 'watch'>;
19+
20+
/**
21+
* A `FileSystemObserver` implementation backed by the underlying Node.js-like
22+
* `fs.watch`. This is a best-effort profile, per the File System Observer
23+
* proposal's allowance for local file systems: `rename` events are classified
24+
* into `"appeared"`/`"disappeared"` records by stat-ing the path, no
25+
* `"moved"` records are ever produced (pairing renames is unreliable — same
26+
* as Chrome on Windows), and a backend watcher error surfaces as a terminal
27+
* `"errored"` record for that observation.
28+
*
29+
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemObserver
30+
*/
31+
export class NodeFileSystemObserver implements IFileSystemObserver {
32+
protected readonly _observations = new Map<
33+
IFileSystemFileHandle | IFileSystemDirectoryHandle | IFileSystemSyncAccessHandle,
34+
misc.IFSWatcher
35+
>();
36+
protected _records: IFileSystemChangeRecord[] = [];
37+
protected _flushScheduled: boolean = false;
38+
39+
constructor(
40+
protected readonly fs: NodeFsaWatchFs,
41+
protected readonly callback: (records: IFileSystemChangeRecord[], observer: IFileSystemObserver) => void,
42+
) {}
43+
44+
public async observe(
45+
handle: IFileSystemFileHandle | IFileSystemDirectoryHandle | IFileSystemSyncAccessHandle,
46+
options?: IFileSystemObserverObserveOptions,
47+
): Promise<void> {
48+
const path = (handle as unknown as { __path?: unknown }).__path;
49+
const ctx = ((handle as any).ctx ?? (handle as any)._ctx) as NodeFsaContext | undefined;
50+
if (typeof path !== 'string' || !ctx || (ctx.separator !== '/' && ctx.separator !== '\\'))
51+
throw new TypeError("Failed to execute 'observe' on 'FileSystemObserver': Invalid handle.");
52+
const isDirectory = (handle as IFileSystemHandle).kind === 'directory';
53+
const last = path[path.length - 1];
54+
const isTrimmableSeparator = path.length > 1 && (last === '/' || last === '\\') && path[path.length - 2] !== ':';
55+
const target = isTrimmableSeparator ? path.slice(0, -1) : path;
56+
try {
57+
await this.fs.promises.stat(target);
58+
} catch (error) {
59+
if (error && typeof error === 'object') {
60+
switch (error.code) {
61+
case 'ENOENT':
62+
throw newNotFoundError();
63+
case 'EACCES':
64+
case 'EPERM':
65+
throw newNotAllowedError();
66+
}
67+
}
68+
throw error;
69+
}
70+
const recursive = isDirectory && !!options?.recursive;
71+
const watcher = this.fs.watch(target, { recursive }, (eventType, filename) => {
72+
void this.onEvent(handle, watcher, target, isDirectory, ctx, eventType, filename ? String(filename) : '');
73+
});
74+
watcher.on('error', () => {
75+
if (this._observations.get(handle) !== watcher) return;
76+
this._observations.delete(handle);
77+
watcher.close();
78+
this._enqueue(new FileSystemChangeRecord(handle, 'errored', null, []));
79+
});
80+
this._observations.get(handle)?.close();
81+
this._observations.set(handle, watcher);
82+
}
83+
84+
public unobserve(handle: IFileSystemFileHandle | IFileSystemDirectoryHandle | IFileSystemSyncAccessHandle): void {
85+
const watcher = this._observations.get(handle);
86+
if (!watcher) return;
87+
watcher.close();
88+
this._observations.delete(handle);
89+
}
90+
91+
/** Disconnect and stop all observations. */
92+
public disconnect(): void {
93+
for (const watcher of this._observations.values()) watcher.close();
94+
this._observations.clear();
95+
this._records = [];
96+
}
97+
98+
protected async onEvent(
99+
root: IFileSystemFileHandle | IFileSystemDirectoryHandle | IFileSystemSyncAccessHandle,
100+
watcher: misc.IFSWatcher,
101+
rootPath: string,
102+
isDirectory: boolean,
103+
ctx: NodeFsaContext,
104+
eventType: string,
105+
filename: string,
106+
): Promise<void> {
107+
const sep = ctx.separator;
108+
const steps = isDirectory && filename ? filename.split(sep) : [];
109+
const absolute = isDirectory ? (rootPath === sep ? rootPath + filename : rootPath + sep + filename) : rootPath;
110+
let stats: misc.IStats | null = null;
111+
try {
112+
stats = (await this.fs.promises.stat(absolute)) as misc.IStats;
113+
} catch {
114+
stats = null;
115+
}
116+
if (this._observations.get(root) !== watcher) return;
117+
if (eventType === 'rename') {
118+
if (stats) {
119+
this._enqueue(new FileSystemChangeRecord(root, 'appeared', this._handle(absolute, stats, ctx), steps));
120+
} else {
121+
this._enqueue(new FileSystemChangeRecord(root, 'disappeared', null, steps));
122+
}
123+
} else if (stats) {
124+
this._enqueue(new FileSystemChangeRecord(root, 'modified', this._handle(absolute, stats, ctx), steps));
125+
}
126+
}
127+
128+
protected _handle(absolute: string, stats: misc.IStats, ctx: NodeFsaContext): IFileSystemHandle {
129+
return stats.isDirectory()
130+
? new NodeFileSystemDirectoryHandle(this.fs, absolute, ctx)
131+
: new NodeFileSystemFileHandle(this.fs, absolute, ctx);
132+
}
133+
134+
protected _enqueue(record: IFileSystemChangeRecord): void {
135+
this._records.push(record);
136+
if (this._flushScheduled) return;
137+
this._flushScheduled = true;
138+
queueMicrotask(() => {
139+
this._flushScheduled = false;
140+
const records = this._records;
141+
this._records = [];
142+
if (records.length) this.callback(records, this);
143+
});
144+
}
145+
}

packages/fs-node-to-fsa/src/NodeFileSystemSyncAccessHandle.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ export class NodeFileSystemSyncAccessHandle implements IFileSystemSyncAccessHand
1111

1212
constructor(
1313
protected readonly fs: NodeFsaFs,
14-
protected readonly path: string,
14+
public readonly __path: string,
1515
protected readonly ctx: NodeFsaContext,
1616
) {
17-
this.fd = fs.openSync(path, 'r+');
18-
this.ctx.locks.acquireLock(this.path);
17+
this.fd = fs.openSync(__path, 'r+');
18+
this.ctx.locks.acquireLock(this.__path);
1919
}
2020

2121
/**
@@ -24,7 +24,7 @@ export class NodeFileSystemSyncAccessHandle implements IFileSystemSyncAccessHand
2424
public async close(): Promise<void> {
2525
assertCanWrite(this.ctx.mode);
2626
this.fs.closeSync(this.fd);
27-
this.ctx.locks.releaseLock(this.path);
27+
this.ctx.locks.releaseLock(this.__path);
2828
}
2929

3030
/**
@@ -39,7 +39,7 @@ export class NodeFileSystemSyncAccessHandle implements IFileSystemSyncAccessHand
3939
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemSyncAccessHandle/getSize
4040
*/
4141
public async getSize(): Promise<number> {
42-
return this.fs.statSync(this.path).size;
42+
return this.fs.statSync(this.__path).size;
4343
}
4444

4545
/**

0 commit comments

Comments
 (0)