Tighten plugin dev file watching

This commit is contained in:
Dotta
2026-03-14 12:07:04 -05:00
parent 22b8e90ba6
commit 0605c9f229
5 changed files with 464 additions and 76 deletions

View File

@@ -48,6 +48,7 @@
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-auth": "1.4.18",
"chokidar": "^4.0.3",
"detect-port": "^2.1.0",
"dotenv": "^17.0.1",
"drizzle-orm": "^0.38.4",

View File

@@ -20,7 +20,7 @@ function makeTempPluginDir(): string {
}
describe("resolvePluginWatchTargets", () => {
it("watches the package root plus declared build output directories", () => {
it("watches package metadata plus concrete declared runtime files", () => {
const pluginDir = makeTempPluginDir();
mkdirSync(path.join(pluginDir, "dist", "ui"), { recursive: true });
writeFileSync(
@@ -37,26 +37,32 @@ describe("resolvePluginWatchTargets", () => {
writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n");
writeFileSync(path.join(pluginDir, "dist", "worker.js"), "export default {};\n");
writeFileSync(path.join(pluginDir, "dist", "ui", "index.js"), "export default {};\n");
writeFileSync(path.join(pluginDir, "dist", "ui", "index.css"), "body {}\n");
const targets = resolvePluginWatchTargets(pluginDir);
expect(targets).toEqual([
{ path: pluginDir, recursive: false },
{ path: path.join(pluginDir, "dist"), recursive: true },
{ path: path.join(pluginDir, "dist", "ui"), recursive: true },
{ path: path.join(pluginDir, "dist", "manifest.js"), recursive: false, kind: "file" },
{ path: path.join(pluginDir, "dist", "ui", "index.css"), recursive: false, kind: "file" },
{ path: path.join(pluginDir, "dist", "ui", "index.js"), recursive: false, kind: "file" },
{ path: path.join(pluginDir, "dist", "worker.js"), recursive: false, kind: "file" },
{ path: path.join(pluginDir, "package.json"), recursive: false, kind: "file" },
]);
});
it("falls back to dist when package metadata does not declare entrypoints", () => {
const pluginDir = makeTempPluginDir();
mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
mkdirSync(path.join(pluginDir, "dist", "nested"), { recursive: true });
writeFileSync(path.join(pluginDir, "package.json"), JSON.stringify({ name: "@acme/example" }));
writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n");
writeFileSync(path.join(pluginDir, "dist", "nested", "chunk.js"), "export default {};\n");
const targets = resolvePluginWatchTargets(pluginDir);
expect(targets).toEqual([
{ path: pluginDir, recursive: false },
{ path: path.join(pluginDir, "dist"), recursive: true },
{ path: path.join(pluginDir, "package.json"), recursive: false, kind: "file" },
{ path: path.join(pluginDir, "dist", "manifest.js"), recursive: false, kind: "file" },
{ path: path.join(pluginDir, "dist", "nested", "chunk.js"), recursive: false, kind: "file" },
]);
});
});

View File

@@ -272,14 +272,16 @@ export async function createApp(
void toolDispatcher.initialize().catch((err) => {
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
});
const devWatcher = createPluginDevWatcher(
lifecycle,
async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null,
);
const devWatcher = opts.uiMode === "vite-dev"
? createPluginDevWatcher(
lifecycle,
async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null,
)
: null;
void loader.loadAll().then((result) => {
if (!result) return;
for (const loaded of result.results) {
if (loaded.success && loaded.plugin.packagePath) {
if (devWatcher && loaded.success && loaded.plugin.packagePath) {
devWatcher.watch(loaded.plugin.id, loaded.plugin.packagePath);
}
}
@@ -287,7 +289,7 @@ export async function createApp(
logger.error({ err }, "Failed to load ready plugins on startup");
});
process.once("exit", () => {
devWatcher.close();
devWatcher?.close();
hostServiceCleanup.disposeAll();
hostServiceCleanup.teardown();
});

View File

@@ -7,10 +7,14 @@
* `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 { watch, type FSWatcher } from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
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";
@@ -35,14 +39,15 @@ export type ResolvePluginPackagePath = (
export interface PluginDevWatcherFsDeps {
existsSync?: typeof existsSync;
watch?: typeof watch;
readFileSync?: typeof readFileSync;
readdirSync?: typeof readdirSync;
statSync?: typeof statSync;
}
type PluginWatchTarget = {
path: string;
recursive: boolean;
kind: "file" | "dir";
};
type PluginPackageJson = {
@@ -69,17 +74,19 @@ function shouldIgnorePath(filename: string | null | undefined): boolean {
export function resolvePluginWatchTargets(
packagePath: string,
fsDeps?: Pick<PluginDevWatcherFsDeps, "existsSync" | "readFileSync" | "statSync">,
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): void {
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) {
@@ -87,14 +94,27 @@ export function resolvePluginWatchTargets(
return;
}
targets.set(resolved, { path: resolved, recursive });
targets.set(resolved, { path: resolved, recursive, kind: inferredKind });
}
// Watch the package root non-recursively so top-level files like package.json
// can trigger reloads without traversing node_modules or other deep trees.
addWatchTarget(absPath, false);
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()];
}
@@ -113,7 +133,7 @@ export function resolvePluginWatchTargets(
].filter((value): value is string => typeof value === "string" && value.length > 0);
if (entrypointPaths.length === 0) {
addWatchTarget(path.join(absPath, "dist"), true);
addRuntimeFilesFromDir(path.join(absPath, "dist"));
return [...targets.values()];
}
@@ -123,13 +143,13 @@ export function resolvePluginWatchTargets(
const stat = statFile(resolvedEntrypoint);
if (stat.isDirectory()) {
addWatchTarget(resolvedEntrypoint, true);
addRuntimeFilesFromDir(resolvedEntrypoint);
} else {
addWatchTarget(path.dirname(resolvedEntrypoint), true);
addWatchTarget(resolvedEntrypoint, false, "file");
}
}
return [...targets.values()];
return [...targets.values()].sort((a, b) => a.path.localeCompare(b.path));
}
/**
@@ -141,10 +161,9 @@ export function createPluginDevWatcher(
resolvePluginPackagePath?: ResolvePluginPackagePath,
fsDeps?: PluginDevWatcherFsDeps,
): PluginDevWatcher {
const watchers = new Map<string, FSWatcher[]>();
const watchers = new Map<string, FSWatcher>();
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
const fileExists = fsDeps?.existsSync ?? existsSync;
const watchFs = fsDeps?.watch ?? watch;
function watchPlugin(pluginId: string, packagePath: string): void {
// Don't double-watch
@@ -169,60 +188,70 @@ export function createPluginDevWatcher(
return;
}
const activeWatchers = watcherTargets.map((target) => {
const watcher = watchFs(target.path, { recursive: target.recursive }, (_event, filename) => {
if (shouldIgnorePath(filename)) 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);
},
},
);
// Debounce: multiple rapid file changes collapse into one restart
const existing = debounceTimers.get(pluginId);
if (existing) clearTimeout(existing);
watcher.on("all", (_eventName, changedPath) => {
const relativePath = path.relative(absPath, changedPath);
if (shouldIgnorePath(relativePath)) return;
debounceTimers.set(
pluginId,
setTimeout(() => {
debounceTimers.delete(pluginId);
log.info(
{ pluginId, changedFile: filename, watchTarget: target.path },
"plugin-dev-watcher: file change detected, restarting worker",
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",
);
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,
watchTarget: target.path,
err: err instanceof Error ? err.message : String(err),
},
"plugin-dev-watcher: watcher error, stopping watch for this plugin",
);
unwatchPlugin(pluginId);
});
return watcher;
});
}, DEBOUNCE_MS),
);
});
watchers.set(pluginId, activeWatchers);
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,
recursive: target.recursive,
kind: target.kind,
})),
},
"plugin-dev-watcher: watching local plugin for changes",
@@ -240,11 +269,9 @@ export function createPluginDevWatcher(
}
function unwatchPlugin(pluginId: string): void {
const pluginWatchers = watchers.get(pluginId);
if (pluginWatchers) {
for (const watcher of pluginWatchers) {
watcher.close();
}
const pluginWatcher = watchers.get(pluginId);
if (pluginWatcher) {
void pluginWatcher.close();
watchers.delete(pluginId);
}
const timer = debounceTimers.get(pluginId);