import { realpathSync, statSync, type BigIntStats } from 'node:fs' import { realpath, stat } from 'node:fs/promises' import { basename, dirname, resolve } from 'node:path' import { subscribeViaWatcherProcess } from '../ipc/filesystem/watcher-process/parcel-watcher-process' import type { WatcherProcessSubscribeOptions } from '../ipc/filesystem/watcher-process/parcel-watcher-process-protocol' import type { WatcherProcessSubscription } from '../ipc/filesystem/watcher-process/parcel-watcher-process-subscription' import { resolveWatcherRootPaths } from '../ipc/watcher-event-root-path-rewrite' import { watcherDirectoryIdentity } from '../ipc/watcher-directory-identity' // Deleted roots can reject unsubscribe after the native stream has stopped. function releaseSubscription(subscription: WatcherProcessSubscription): void { void subscription.unsubscribe().catch(() => undefined) } function addPluginRootParent(parentNames: Map>, root: string): void { const { watchRoot: parent } = resolveWatcherRootPaths(dirname(root)) const names = parentNames.get(parent) ?? new Set() names.add(basename(root)) parentNames.set(parent, names) } type PluginRootBinding = { physicalRoot: string; identity: string | null } function pluginRootBinding(physicalRoot: string, entry: BigIntStats): PluginRootBinding | null { return entry.isDirectory() ? { physicalRoot, identity: watcherDirectoryIdentity(entry) } : null } function readPluginRootBindingSync(physicalRoot: string): PluginRootBinding | null { try { return pluginRootBinding(physicalRoot, statSync(physicalRoot, { bigint: true })) } catch { return null } } async function readPluginRootBinding(requestedRoot: string): Promise { try { const physicalRoot = await realpath(requestedRoot) return pluginRootBinding(physicalRoot, await stat(physicalRoot, { bigint: true })) } catch { return null } } /** Owns debounced manifest/panel refresh watchers for mutable dev plugins. */ export class PluginDevWatcher { private readonly subscriptions: WatcherProcessSubscription[] = [] private readonly physicalRoots = new Map() private readonly rootBindings = new Map() private refreshTimer: ReturnType | null = null private generation = 0 constructor(private readonly subscribePath = subscribeViaWatcherProcess) {} start( devPaths: readonly string[], refresh: () => void, onWatcherError?: (retry?: boolean) => void ): void { this.stopSubscriptions() const requestedRoots = new Set(devPaths.map((path) => resolve(path))) for (const path of this.physicalRoots.keys()) { if (!requestedRoots.has(path)) { this.physicalRoots.delete(path) } } const parentNames = new Map>() for (const requestedRoot of requestedRoots) { const { watchRoot } = resolveWatcherRootPaths(requestedRoot, { realpath: (candidate) => { const physicalRoot = realpathSync.native(candidate) this.physicalRoots.set(requestedRoot, physicalRoot) return physicalRoot } }) this.rootBindings.set(requestedRoot, readPluginRootBindingSync(watchRoot)) // macOS parent streams are recursive; Windows child edits also report parent names. if (process.platform !== 'darwin' && process.platform !== 'win32') { // A dangling symlink still needs its last physical parent watched. const physicalRoot = this.physicalRoots.get(requestedRoot) ?? watchRoot for (const root of new Set([requestedRoot, physicalRoot])) { addPluginRootParent(parentNames, root) } } this.subscribe( requestedRoot, process.platform === 'win32' ? { backend: 'windows' } : {}, false, refresh, onWatcherError ) } for (const [parent, names] of parentNames) { this.subscribe( parent, { mode: 'shallow', include: [...names] }, true, refresh, onWatcherError ) } } dispose(): void { this.stopSubscriptions() this.physicalRoots.clear() } async checkRootBindings(): Promise { const generation = this.generation const nextBindings = await Promise.all( [...this.rootBindings.keys()].map(async (root) => ({ root, binding: await readPluginRootBinding(root) })) ) if (generation !== this.generation) { return null } let changed = false for (const { root, binding } of nextBindings) { const previous = this.rootBindings.get(root) // Keep the last known binding when a present directory's metadata becomes ambiguous. if ( previous && binding && previous.physicalRoot === binding.physicalRoot && binding.identity === null ) { binding.identity = previous.identity } if ( previous?.physicalRoot !== binding?.physicalRoot || (binding?.identity && previous?.identity !== binding.identity) ) { changed = true } // Unknown identity stays quiet; a newly usable identity admits one conservative rebind. this.rootBindings.set(root, binding) } return changed } private stopSubscriptions(): void { this.generation += 1 this.rootBindings.clear() if (this.refreshTimer) { clearTimeout(this.refreshTimer) this.refreshTimer = null } for (const subscription of this.subscriptions.splice(0)) { releaseSubscription(subscription) } } private subscribe( path: string, options: WatcherProcessSubscribeOptions, parent: boolean, refresh: () => void, onWatcherError?: (retry?: boolean) => void ): void { const generation = this.generation const retrySetupFailure = parent ? readPluginRootBindingSync(path) !== null : !!this.rootBindings.get(path) let subscription: WatcherProcessSubscription | null = null let closed = false const isActive = (): boolean => generation === this.generation && !closed const requestRetry = (): void => { if (isActive()) { onWatcherError?.() this.scheduleRefresh(refresh) } } const fail = (): void => { if (!isActive()) { return } requestRetry() closed = true if (subscription) { this.removeSubscription(subscription) releaseSubscription(subscription) } } void this.subscribePath( path, (error, events) => { if (!isActive()) { return } if (error || events.some((event) => event.type === 'delete' && event.path === path)) { fail() } else if (parent) { // Root replacement stops recursive watching without reporting an error. requestRetry() } else { this.scheduleRefresh(refresh) } }, options, { onInterruption: requestRetry, onOverflow: requestRetry, onTerminalError: fail } ) .then((created) => { subscription = created if (!isActive()) { releaseSubscription(created) return } this.subscriptions.push(created) // Why: the watcher reports only what changes after it is live, and discovery ran before // that, so an edit made in between (a save right after adding the folder) would be lost. if (!parent) { this.scheduleRefresh(refresh) } }) .catch(() => { if (isActive()) { closed = true // Missing roots recover through binding checks instead of refresh loops. onWatcherError?.(retrySetupFailure) } }) } private removeSubscription(subscription: WatcherProcessSubscription): void { const index = this.subscriptions.indexOf(subscription) if (index !== -1) { this.subscriptions.splice(index, 1) } } private scheduleRefresh(refresh: () => void): void { if (this.refreshTimer) { clearTimeout(this.refreshTimer) } this.refreshTimer = setTimeout(() => { this.refreshTimer = null refresh() }, 300) } }