mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
- Fix external adapters (hermes, droid) not auto-selected when navigating with ?adapterType= param — was using a stale module-level Set built before async adapter registration - Move SchemaConfigFields to render after thinking effort (same visual area as Claude's chrome toggle) instead of bottom of config section - Extract SelectField into its own component to fix React hooks order violation when schema fields change between renders - Add onAdapterChange() subscription in registry.ts so registerUIAdapter() notifies components when dynamic parsers load, fixing stale parser for old runs - Add parserTick to both RunTranscriptView and useLiveRunTranscripts to force recomputation on parser change
277 lines
9 KiB
TypeScript
277 lines
9 KiB
TypeScript
/**
|
|
* 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 fileUrl = `file://${modulePath}`;
|
|
|
|
// Bust ESM module cache so re-import loads fresh code from disk.
|
|
// Query-string trick (?t=...) works in Node; Bun may need the file:// URL
|
|
// to be evicted from its internal registry first.
|
|
try {
|
|
// @ts-expect-error -- Bun internal module cache
|
|
const bunCache = globalThis.Bun?.__moduleCache as Map<string, unknown> | undefined;
|
|
if (bunCache) {
|
|
bunCache.delete(fileUrl);
|
|
bunCache.delete(modulePath);
|
|
}
|
|
} catch {
|
|
// Ignore — query-string fallback still works in Node
|
|
}
|
|
|
|
const cacheBustUrl = `${fileUrl}?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;
|
|
}
|