mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
feat(adapters): external adapter plugin system with dynamic UI parser
- Plugin loader: install/reload/remove/reinstall external adapters from npm packages or local directories - Plugin store persisted at ~/.paperclip/adapter-plugins.json - Self-healing UI parser resolution with version caching - UI: Adapter Manager page, dynamic loader, display registry with humanized names for unknown adapter types - Dev watch: exclude adapter-plugins dir from tsx watcher to prevent mid-request server restarts during reinstall - All consumer fallbacks use getAdapterLabel() for consistent display - AdapterTypeDropdown uses controlled open state for proper close behavior - Remove hermes-local from built-in UI (externalized to plugin) - Add docs for external adapters and UI parser contract
This commit is contained in:
parent
f8452a4520
commit
14d59da316
72 changed files with 4102 additions and 585 deletions
262
server/src/adapters/plugin-loader.ts
Normal file
262
server/src/adapters/plugin-loader.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
* External adapter plugin loader.
|
||||
*
|
||||
* Loads external adapter packages from the adapter-plugin-store and returns
|
||||
* their ServerAdapterModule instances. The caller (registry.ts) is
|
||||
* responsible for registering them.
|
||||
*
|
||||
* This avoids circular initialization: plugin-loader imports only
|
||||
* adapter-utils, never registry.ts.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ServerAdapterModule } from "./types.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
import {
|
||||
listAdapterPlugins,
|
||||
getAdapterPluginsDir,
|
||||
getAdapterPluginByType,
|
||||
} from "../services/adapter-plugin-store.js";
|
||||
import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory UI parser cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const uiParserCache = new Map<string, string>();
|
||||
|
||||
export function getUiParserSource(adapterType: string): string | undefined {
|
||||
return uiParserCache.get(adapterType);
|
||||
}
|
||||
|
||||
/**
|
||||
* On cache miss, attempt on-demand extraction from the plugin store.
|
||||
* Makes the ui-parser.js endpoint self-healing.
|
||||
*/
|
||||
export function getOrExtractUiParserSource(adapterType: string): string | undefined {
|
||||
const cached = uiParserCache.get(adapterType);
|
||||
if (cached) return cached;
|
||||
|
||||
const record = getAdapterPluginByType(adapterType);
|
||||
if (!record) return undefined;
|
||||
|
||||
const packageDir = resolvePackageDir(record);
|
||||
const source = extractUiParserSource(packageDir, record.packageName);
|
||||
if (source) {
|
||||
uiParserCache.set(adapterType, source);
|
||||
logger.info(
|
||||
{ type: adapterType, packageName: record.packageName, origin: "lazy" },
|
||||
"UI parser extracted on-demand (cache miss)",
|
||||
);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolvePackageDir(record: Pick<AdapterPluginRecord, "localPath" | "packageName">): string {
|
||||
return record.localPath
|
||||
? path.resolve(record.localPath)
|
||||
: path.resolve(getAdapterPluginsDir(), "node_modules", record.packageName);
|
||||
}
|
||||
|
||||
function resolvePackageEntryPoint(packageDir: string): string {
|
||||
const pkgJsonPath = path.join(packageDir, "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
||||
|
||||
if (pkg.exports && typeof pkg.exports === "object" && pkg.exports["."]) {
|
||||
const exp = pkg.exports["."];
|
||||
return typeof exp === "string" ? exp : (exp.import ?? exp.default ?? "index.js");
|
||||
}
|
||||
return pkg.main ?? "index.js";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI parser extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SUPPORTED_PARSER_CONTRACT = "1";
|
||||
|
||||
function extractUiParserSource(
|
||||
packageDir: string,
|
||||
packageName: string,
|
||||
): string | undefined {
|
||||
const pkgJsonPath = path.join(packageDir, "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
||||
|
||||
if (!pkg.exports || typeof pkg.exports !== "object" || !pkg.exports["./ui-parser"]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const contractVersion = pkg.paperclip?.adapterUiParser;
|
||||
if (contractVersion) {
|
||||
const major = contractVersion.split(".")[0];
|
||||
if (major !== SUPPORTED_PARSER_CONTRACT) {
|
||||
logger.warn(
|
||||
{ packageName, contractVersion, supported: `${SUPPORTED_PARSER_CONTRACT}.x` },
|
||||
"Adapter declares unsupported UI parser contract version — skipping UI parser",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
{ packageName },
|
||||
"Adapter has ./ui-parser export but no paperclip.adapterUiParser version — loading anyway (future versions may require it)",
|
||||
);
|
||||
}
|
||||
|
||||
const uiParserExp = pkg.exports["./ui-parser"];
|
||||
const uiParserFile = typeof uiParserExp === "string"
|
||||
? uiParserExp
|
||||
: (uiParserExp.import ?? uiParserExp.default);
|
||||
const uiParserPath = path.resolve(packageDir, uiParserFile);
|
||||
|
||||
if (!uiParserPath.startsWith(packageDir + path.sep) && uiParserPath !== packageDir) {
|
||||
logger.warn(
|
||||
{ packageName, uiParserFile },
|
||||
"UI parser path escapes package directory — skipping",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(uiParserPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = fs.readFileSync(uiParserPath, "utf-8");
|
||||
logger.info(
|
||||
{ packageName, uiParserFile, size: source.length },
|
||||
`Loaded UI parser from adapter package${contractVersion ? "" : " (no version declared)"}`,
|
||||
);
|
||||
return source;
|
||||
} catch (err) {
|
||||
logger.warn({ err, packageName, uiParserFile }, "Failed to read UI parser from adapter package");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load / reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validateAdapterModule(mod: unknown, packageName: string): ServerAdapterModule {
|
||||
const m = mod as Record<string, unknown>;
|
||||
const createServerAdapter = m.createServerAdapter;
|
||||
if (typeof createServerAdapter !== "function") {
|
||||
throw new Error(
|
||||
`Package "${packageName}" does not export createServerAdapter(). ` +
|
||||
`Ensure the package's main entry exports a createServerAdapter function.`,
|
||||
);
|
||||
}
|
||||
|
||||
const adapterModule = createServerAdapter() as ServerAdapterModule;
|
||||
if (!adapterModule || !adapterModule.type) {
|
||||
throw new Error(
|
||||
`createServerAdapter() from "${packageName}" returned an invalid module (missing "type").`,
|
||||
);
|
||||
}
|
||||
return adapterModule;
|
||||
}
|
||||
|
||||
export async function loadExternalAdapterPackage(
|
||||
packageName: string,
|
||||
localPath?: string,
|
||||
): Promise<ServerAdapterModule> {
|
||||
const packageDir = localPath
|
||||
? path.resolve(localPath)
|
||||
: path.resolve(getAdapterPluginsDir(), "node_modules", packageName);
|
||||
|
||||
const entryPoint = resolvePackageEntryPoint(packageDir);
|
||||
const modulePath = path.resolve(packageDir, entryPoint);
|
||||
const uiParserSource = extractUiParserSource(packageDir, packageName);
|
||||
|
||||
logger.info({ packageName, packageDir, entryPoint, modulePath, hasUiParser: !!uiParserSource }, "Loading external adapter package");
|
||||
|
||||
const mod = await import(modulePath);
|
||||
const adapterModule = validateAdapterModule(mod, packageName);
|
||||
|
||||
if (uiParserSource) {
|
||||
uiParserCache.set(adapterModule.type, uiParserSource);
|
||||
}
|
||||
|
||||
return adapterModule;
|
||||
}
|
||||
|
||||
async function loadFromRecord(record: AdapterPluginRecord): Promise<ServerAdapterModule | null> {
|
||||
try {
|
||||
return await loadExternalAdapterPackage(record.packageName, record.localPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, packageName: record.packageName, type: record.type },
|
||||
"Failed to dynamically load external adapter; skipping",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload an external adapter at runtime (dev iteration without server restart).
|
||||
* Busts the ESM module cache via a cache-busting query string.
|
||||
*/
|
||||
export async function reloadExternalAdapter(
|
||||
type: string,
|
||||
): Promise<ServerAdapterModule | null> {
|
||||
const record = getAdapterPluginByType(type);
|
||||
if (!record) return null;
|
||||
|
||||
const packageDir = resolvePackageDir(record);
|
||||
const entryPoint = resolvePackageEntryPoint(packageDir);
|
||||
const modulePath = path.resolve(packageDir, entryPoint);
|
||||
|
||||
const cacheBustUrl = `file://${modulePath}?t=${Date.now()}`;
|
||||
|
||||
logger.info(
|
||||
{ type, packageName: record.packageName, modulePath, cacheBustUrl },
|
||||
"Reloading external adapter (cache bust)",
|
||||
);
|
||||
|
||||
const mod = await import(cacheBustUrl);
|
||||
const adapterModule = validateAdapterModule(mod, record.packageName);
|
||||
|
||||
uiParserCache.delete(type);
|
||||
const uiParserSource = extractUiParserSource(packageDir, record.packageName);
|
||||
if (uiParserSource) {
|
||||
uiParserCache.set(adapterModule.type, uiParserSource);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ type, packageName: record.packageName, hasUiParser: !!uiParserSource },
|
||||
"Successfully reloaded external adapter",
|
||||
);
|
||||
|
||||
return adapterModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build all external adapter modules from the plugin store.
|
||||
*/
|
||||
export async function buildExternalAdapters(): Promise<ServerAdapterModule[]> {
|
||||
const results: ServerAdapterModule[] = [];
|
||||
|
||||
const storeRecords = listAdapterPlugins();
|
||||
for (const record of storeRecords) {
|
||||
const adapter = await loadFromRecord(record);
|
||||
if (adapter) {
|
||||
results.push(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
logger.info(
|
||||
{ count: results.length, adapters: results.map((a) => a.type) },
|
||||
"Loaded external adapters from plugin store",
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue