import { useMemo, useSyncExternalStore } from "react"; import { QueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { startPluginClientSide } from "./composer-pills/lifecycle"; import { evaluatePluginClientBundle } from "./evaluate"; import type { InstalledPlugin } from "./types"; interface CatalogPlugin { id: string; clientBundle: string; } class PluginRegistry { private readonly byHost = new Map(); private readonly listeners = new Set<() => void>(); private snapshot: InstalledPlugin[] = []; private readonly disposed = new WeakSet(); private readonly evaluationErrors = new Map(); subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); }; getSnapshot = (): InstalledPlugin[] => this.snapshot; getEvaluationError(serverId: string, pluginId: string): string | undefined { return this.evaluationErrors.get(`${serverId}/${pluginId}`); } installCatalog( serverId: string, catalog: CatalogPlugin[], options: { replacePluginId?: string; client?: DaemonClient; } = {}, ): boolean { const previous = this.byHost.get(serverId) ?? []; const previousTimelineBundles = previous .filter((plugin) => plugin.timelineTransformers.length > 0) .map((plugin) => `${plugin.id}\0${plugin.clientBundle}`); const preserved = catalog.flatMap((entry) => { const existing = previous.find( (plugin) => plugin.id !== options.replacePluginId && plugin.id === entry.id && plugin.clientBundle === entry.clientBundle, ); return existing ? [existing] : []; }); const removed = previous.filter((plugin) => !preserved.includes(plugin)); if (removed.length > 0) { this.byHost.set(serverId, preserved); this.publish(); for (const plugin of removed) this.dispose(plugin); } const installed = catalog.flatMap((entry) => { const key = `${serverId}/${entry.id}`; try { const existing = preserved.find( (plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle, ); if (existing) { this.evaluationErrors.delete(key); return [existing]; } const queryClient = new QueryClient(); const evaluated = evaluatePluginClientBundle(entry.id, entry.clientBundle); const installation: InstalledPlugin = { ...evaluated, serverId, clientBundle: entry.clientBundle, queryClient, }; if (installation.clientSide) { if (!options.client) throw new Error("Plugin client runtime is unavailable"); let clientCleanup; try { clientCleanup = startPluginClientSide(installation, options.client); } catch (error) { queryClient.clear(); void Promise.resolve(evaluated.cleanup()).catch(() => undefined); throw error; } installation.cleanup = async () => { await clientCleanup(); await evaluated.cleanup(); }; } this.evaluationErrors.delete(key); return [installation]; } catch (error) { this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error)); console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error); return []; } }); const configuredIds = new Set(catalog.map((entry) => entry.id)); for (const key of this.evaluationErrors.keys()) { if (key.startsWith(`${serverId}/`) && !configuredIds.has(key.slice(serverId.length + 1))) { this.evaluationErrors.delete(key); } } this.byHost.set(serverId, installed); this.publish(); const installedTimelineBundles = installed .filter((plugin) => plugin.timelineTransformers.length > 0) .map((plugin) => `${plugin.id}\0${plugin.clientBundle}`); return ( previousTimelineBundles.length !== installedTimelineBundles.length || previousTimelineBundles.some((bundle, index) => bundle !== installedTimelineBundles[index]) ); } removeHost(serverId: string): void { const installed = this.byHost.get(serverId); if (!installed) return; for (const plugin of installed) this.dispose(plugin); for (const key of this.evaluationErrors.keys()) { if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key); } this.byHost.delete(serverId); this.publish(); } private dispose(plugin: InstalledPlugin): void { if (this.disposed.has(plugin)) return; this.disposed.add(plugin); plugin.queryClient.clear(); try { void Promise.resolve(plugin.cleanup()).catch((error) => { console.warn(`[Plugins] Cleanup failed for ${plugin.serverId}/${plugin.id}`, error); }); } catch (error) { console.warn(`[Plugins] Cleanup failed for ${plugin.serverId}/${plugin.id}`, error); } } private publish(): void { this.snapshot = [...this.byHost.values()] .flat() .sort((left, right) => `${left.serverId}/${left.id}`.localeCompare(`${right.serverId}/${right.id}`), ); for (const listener of this.listeners) listener(); } } export const pluginRegistry = new PluginRegistry(); export function useInstalledPlugins(): InstalledPlugin[] { return useSyncExternalStore( pluginRegistry.subscribe, pluginRegistry.getSnapshot, pluginRegistry.getSnapshot, ); } export function useInstalledPlugin(serverId: string, pluginId: string): InstalledPlugin | null { return ( useInstalledPlugins().find( (plugin) => plugin.serverId === serverId && plugin.id === pluginId, ) ?? null ); } export function usePluginInstallations(pluginId: string): InstalledPlugin[] { const installed = useInstalledPlugins(); return useMemo(() => installed.filter((plugin) => plugin.id === pluginId), [installed, pluginId]); }