/ web / src / plugins / usePlugins.ts
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  }