feat: plugins v1
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import type { TojuPluginManifest } from '../../../../shared-kernel';
|
||||
import {
|
||||
DEVELOPMENT_PLUGIN_ENTRYPOINT,
|
||||
DEVELOPMENT_PLUGIN_MANIFEST,
|
||||
DEVELOPMENT_PLUGIN_MODULE
|
||||
} from '../../development/development-plugin';
|
||||
import type {
|
||||
TojuClientPluginModule,
|
||||
TojuPluginActivationContext,
|
||||
TojuPluginDisposable
|
||||
} from '../../domain/models/plugin-api.models';
|
||||
import type {
|
||||
LocalPluginDiscoveryError,
|
||||
LocalPluginRegistrationResult,
|
||||
RegisteredPlugin
|
||||
} from '../../domain/models/plugin-runtime.models';
|
||||
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
|
||||
import { PluginCapabilityService } from './plugin-capability.service';
|
||||
import { PluginClientApiService } from './plugin-client-api.service';
|
||||
import { PluginLoggerService } from './plugin-logger.service';
|
||||
import { PluginRegistryService } from './plugin-registry.service';
|
||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
||||
|
||||
interface ActivePluginRuntime {
|
||||
context: TojuPluginActivationContext;
|
||||
module: TojuClientPluginModule;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginHostService {
|
||||
private readonly apiFactory = inject(PluginClientApiService);
|
||||
private readonly capabilities = inject(PluginCapabilityService);
|
||||
private readonly localDiscovery = inject(LocalPluginDiscoveryService);
|
||||
private readonly logger = inject(PluginLoggerService);
|
||||
private readonly registry = inject(PluginRegistryService);
|
||||
private readonly uiRegistry = inject(PluginUiRegistryService);
|
||||
private readonly activePlugins = new Map<string, ActivePluginRuntime>();
|
||||
|
||||
constructor() {
|
||||
this.registerDevelopmentPlugin();
|
||||
}
|
||||
|
||||
registerLocalManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
|
||||
return this.registry.registerManifest(manifestValue, sourcePath);
|
||||
}
|
||||
|
||||
async discoverLocalPlugins(): Promise<LocalPluginRegistrationResult> {
|
||||
const discovery = await this.localDiscovery.discoverManifests();
|
||||
const registered: RegisteredPlugin[] = [];
|
||||
const errors: LocalPluginDiscoveryError[] = [...discovery.errors];
|
||||
|
||||
for (const descriptor of discovery.plugins) {
|
||||
try {
|
||||
registered.push(this.registerLocalManifest(descriptor.manifest, descriptor.pluginRootUrl ?? descriptor.pluginRoot));
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
manifestPath: descriptor.manifestPath,
|
||||
message: error instanceof Error ? error.message : 'Plugin manifest validation failed',
|
||||
pluginRoot: descriptor.pluginRoot
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
discovery,
|
||||
errors,
|
||||
registered
|
||||
};
|
||||
}
|
||||
|
||||
getReadyManifests(): TojuPluginManifest[] {
|
||||
return this.registry.loadOrder().ordered;
|
||||
}
|
||||
|
||||
async activateReadyPlugins(): Promise<void> {
|
||||
const activated: TojuPluginActivationContext[] = [];
|
||||
|
||||
for (const manifest of this.registry.loadOrder().ordered) {
|
||||
const entry = this.registry.find(manifest.id);
|
||||
|
||||
if (!entry || !entry.enabled || this.activePlugins.has(manifest.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.activatePlugin(entry);
|
||||
|
||||
const active = this.activePlugins.get(manifest.id);
|
||||
|
||||
if (active) {
|
||||
activated.push(active.context);
|
||||
}
|
||||
}
|
||||
|
||||
for (const context of activated) {
|
||||
const active = this.activePlugins.get(context.pluginId);
|
||||
|
||||
if (!active?.module.ready) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await active.module.ready(context);
|
||||
this.registry.setState(context.pluginId, 'ready');
|
||||
} catch (error) {
|
||||
this.failPlugin(context.pluginId, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deactivatePlugin(pluginId: string): Promise<void> {
|
||||
const active = this.activePlugins.get(pluginId);
|
||||
|
||||
if (!active) {
|
||||
this.registry.setState(pluginId, 'unloaded');
|
||||
this.uiRegistry.unregisterPlugin(pluginId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.registry.setState(pluginId, 'unloading');
|
||||
|
||||
try {
|
||||
await active.module.deactivate?.(active.context);
|
||||
} catch (error) {
|
||||
this.logger.warn(pluginId, 'Plugin deactivate failed', error);
|
||||
}
|
||||
|
||||
for (const disposable of [...active.context.subscriptions].reverse()) {
|
||||
safeDispose(disposable, pluginId, this.logger);
|
||||
}
|
||||
|
||||
this.uiRegistry.unregisterPlugin(pluginId);
|
||||
this.activePlugins.delete(pluginId);
|
||||
this.registry.setState(pluginId, 'unloaded');
|
||||
}
|
||||
|
||||
async deactivateAll(): Promise<void> {
|
||||
const pluginIds = Array.from(this.activePlugins.keys()).reverse();
|
||||
|
||||
for (const pluginId of pluginIds) {
|
||||
await this.deactivatePlugin(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
async reloadPlugin(pluginId: string): Promise<void> {
|
||||
await this.deactivatePlugin(pluginId);
|
||||
|
||||
const entry = this.registry.find(pluginId);
|
||||
|
||||
if (entry?.enabled) {
|
||||
await this.activatePlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
markLoaded(pluginId: string): void {
|
||||
this.registry.setState(pluginId, 'loaded');
|
||||
}
|
||||
|
||||
markFailed(pluginId: string): void {
|
||||
this.registry.setState(pluginId, 'failed');
|
||||
}
|
||||
|
||||
private async activatePlugin(entry: RegisteredPlugin): Promise<void> {
|
||||
const manifest = entry.manifest;
|
||||
const missingCapabilities = this.capabilities.missing(manifest);
|
||||
|
||||
if (missingCapabilities.length > 0) {
|
||||
this.registry.setFailed(manifest.id, `Missing capabilities: ${missingCapabilities.join(', ')}`);
|
||||
this.logger.warn(manifest.id, 'Plugin blocked by missing capability grants', missingCapabilities);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!manifest.entrypoint) {
|
||||
this.registry.setState(manifest.id, 'ready');
|
||||
return;
|
||||
}
|
||||
|
||||
this.registry.setState(manifest.id, 'loading');
|
||||
|
||||
try {
|
||||
const module = await this.loadPluginModule(manifest, entry.sourcePath);
|
||||
const context: TojuPluginActivationContext = {
|
||||
api: this.apiFactory.createApi(manifest),
|
||||
manifest,
|
||||
pluginId: manifest.id,
|
||||
subscriptions: []
|
||||
};
|
||||
|
||||
await module.activate?.(context);
|
||||
this.activePlugins.set(manifest.id, { context, module });
|
||||
this.registry.setState(manifest.id, 'loaded');
|
||||
this.logger.info(manifest.id, 'Plugin activated');
|
||||
} catch (error) {
|
||||
this.failPlugin(manifest.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
private failPlugin(pluginId: string, error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : 'Plugin activation failed';
|
||||
|
||||
this.registry.setFailed(pluginId, message);
|
||||
this.logger.error(pluginId, message, error);
|
||||
this.uiRegistry.unregisterPlugin(pluginId);
|
||||
this.activePlugins.delete(pluginId);
|
||||
}
|
||||
|
||||
private async loadPluginModule(manifest: TojuPluginManifest, sourcePath?: string): Promise<TojuClientPluginModule> {
|
||||
if (manifest.entrypoint === DEVELOPMENT_PLUGIN_ENTRYPOINT) {
|
||||
return DEVELOPMENT_PLUGIN_MODULE;
|
||||
}
|
||||
|
||||
return await import(/* @vite-ignore */ this.resolveEntrypoint(manifest, sourcePath)) as TojuClientPluginModule;
|
||||
}
|
||||
|
||||
private registerDevelopmentPlugin(): void {
|
||||
if (environment.production) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.registry.registerManifest(DEVELOPMENT_PLUGIN_MANIFEST, DEVELOPMENT_PLUGIN_ENTRYPOINT);
|
||||
} catch (error) {
|
||||
this.logger.warn(DEVELOPMENT_PLUGIN_MANIFEST.id, 'Development plugin registration failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveEntrypoint(manifest: TojuPluginManifest, sourcePath?: string): string {
|
||||
if (!manifest.entrypoint) {
|
||||
throw new Error('Plugin entrypoint is missing');
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(manifest.entrypoint).toString();
|
||||
} catch {}
|
||||
|
||||
if (sourcePath?.startsWith('http://') || sourcePath?.startsWith('https://') || sourcePath?.startsWith('file://')) {
|
||||
return new URL(manifest.entrypoint, sourcePath).toString();
|
||||
}
|
||||
|
||||
if (manifest.entrypoint.startsWith('/')) {
|
||||
return manifest.entrypoint;
|
||||
}
|
||||
|
||||
throw new Error(`Plugin ${manifest.id} has no browser-importable entrypoint`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void {
|
||||
try {
|
||||
disposable.dispose();
|
||||
} catch (error) {
|
||||
logger.warn(pluginId, 'Plugin disposable failed', error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user