feat(frontend): système de plugins — runtime, menus, layouts custom (#43)
Lots F1-F4 : runtime de chargement/registre plugin, extension des menus existants, panneau de gestion des plugins, types de layout custom (sélecteur, fallback, cellule dédiée) branchés sur le port plugin. Suite npm typecheck/test verte (947/947). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
150
frontend/src/plugins/runtime/loader.ts
Normal file
150
frontend/src/plugins/runtime/loader.ts
Normal file
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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 { PluginRuntimePlugin } from "@/domain";
|
||||
import {
|
||||
PluginCommandRegistry,
|
||||
PluginLayoutRegistry,
|
||||
PluginMenuRegistry,
|
||||
PluginRuntimeRegistry,
|
||||
type LoadedPlugin,
|
||||
type PluginGatewaySet,
|
||||
} from "./registry";
|
||||
|
||||
export type { PluginGatewaySet } from "./registry";
|
||||
|
||||
export interface IdeaPluginContext extends PluginGatewaySet {
|
||||
pluginId: string;
|
||||
pluginDisplayName: string;
|
||||
version: string;
|
||||
commands: PluginCommandRegistry;
|
||||
layouts: PluginLayoutRegistry;
|
||||
menu: PluginMenuRegistry;
|
||||
}
|
||||
|
||||
export interface PluginActivation {
|
||||
dispose?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface IdeaPluginModule {
|
||||
activate(ctx: IdeaPluginContext): PluginActivation | Promise<PluginActivation>;
|
||||
}
|
||||
|
||||
export interface PluginLoadFailure {
|
||||
pluginId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PluginLoadResult {
|
||||
registry: PluginRuntimeRegistry;
|
||||
failures: PluginLoadFailure[];
|
||||
}
|
||||
|
||||
function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
|
||||
return (
|
||||
typeof mod === "object" &&
|
||||
mod !== null &&
|
||||
"activate" in mod &&
|
||||
typeof (mod as { activate: unknown }).activate === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function loadOne(
|
||||
entry: PluginRuntimePlugin,
|
||||
gateways: PluginGatewaySet,
|
||||
): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> {
|
||||
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 mod: unknown = await import(/* @vite-ignore */ entry.bundleUrl);
|
||||
if (!isIdeaPluginModule(mod)) {
|
||||
return {
|
||||
failure: {
|
||||
pluginId: entry.id,
|
||||
reason: `bundle does not export an "activate(ctx)" function`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const declaredCommandIds = new Set(entry.contributes.menuItems.map((item) => item.command));
|
||||
const declaredLayoutTypes = new Set(entry.contributes.layouts.map((layout) => layout.type));
|
||||
const commands = new PluginCommandRegistry(entry.id, declaredCommandIds);
|
||||
const layouts = new PluginLayoutRegistry(entry.id, declaredLayoutTypes);
|
||||
const menu = new PluginMenuRegistry(entry.id);
|
||||
|
||||
const ctx: IdeaPluginContext = {
|
||||
pluginId: entry.id,
|
||||
pluginDisplayName: entry.displayName,
|
||||
version: entry.version,
|
||||
commands,
|
||||
layouts,
|
||||
menu,
|
||||
...gateways,
|
||||
};
|
||||
|
||||
const activation = await mod.activate(ctx);
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
pluginId: entry.id,
|
||||
displayName: entry.displayName,
|
||||
contributes: entry.contributes,
|
||||
commands,
|
||||
layouts,
|
||||
menu,
|
||||
dispose: async () => {
|
||||
// Best-effort, full-trust (carnet §1.3) — a broken `dispose()` must not
|
||||
// prevent removing the plugin from the runtime registry.
|
||||
try {
|
||||
await activation.dispose?.();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
};
|
||||
return { plugin };
|
||||
} catch (e) {
|
||||
return {
|
||||
failure: {
|
||||
pluginId: entry.id,
|
||||
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,
|
||||
): Promise<PluginLoadResult> {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
const failures: PluginLoadFailure[] = [];
|
||||
|
||||
const results = await Promise.all(catalogPlugins.map((entry) => loadOne(entry, gateways)));
|
||||
for (const result of results) {
|
||||
if ("failure" in result) failures.push(result.failure);
|
||||
else registry.add(result.plugin);
|
||||
}
|
||||
|
||||
return { registry, failures };
|
||||
}
|
||||
Reference in New Issue
Block a user