Files
IdeA/frontend/src/plugins/runtime/loader.ts

330 lines
10 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 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 type { 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;
services?: PluginServices;
}
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),
};
}
function hasCapability(entry: PluginRuntimePlugin, capability: string): boolean {
return arrayOrEmpty<string>(objectOrEmpty(entry).capabilities).includes(capability);
}
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,
};
if (hasCapability(entry, "tooling")) {
ctx.services = createPluginServices(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);
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}`,
);
}
return { registry, failures };
}