Complète le diagnostic backend (#120) côté frontend : un crash React non capturé (ex. plugin cassant le rendu) laissait un écran noir sans trace, et une activation de plugin qui ne se résout jamais (promesse infinie) bloquait le chargement sans échouer. Ajoute RootErrorBoundary + logging d'erreurs globales autour de l'arbre React, et un timeout sur l'import/l'activation de chaque plugin dans loadPlugins pour transformer un hang silencieux en échec explicite et diagnosticable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
310 lines
9.5 KiB
TypeScript
310 lines
9.5 KiB
TypeScript
/**
|
|
* 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 stable
|
|
* gateways the plugin context exposes. 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 type { PluginContributionDto, PluginRuntimePlugin } from "@/domain";
|
|
import {
|
|
PluginCommandRegistry,
|
|
PluginLayoutRegistry,
|
|
PluginMenuRegistry,
|
|
PluginRuntimeRegistry,
|
|
type Disposable,
|
|
type LoadedPlugin,
|
|
type PluginGatewaySet,
|
|
} from "./registry";
|
|
|
|
export type { PluginGatewaySet } from "./registry";
|
|
|
|
export interface IdeaPluginContext extends PluginGatewaySet {
|
|
pluginId: string;
|
|
pluginDisplayName: string;
|
|
version: string;
|
|
logger: PluginLogger;
|
|
subscriptions: Disposable[];
|
|
commands: PluginCommandContext;
|
|
layouts: PluginLayoutRegistry;
|
|
menu: PluginMenuRegistry;
|
|
}
|
|
|
|
export interface PluginActivation {
|
|
dispose?: () => void | Promise<void>;
|
|
}
|
|
|
|
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<void>): Disposable;
|
|
registerCommand(
|
|
commandId: string,
|
|
handler: (...args: unknown[]) => unknown | Promise<unknown>,
|
|
): Disposable;
|
|
}
|
|
|
|
export interface IdeaPluginModule {
|
|
activate(ctx: IdeaPluginContext): void | PluginActivation | Promise<void | PluginActivation>;
|
|
}
|
|
|
|
export interface PluginLoadFailure {
|
|
pluginId: string;
|
|
reason: string;
|
|
}
|
|
|
|
export interface PluginLoadResult {
|
|
registry: PluginRuntimeRegistry;
|
|
failures: PluginLoadFailure[];
|
|
}
|
|
|
|
export interface PluginLoadOptions {
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000;
|
|
|
|
async function withTimeout<T>(
|
|
promise: Promise<T>,
|
|
timeoutMs: number,
|
|
label: string,
|
|
): Promise<T> {
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
promise,
|
|
new Promise<never>((_, 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<T>(value: unknown): T[] {
|
|
return Array.isArray(value) ? (value as T[]) : [];
|
|
}
|
|
|
|
function objectOrEmpty(value: unknown): Record<string, unknown> {
|
|
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
}
|
|
|
|
function nonEmptyString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
}
|
|
|
|
function safePluginId(entry: unknown): string {
|
|
return nonEmptyString(objectOrEmpty(entry).id) ?? "<unknown-plugin>";
|
|
}
|
|
|
|
function commandIdsFromContributes(contributes: PluginContributionDto): Set<string> {
|
|
return new Set<string>(
|
|
contributes.menuItems.flatMap<string>((item) => {
|
|
const command = nonEmptyString(objectOrEmpty(item).command);
|
|
return command ? [command] : [];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function layoutTypesFromContributes(contributes: PluginContributionDto): Set<string> {
|
|
return new Set<string>(
|
|
contributes.layouts.flatMap<string>((layout) => {
|
|
const type = nonEmptyString(objectOrEmpty(layout).type);
|
|
return type ? [type] : [];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto {
|
|
const contributes = objectOrEmpty(entry.contributes) as Partial<PluginContributionDto>;
|
|
return {
|
|
menus: arrayOrEmpty(contributes?.menus),
|
|
menuItems: arrayOrEmpty(contributes?.menuItems),
|
|
layouts: arrayOrEmpty(contributes?.layouts),
|
|
mcpServers: arrayOrEmpty(contributes?.mcpServers),
|
|
};
|
|
}
|
|
|
|
async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise<void> {
|
|
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<PluginLoadOptions>,
|
|
): 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");
|
|
}
|
|
|
|
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 ctx: IdeaPluginContext = {
|
|
pluginId,
|
|
pluginDisplayName: displayName,
|
|
version,
|
|
logger: createPluginLogger(entry),
|
|
subscriptions,
|
|
commands: createCommandContext(commands),
|
|
layouts,
|
|
menu,
|
|
...gateways,
|
|
};
|
|
|
|
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),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<PluginLoadResult> {
|
|
const registry = new PluginRuntimeRegistry();
|
|
const failures: PluginLoadFailure[] = [];
|
|
const resolvedOptions: Required<PluginLoadOptions> = {
|
|
timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS,
|
|
};
|
|
|
|
const entries = Array.isArray(catalogPlugins) ? catalogPlugins : [];
|
|
const results = await Promise.all(
|
|
entries.map((entry) => loadOne(entry, gateways, resolvedOptions)),
|
|
);
|
|
for (const result of results) {
|
|
if ("failure" in result) failures.push(result.failure);
|
|
else registry.add(result.plugin);
|
|
}
|
|
|
|
return { registry, failures };
|
|
}
|