/** * PluginDevWatcher — watches local-path plugin directories for file changes * and triggers worker restarts so plugin authors get a fast rebuild-and-reload * cycle without manually restarting the server. * * Only plugins installed from a local path (i.e. those with a non-null * `packagePath` in the DB) are watched. File changes in the plugin's package * directory trigger a debounced worker restart via the lifecycle manager. * * Uses chokidar rather than raw fs.watch so we get a production-grade watcher * backend across platforms and avoid exhausting file descriptors as quickly in * large dev workspaces. * * @see PLUGIN_SPEC.md §27.2 — Local Development Workflow */ import chokidar, { type FSWatcher } from "chokidar"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { logger } from "../middleware/logger.js"; import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; const log = logger.child({ service: "plugin-dev-watcher" }); /** Debounce interval for file changes (ms). */ const DEBOUNCE_MS = 500; export interface PluginDevWatcher { /** Start watching a local-path plugin directory. */ watch(pluginId: string, packagePath: string): void; /** Stop watching a specific plugin. */ unwatch(pluginId: string): void; /** Stop all watchers and clean up. */ close(): void; } export type ResolvePluginPackagePath = ( pluginId: string, ) => Promise; export interface PluginDevWatcherFsDeps { existsSync?: typeof existsSync; readFileSync?: typeof readFileSync; readdirSync?: typeof readdirSync; statSync?: typeof statSync; } type PluginWatchTarget = { path: string; recursive: boolean; kind: "file" | "dir"; }; type PluginPackageJson = { paperclipPlugin?: { manifest?: string; worker?: string; ui?: string; }; }; function shouldIgnorePath(filename: string | null | undefined): boolean { if (!filename) return false; const normalized = filename.replace(/\\/g, "/"); const segments = normalized.split("/").filter(Boolean); return segments.some( (segment) => segment === "node_modules" || segment === ".git" || segment === ".vite" || segment === ".paperclip-sdk" || segment.startsWith("."), ); } export function resolvePluginWatchTargets( packagePath: string, fsDeps?: Pick, ): PluginWatchTarget[] { const fileExists = fsDeps?.existsSync ?? existsSync; const readFile = fsDeps?.readFileSync ?? readFileSync; const readDir = fsDeps?.readdirSync ?? readdirSync; const statFile = fsDeps?.statSync ?? statSync; const absPath = path.resolve(packagePath); const targets = new Map(); function addWatchTarget(targetPath: string, recursive: boolean, kind?: "file" | "dir"): void { const resolved = path.resolve(targetPath); if (!fileExists(resolved)) return; const inferredKind = kind ?? (statFile(resolved).isDirectory() ? "dir" : "file"); const existing = targets.get(resolved); if (existing) { existing.recursive = existing.recursive || recursive; return; } targets.set(resolved, { path: resolved, recursive, kind: inferredKind }); } function addRuntimeFilesFromDir(dirPath: string): void { if (!fileExists(dirPath)) return; for (const entry of readDir(dirPath, { withFileTypes: true })) { const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { addRuntimeFilesFromDir(entryPath); continue; } if (!entry.isFile()) continue; if (!entry.name.endsWith(".js") && !entry.name.endsWith(".css")) continue; addWatchTarget(entryPath, false, "file"); } } const packageJsonPath = path.join(absPath, "package.json"); addWatchTarget(packageJsonPath, false, "file"); if (!fileExists(packageJsonPath)) { return [...targets.values()]; } let packageJson: PluginPackageJson | null = null; try { packageJson = JSON.parse(readFile(packageJsonPath, "utf8")) as PluginPackageJson; } catch { packageJson = null; } const entrypointPaths = [ packageJson?.paperclipPlugin?.manifest, packageJson?.paperclipPlugin?.worker, packageJson?.paperclipPlugin?.ui, ].filter((value): value is string => typeof value === "string" && value.length > 0); if (entrypointPaths.length === 0) { addRuntimeFilesFromDir(path.join(absPath, "dist")); return [...targets.values()]; } for (const relativeEntrypoint of entrypointPaths) { const resolvedEntrypoint = path.resolve(absPath, relativeEntrypoint); if (!fileExists(resolvedEntrypoint)) continue; const stat = statFile(resolvedEntrypoint); if (stat.isDirectory()) { addRuntimeFilesFromDir(resolvedEntrypoint); } else { addWatchTarget(resolvedEntrypoint, false, "file"); } } return [...targets.values()].sort((a, b) => a.path.localeCompare(b.path)); } /** * Create a PluginDevWatcher that monitors local plugin directories and * restarts workers on file changes. */ export function createPluginDevWatcher( lifecycle: PluginLifecycleManager, resolvePluginPackagePath?: ResolvePluginPackagePath, fsDeps?: PluginDevWatcherFsDeps, ): PluginDevWatcher { const watchers = new Map(); const debounceTimers = new Map>(); const fileExists = fsDeps?.existsSync ?? existsSync; function watchPlugin(pluginId: string, packagePath: string): void { // Don't double-watch if (watchers.has(pluginId)) return; const absPath = path.resolve(packagePath); if (!fileExists(absPath)) { log.warn( { pluginId, packagePath: absPath }, "plugin-dev-watcher: package path does not exist, skipping watch", ); return; } try { const watcherTargets = resolvePluginWatchTargets(absPath, fsDeps); if (watcherTargets.length === 0) { log.warn( { pluginId, packagePath: absPath }, "plugin-dev-watcher: no valid watch targets found, skipping watch", ); return; } const watcher = chokidar.watch( watcherTargets.map((target) => target.path), { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100, }, ignored: (watchedPath) => { const relativePath = path.relative(absPath, watchedPath); return shouldIgnorePath(relativePath); }, }, ); watcher.on("all", (_eventName, changedPath) => { const relativePath = path.relative(absPath, changedPath); if (shouldIgnorePath(relativePath)) return; const existing = debounceTimers.get(pluginId); if (existing) clearTimeout(existing); debounceTimers.set( pluginId, setTimeout(() => { debounceTimers.delete(pluginId); log.info( { pluginId, changedFile: relativePath || path.basename(changedPath) }, "plugin-dev-watcher: file change detected, restarting worker", ); lifecycle.restartWorker(pluginId).catch((err) => { log.warn( { pluginId, err: err instanceof Error ? err.message : String(err), }, "plugin-dev-watcher: failed to restart worker after file change", ); }); }, DEBOUNCE_MS), ); }); watcher.on("error", (err) => { log.warn( { pluginId, packagePath: absPath, err: err instanceof Error ? err.message : String(err), }, "plugin-dev-watcher: watcher error, stopping watch for this plugin", ); unwatchPlugin(pluginId); }); watchers.set(pluginId, watcher); log.info( { pluginId, packagePath: absPath, watchTargets: watcherTargets.map((target) => ({ path: target.path, kind: target.kind, })), }, "plugin-dev-watcher: watching local plugin for changes", ); } catch (err) { log.warn( { pluginId, packagePath: absPath, err: err instanceof Error ? err.message : String(err), }, "plugin-dev-watcher: failed to start file watcher", ); } } function unwatchPlugin(pluginId: string): void { const pluginWatcher = watchers.get(pluginId); if (pluginWatcher) { void pluginWatcher.close(); watchers.delete(pluginId); } const timer = debounceTimers.get(pluginId); if (timer) { clearTimeout(timer); debounceTimers.delete(pluginId); } } function close(): void { lifecycle.off("plugin.loaded", handlePluginLoaded); lifecycle.off("plugin.enabled", handlePluginEnabled); lifecycle.off("plugin.disabled", handlePluginDisabled); lifecycle.off("plugin.unloaded", handlePluginUnloaded); for (const [pluginId] of watchers) { unwatchPlugin(pluginId); } } async function watchLocalPluginById(pluginId: string): Promise { if (!resolvePluginPackagePath) return; try { const packagePath = await resolvePluginPackagePath(pluginId); if (!packagePath) return; watchPlugin(pluginId, packagePath); } catch (err) { log.warn( { pluginId, err: err instanceof Error ? err.message : String(err), }, "plugin-dev-watcher: failed to resolve plugin package path", ); } } function handlePluginLoaded(payload: { pluginId: string }): void { void watchLocalPluginById(payload.pluginId); } function handlePluginEnabled(payload: { pluginId: string }): void { void watchLocalPluginById(payload.pluginId); } function handlePluginDisabled(payload: { pluginId: string }): void { unwatchPlugin(payload.pluginId); } function handlePluginUnloaded(payload: { pluginId: string }): void { unwatchPlugin(payload.pluginId); } lifecycle.on("plugin.loaded", handlePluginLoaded); lifecycle.on("plugin.enabled", handlePluginEnabled); lifecycle.on("plugin.disabled", handlePluginDisabled); lifecycle.on("plugin.unloaded", handlePluginUnloaded); return { watch: watchPlugin, unwatch: unwatchPlugin, close, }; }