usePlugins.ts
1 /** 2 * usePlugins hook — discovers and loads dashboard plugins. 3 * 4 * 1. Fetches plugin manifests from GET /api/dashboard/plugins 5 * 2. Injects CSS <link> tags for plugins that declare css 6 * 3. Loads plugin JS bundles via <script> tags 7 * 4. Waits for plugins to call register() and resolves them 8 */ 9 10 import { useState, useEffect, useRef } from "react"; 11 import { api } from "@/lib/api"; 12 import type { PluginManifest, RegisteredPlugin } from "./types"; 13 import { 14 getPluginComponent, 15 onPluginRegistered, 16 notifyPluginRegistry, 17 setPluginLoadError, 18 } from "./registry"; 19 20 export function usePlugins() { 21 const [manifests, setManifests] = useState<PluginManifest[]>([]); 22 const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]); 23 const [loading, setLoading] = useState(true); 24 const loadedScripts = useRef<Set<string>>(new Set()); 25 26 // Fetch manifests on mount. 27 useEffect(() => { 28 api 29 .getPlugins() 30 .then((list) => { 31 setManifests(list); 32 if (list.length === 0) setLoading(false); 33 }) 34 .catch(() => setLoading(false)); 35 }, []); 36 37 // Load plugin assets when manifests arrive. 38 useEffect(() => { 39 if (manifests.length === 0) return; 40 41 const injectedScripts: HTMLScriptElement[] = []; 42 43 for (const manifest of manifests) { 44 // Inject CSS if specified. 45 if (manifest.css) { 46 const cssUrl = `/dashboard-plugins/${manifest.name}/${manifest.css}`; 47 if (!document.querySelector(`link[href="${cssUrl}"]`)) { 48 const link = document.createElement("link"); 49 link.rel = "stylesheet"; 50 link.href = cssUrl; 51 document.head.appendChild(link); 52 } 53 } 54 55 // Load JS bundle. In dev, cache-bust so Vite HMR can clear the 56 // in-memory registry while the browser would otherwise never 57 // re-execute a previously cached <script> URL. 58 const baseUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`; 59 const scriptSrc = import.meta.env.DEV 60 ? `${baseUrl}?hermes_dv=${Date.now()}` 61 : baseUrl; 62 if (!import.meta.env.DEV) { 63 if (loadedScripts.current.has(baseUrl)) continue; 64 loadedScripts.current.add(baseUrl); 65 } 66 67 const script = document.createElement("script"); 68 script.setAttribute("data-hermes-plugin", manifest.name); 69 script.src = scriptSrc; 70 script.async = true; 71 script.onerror = () => { 72 setPluginLoadError(manifest.name, "LOAD_FAILED"); 73 console.warn( 74 `[plugins] Failed to load ${manifest.name} from ${scriptSrc} (open Network tab)`, 75 ); 76 }; 77 script.onload = () => { 78 notifyPluginRegistry(); 79 queueMicrotask(() => { 80 if (getPluginComponent(manifest.name)) return; 81 setPluginLoadError(manifest.name, "NO_REGISTER"); 82 }); 83 }; 84 document.body.appendChild(script); 85 injectedScripts.push(script); 86 } 87 88 // Give plugins a moment to load and register, then stop loading state. 89 const timeout = setTimeout(() => setLoading(false), 2000); 90 return () => { 91 clearTimeout(timeout); 92 if (import.meta.env.DEV) { 93 for (const el of injectedScripts) { 94 el.remove(); 95 } 96 } 97 }; 98 }, [manifests]); 99 100 // Listen for plugin registrations and resolve them against manifests. 101 useEffect(() => { 102 function resolvePlugins() { 103 const resolved: RegisteredPlugin[] = []; 104 for (const manifest of manifests) { 105 const component = getPluginComponent(manifest.name); 106 if (component) { 107 resolved.push({ manifest, component }); 108 } 109 } 110 setPlugins(resolved); 111 // If all plugins registered, stop loading early. 112 if (resolved.length === manifests.length && manifests.length > 0) { 113 setLoading(false); 114 } 115 } 116 117 resolvePlugins(); 118 const unsub = onPluginRegistered(resolvePlugins); 119 return unsub; 120 }, [manifests]); 121 122 return { plugins, manifests, loading }; 123 }