Add plugin framework and settings UI
This commit is contained in:
189
server/src/services/plugin-dev-watcher.ts
Normal file
189
server/src/services/plugin-dev-watcher.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §27.2 — Local Development Workflow
|
||||
*/
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { existsSync } 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;
|
||||
watch?: typeof watch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const watchFs = fsDeps?.watch ?? watch;
|
||||
|
||||
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 watcher = watchFs(absPath, { recursive: true }, (_event, filename) => {
|
||||
// Ignore node_modules and hidden files inside the plugin dir
|
||||
if (
|
||||
filename &&
|
||||
(filename.includes("node_modules") || filename.startsWith("."))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: multiple rapid file changes collapse into one restart
|
||||
const existing = debounceTimers.get(pluginId);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
debounceTimers.set(
|
||||
pluginId,
|
||||
setTimeout(() => {
|
||||
debounceTimers.delete(pluginId);
|
||||
log.info(
|
||||
{ pluginId, changedFile: filename },
|
||||
"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),
|
||||
);
|
||||
});
|
||||
|
||||
watchers.set(pluginId, watcher);
|
||||
log.info(
|
||||
{ pluginId, packagePath: absPath },
|
||||
"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 watcher = watchers.get(pluginId);
|
||||
if (watcher) {
|
||||
watcher.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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user