File size: 7,119 Bytes
674d56a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | /**
* usePlugins hook — discovers and loads dashboard plugins.
*
* 1. Fetches plugin manifests from GET /api/dashboard/plugins
* 2. Injects CSS <link> tags for plugins that declare css
* 3. Loads plugin JS bundles via <script> tags
* 4. Waits for plugins to call register() and resolves them
*/
import { useState, useEffect, useRef } from "react";
import { api, HERMES_BASE_PATH } from "@/lib/api";
import type { PluginManifest, RegisteredPlugin } from "./types";
import {
getPluginComponent,
onPluginRegistered,
notifyPluginRegistry,
setPluginLoadError,
} from "./registry";
export const MANIFEST_CACHE_KEY = "hermes:plugin-manifests";
export function getCachedManifests(): PluginManifest[] | null {
try {
const raw = sessionStorage.getItem(MANIFEST_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as PluginManifest[]) : null;
} catch {
return null;
}
}
export function cacheManifests(manifests: PluginManifest[]): void {
try {
sessionStorage.setItem(MANIFEST_CACHE_KEY, JSON.stringify(manifests));
} catch {
// sessionStorage unavailable (private browsing, storage full, etc.)
}
}
/**
* Whether it is safe to skip the initial plugin-loading gate for a set of
* cached manifests.
*
* App.tsx waits on `pluginsLoading` before mounting the persistent ChatPage
* host: if a plugin overrides /chat (`tab.override === "/chat"`), mounting
* the built-in chat first would spawn a PTY and then yank it out from under
* the user when the plugin resolves. That gate is load-bearing — so we may
* only seed `loading = false` from the cache when no cached manifest
* declares a /chat override. Manifests are still seeded either way; only
* the loading flag stays conservative.
*/
export function canSeedLoadedFromCache(
cached: PluginManifest[] | null,
): boolean {
if (cached === null) return false;
return !cached.some((m) => m.tab?.override === "/chat");
}
export function usePlugins() {
// Lazy initialisers run once at mount — safe to read sessionStorage here.
// This avoids the "cannot access ref during render" lint error that would
// occur if we stored the cached value in a useRef and read .current in the
// useState initial value expression.
const [manifests, setManifests] = useState<PluginManifest[]>(
() => getCachedManifests() ?? [],
);
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
// Start loading=false when the cache has manifests so plugin routes are
// registered synchronously on the first render after a refresh.
// The catch-all in App.tsx is only a safety net for the very first visit
// (no cache yet). On subsequent visits this flag starts false immediately.
//
// Exception: if any cached manifest overrides /chat we must keep
// loading=true — App.tsx's pluginsLoading gate around the persistent
// ChatPage host is load-bearing (see canSeedLoadedFromCache).
const [loading, setLoading] = useState<boolean>(
() => !canSeedLoadedFromCache(getCachedManifests()),
);
const loadedScripts = useRef<Set<string>>(new Set());
// Always re-fetch in the background to keep the cache fresh.
// This handles: new plugins added, plugins removed, manifest changes.
// setManifests(list) will update routes if the server list differs from cache.
useEffect(() => {
api
.getPlugins()
.then((list) => {
cacheManifests(list);
setManifests(list);
if (list.length === 0) setLoading(false);
})
.catch(() => setLoading(false));
}, []);
// Load plugin assets when manifests arrive.
useEffect(() => {
if (manifests.length === 0) return;
const injectedScripts: HTMLScriptElement[] = [];
for (const manifest of manifests) {
// Inject CSS if specified.
if (manifest.css) {
const cssUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.css}`;
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = cssUrl;
document.head.appendChild(link);
}
}
// Load JS bundle. In dev, cache-bust so Vite HMR can clear the
// in-memory registry while the browser would otherwise never
// re-execute a previously cached <script> URL.
const baseUrl = `${HERMES_BASE_PATH}/dashboard-plugins/${manifest.name}/${manifest.entry}`;
const scriptSrc = import.meta.env.DEV
? `${baseUrl}?hermes_dv=${Date.now()}`
: baseUrl;
if (!import.meta.env.DEV) {
if (loadedScripts.current.has(baseUrl)) continue;
loadedScripts.current.add(baseUrl);
}
const script = document.createElement("script");
script.setAttribute("data-hermes-plugin", manifest.name);
script.src = scriptSrc;
script.async = true;
// SRI integrity verification — defense against compromised plugin
// delivery. Plugin manifests can declare an integrity hash
// (e.g. "sha384-...") which the browser verifies before executing.
// Without this, a man-in-the-middle or compromised plugin server
// can substitute the JS bundle silently. Opt-in: when no integrity
// is declared in the manifest, behavior is unchanged.
if (manifest.integrity && typeof manifest.integrity === "string") {
script.integrity = manifest.integrity;
script.crossOrigin = "anonymous";
}
script.onerror = () => {
setPluginLoadError(manifest.name, "LOAD_FAILED");
console.warn(
`[plugins] Failed to load ${manifest.name} from ${scriptSrc} (open Network tab)`,
);
};
script.onload = () => {
notifyPluginRegistry();
queueMicrotask(() => {
if (getPluginComponent(manifest.name)) return;
setPluginLoadError(manifest.name, "NO_REGISTER");
});
};
document.body.appendChild(script);
injectedScripts.push(script);
}
// Give plugins a moment to load and register, then stop loading state.
const timeout = setTimeout(() => setLoading(false), 2000);
return () => {
clearTimeout(timeout);
if (import.meta.env.DEV) {
for (const el of injectedScripts) {
el.remove();
}
}
};
}, [manifests]);
// Listen for plugin registrations and resolve them against manifests.
useEffect(() => {
function resolvePlugins() {
const resolved: RegisteredPlugin[] = [];
for (const manifest of manifests) {
const component = getPluginComponent(manifest.name);
if (component) {
resolved.push({ manifest, component });
}
}
setPlugins(resolved);
// If all plugins registered, stop loading early.
if (resolved.length === manifests.length && manifests.length > 0) {
setLoading(false);
}
}
resolvePlugins();
const unsub = onPluginRegistered(resolvePlugins);
return unsub;
}, [manifests]);
return { plugins, manifests, loading };
}
|