/** * Plugin bootstrap loader (ticket #43, F1, carnet §1.3/§6). * * At UI bootstrap, the app calls {@link loadPlugins} once with the catalog * from `PluginGateway.listRuntimeContributions()` (already filtered by the * backend to `enabled && !pendingUninstall`, carnet §1.3) and the gateway set * used to build the public service facade. For each entry it dynamically imports * the bundle URL, validates the module shape, and calls `activate(ctx)`, * scoping the command/layout registries to exactly the ids declared in that * plugin's manifest (enforced by {@link PluginCommandRegistry}/ * {@link PluginLayoutRegistry} themselves). * * A single plugin failing to load/activate must not break the rest of the * app or the other plugins — each is loaded independently and failures are * collected, never thrown past `loadPlugins`. */ import * as React from "react"; import * as ReactDom from "react-dom"; import * as ReactDomClient from "react-dom/client"; import * as ReactJsxRuntime from "react/jsx-runtime"; import * as ReactJsxDevRuntime from "react/jsx-dev-runtime"; import type { JsonValue, PluginContributionDto, PluginRuntimePlugin } from "@/domain"; import { PluginCommandRegistry, PluginLayoutRegistry, PluginMenuRegistry, PluginRuntimeRegistry, type Disposable, type LoadedPlugin, type PluginGatewaySet, } from "./registry"; import { createPluginServices, type PluginServices } from "./services"; export type { PluginGatewaySet } from "./registry"; export interface IdeaPluginContext { pluginId: string; pluginDisplayName: string; version: string; logger: PluginLogger; subscriptions: Disposable[]; commands: PluginCommandContext; layouts: PluginLayoutRegistry; menu: PluginMenuRegistry; storage: PluginStorage; services?: PluginServices; } export interface PluginStorage { get(key: string): Promise; set(key: string, value: JsonValue): Promise; delete(key: string): Promise; } export interface PluginActivation { dispose?: () => void | Promise; } export interface PluginLogger { debug(message: string, ...args: unknown[]): void; info(message: string, ...args: unknown[]): void; warn(message: string, ...args: unknown[]): void; error(message: string, ...args: unknown[]): void; } export interface PluginCommandContext { register(commandId: string, handler: (...args: unknown[]) => void | Promise): Disposable; registerCommand( commandId: string, handler: (...args: unknown[]) => unknown | Promise, ): Disposable; } export interface IdeaPluginModule { activate(ctx: IdeaPluginContext): void | PluginActivation | Promise; } export interface PluginLoadFailure { pluginId: string; reason: string; } export interface PluginLoadPending { pluginId: string; displayName: string; reason: string; } export interface PluginLoadResult { registry: PluginRuntimeRegistry; failures: PluginLoadFailure[]; pending: PluginLoadPending[]; } export interface PluginLoadOptions { timeoutMs?: number; } const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000; let hostReactImportMapInstalled = false; async function withTimeout( promise: Promise, timeoutMs: number, label: string, ): Promise { let timeout: ReturnType | undefined; try { return await Promise.race([ promise, new Promise((_, reject) => { timeout = setTimeout(() => { reject(new Error(`${label} timed out after ${timeoutMs} ms`)); }, timeoutMs); }), ]); } finally { if (timeout) clearTimeout(timeout); } } function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule { return ( typeof mod === "object" && mod !== null && "activate" in mod && typeof (mod as { activate: unknown }).activate === "function" ); } function resolveIdeaPluginModule(mod: unknown): IdeaPluginModule | null { if (isIdeaPluginModule(mod)) return mod; const defaultExport = objectOrEmpty(mod).default; return isIdeaPluginModule(defaultExport) ? defaultExport : null; } function createPluginLogger(entry: PluginRuntimePlugin): PluginLogger { const prefix = `[plugin:${safePluginId(entry)}]`; return { debug: (message, ...args) => console.debug(prefix, message, ...args), info: (message, ...args) => console.info(prefix, message, ...args), warn: (message, ...args) => console.warn(prefix, message, ...args), error: (message, ...args) => console.error(prefix, message, ...args), }; } function createCommandContext(commands: PluginCommandRegistry): PluginCommandContext { return { register: (commandId, handler) => commands.register(commandId, handler), registerCommand: (commandId, handler) => commands.register(commandId, async (...args) => { await handler(...args); }), }; } function arrayOrEmpty(value: unknown): T[] { return Array.isArray(value) ? (value as T[]) : []; } function objectOrEmpty(value: unknown): Record { return value !== null && typeof value === "object" ? (value as Record) : {}; } function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } function installHostReactImportMap(): void { if (hostReactImportMapInstalled) { hostReactImportMapInstalled = true; return; } const globalHost = globalThis as unknown as { __IDEA_PLUGIN_HOST_REACT__?: typeof React; __IDEA_PLUGIN_HOST_REACT_DOM__?: typeof ReactDom; __IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__?: typeof ReactDomClient; __IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__?: typeof ReactJsxRuntime; __IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__?: typeof ReactJsxDevRuntime; }; globalHost.__IDEA_PLUGIN_HOST_REACT__ = React; globalHost.__IDEA_PLUGIN_HOST_REACT_DOM__ = ReactDom; globalHost.__IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__ = ReactDomClient; globalHost.__IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__ = ReactJsxRuntime; globalHost.__IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__ = ReactJsxDevRuntime; hostReactImportMapInstalled = true; } function safePluginId(entry: unknown): string { return nonEmptyString(objectOrEmpty(entry).id) ?? ""; } function commandIdsFromContributes(contributes: PluginContributionDto): Set { return new Set( contributes.menuItems.flatMap((item) => { const command = nonEmptyString(objectOrEmpty(item).command); return command ? [command] : []; }), ); } function layoutTypesFromContributes(contributes: PluginContributionDto): Set { return new Set( contributes.layouts.flatMap((layout) => { const type = nonEmptyString(objectOrEmpty(layout).type); return type ? [type] : []; }), ); } function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto { const contributes = objectOrEmpty(entry.contributes) as Partial; return { menus: arrayOrEmpty(contributes?.menus), menuItems: arrayOrEmpty(contributes?.menuItems), layouts: arrayOrEmpty(contributes?.layouts), mcpServers: arrayOrEmpty(contributes?.mcpServers), }; } function hasCapability(entry: PluginRuntimePlugin, capability: string): boolean { return arrayOrEmpty(objectOrEmpty(entry).capabilities).includes(capability); } function activationScope(entry: PluginRuntimePlugin): "app" | "project" { return objectOrEmpty(entry).activationScope === "project" ? "project" : "app"; } function pendingForProjectFocus(entry: PluginRuntimePlugin): PluginLoadPending { const entryObject = objectOrEmpty(entry); const pluginId = safePluginId(entry); return { pluginId, displayName: nonEmptyString(entryObject.displayName) ?? pluginId, reason: "En attente d'un projet actif.", }; } async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise { try { await activation?.dispose?.(); } catch { /* best-effort */ } for (const disposable of disposables.splice(0).reverse()) { try { disposable.dispose(); } catch { /* best-effort */ } } } async function loadOne( entry: PluginRuntimePlugin, gateways: PluginGatewaySet, options: Required, ): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> { const entryObject = objectOrEmpty(entry); const pluginId = safePluginId(entry); const displayName = nonEmptyString(entryObject.displayName) ?? pluginId; const version = nonEmptyString(entryObject.version) ?? ""; let activation: void | PluginActivation = undefined; const subscriptions: Disposable[] = []; try { // The bundle URL is a plugin-scoped, content-hashed local protocol URL // served by the backend (carnet §1.3) — never a disk path or arbitrary // remote URL, and the content hash busts the module cache after updates. const bundleUrl = nonEmptyString(entryObject.bundleUrl); if (!bundleUrl) { throw new Error("missing plugin bundle URL"); } installHostReactImportMap(); const mod = resolveIdeaPluginModule( await withTimeout( import(/* @vite-ignore */ bundleUrl), options.timeoutMs, "importing plugin bundle", ), ); if (!mod) { return { failure: { pluginId, reason: `bundle does not export an "activate(ctx)" function`, }, }; } const contributes = normalizeContributes(entry); const declaredCommandIds = commandIdsFromContributes(contributes); const declaredLayoutTypes = layoutTypesFromContributes(contributes); const commands = new PluginCommandRegistry(pluginId, declaredCommandIds); const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes); const menu = new PluginMenuRegistry(pluginId); const storage = createPluginStorage(gateways, pluginId); const ctx: IdeaPluginContext = { pluginId, pluginDisplayName: displayName, version, logger: createPluginLogger(entry), subscriptions, commands: createCommandContext(commands), layouts, menu, storage, }; if (hasCapability(entry, "tooling") || hasCapability(entry, "ui")) { ctx.services = createPluginServices(gateways, { pluginId, declaredLayoutTypes, }); } activation = await withTimeout( Promise.resolve(mod.activate(ctx)), options.timeoutMs, "activating plugin", ); const plugin: LoadedPlugin = { pluginId, displayName, contributes, commands, layouts, menu, dispose: async () => { // Best-effort, full-trust (carnet §1.3) — a broken `dispose()` or // subscription must not prevent removing the plugin from the registry. await disposeAll(subscriptions, activation); }, }; return { plugin }; } catch (e) { await disposeAll(subscriptions, activation); return { failure: { pluginId, reason: e instanceof Error ? e.message : String(e), }, }; } } function createPluginStorage(gateways: PluginGatewaySet, pluginId: string): PluginStorage { return { async get(key: string): Promise { const value = await gateways.pluginStorage.get({ pluginId, key }); return value === null ? undefined : (value as T); }, async set(key, value) { await gateways.pluginStorage.set({ pluginId, key, value }); }, async delete(key) { await gateways.pluginStorage.delete({ pluginId, key }); }, }; } /** * Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}. * Called once at app bootstrap (and never again in the same session — carnet * §1.3: disable/uninstall only masks contributions in-session, the actual * unload happens on next restart via a fresh `loadPlugins` call). */ export async function loadPlugins( catalogPlugins: PluginRuntimePlugin[], gateways: PluginGatewaySet, options: PluginLoadOptions = {}, ): Promise { const registry = new PluginRuntimeRegistry(); const failures: PluginLoadFailure[] = []; const pending: PluginLoadPending[] = []; const resolvedOptions: Required = { timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS, }; const entries = Array.isArray(catalogPlugins) ? catalogPlugins : []; const focus = await gateways.focusedProject?.getFocusedProject?.(); const loadableEntries = entries.filter((entry) => { if (activationScope(entry) !== "project" || focus) return true; pending.push(pendingForProjectFocus(entry)); return false; }); const results = await Promise.all( loadableEntries.map((entry) => loadOne(entry, gateways, resolvedOptions)), ); for (const result of results) { if ("failure" in result) { failures.push(result.failure); console.warn( `[plugins] load failed plugin=${result.failure.pluginId}: ${result.failure.reason}`, ); } else { registry.add(result.plugin); } } if (entries.length > 0) { console.info( `[plugins] load complete loaded=${registry.list().length} failed=${failures.length} pending=${pending.length}`, ); } return { registry, failures, pending }; }