Spaces:
Build error
Build error
| /** | |
| * 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<string | null | undefined>; | |
| 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<PluginDevWatcherFsDeps, "existsSync" | "readFileSync" | "readdirSync" | "statSync">, | |
| ): 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<string, PluginWatchTarget>(); | |
| 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<string, FSWatcher>(); | |
| const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>(); | |
| 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<void> { | |
| 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, | |
| }; | |
| } | |