Spaces:
Paused
Paused
| /** | |
| * 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; | |
| } | |