|
| 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 | +} |
0 commit comments