/** * 算法插件注册表 * ------------------------------------------------------------ * 启动时扫描 `server/plugins/manifests/*.json`,把每个 JSON 解析为 * `PluginManifest`。注册表是内存只读单例;增减算法只需添加 / 删除 * manifest 文件并重启进程(无需改 TS)。 * * 路径解析: * - 开发态:本文件位于 `server/plugins/registry.ts`,manifests 直接在同级 * `manifests/` 目录。 * - 生产态:esbuild 把 `server/_core/index.ts` 打到 `dist/index.js`,本文件 * 被打包到 dist/index.js 里。为了让 fs 能找到 manifests,Dockerfile 在 * runner 阶段额外把 `server/plugins/` 拷贝到 `dist/plugins/`,并通过 * 环境变量 `PLUGIN_DIR` 覆盖。 */ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'node:url'; import type { PluginManifest } from './types.js'; let _cache: Map | null = null; /** 返回 manifests / adapters / fixtures 的根目录。 */ export function pluginDir(): string { if (process.env.PLUGIN_DIR) return process.env.PLUGIN_DIR; const here = path.dirname(fileURLToPath(import.meta.url)); // 生产态 here === /app/dist;开发态 here === server/plugins const candidates = [ path.join(here), // server/plugins(开发) path.join(here, 'plugins'), // dist/plugins(生产 — Dockerfile 拷贝) path.join(here, '..', 'server', 'plugins'), ]; for (const c of candidates) { if (fs.existsSync(path.join(c, 'manifests'))) return c; } // 兜底——开发态相对仓库根 return path.resolve(here, '..', '..', 'server', 'plugins'); } export function manifestsDir(): string { return path.join(pluginDir(), 'manifests'); } export function adaptersDir(): string { return path.join(pluginDir(), 'adapters'); } export function fixturesDir(): string { return path.join(pluginDir(), 'fixtures'); } /** 从 `manifests/*.json` 重新加载注册表。 */ export function reloadPlugins(): Map { const dir = manifestsDir(); const result = new Map(); if (!fs.existsSync(dir)) { console.warn('[PluginRegistry] manifests dir not found:', dir); _cache = result; return result; } for (const file of fs.readdirSync(dir)) { if (!file.endsWith('.json')) continue; const full = path.join(dir, file); try { const m = JSON.parse(fs.readFileSync(full, 'utf-8')) as PluginManifest; if (!m.id || !m.runtime?.kind) { console.warn('[PluginRegistry] skip invalid manifest', file); continue; } if (result.has(m.id)) { console.warn('[PluginRegistry] duplicate plugin id:', m.id); } result.set(m.id, m); } catch (e) { console.error('[PluginRegistry] failed to parse', file, e); } } _cache = result; const ids: string[] = []; result.forEach((_v, k) => ids.push(k)); console.log(`[PluginRegistry] loaded ${result.size} plugin(s): ${ids.join(', ')}`); return result; } export function listPlugins(): PluginManifest[] { if (!_cache) reloadPlugins(); const arr: PluginManifest[] = []; _cache!.forEach((m) => arr.push(m)); return arr; } export function getPlugin(id: string): PluginManifest | undefined { if (!_cache) reloadPlugins(); return _cache!.get(id); } /** * Force a full reload from disk and return the new list. * Called by the /api/plugins/reload endpoint so operators can hot-swap * manifests without restarting the server. */ export function forceReload(): PluginManifest[] { _cache = null; return listPlugins(); }