mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
* feat(plugin): add optional agent navigation capability * feat(plugin): extend host navigation capability * fix(plugin): preserve navigation tests after sdk rebase * fix(plugin): scope host navigation contracts --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
172 lines
6.0 KiB
TypeScript
172 lines
6.0 KiB
TypeScript
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<string, InstalledPlugin[]>();
|
|
private readonly listeners = new Set<() => void>();
|
|
private snapshot: InstalledPlugin[] = [];
|
|
private readonly disposed = new WeakSet<InstalledPlugin>();
|
|
private readonly evaluationErrors = new Map<string, string>();
|
|
|
|
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]);
|
|
}
|