| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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"; |
|
|
| |
| |
| |
|
|
| const uiParserCache = new Map<string, string>(); |
|
|
| export function getUiParserSource(adapterType: string): string | undefined { |
| return uiParserCache.get(adapterType); |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| 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"; |
| } |
|
|
| |
| |
| |
|
|
| 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; |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| 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}`; |
|
|
| |
| |
| |
| try { |
| |
| const bunCache = globalThis.Bun?.__moduleCache as Map<string, unknown> | undefined; |
| if (bunCache) { |
| bunCache.delete(fileUrl); |
| bunCache.delete(modulePath); |
| } |
| } catch { |
| |
| } |
|
|
| 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; |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|