// // Copyright 2025 DXOS.org // import { Registry } from '@effect-rx/rx-react'; import { untracked } from '@preact/signals-core'; import { Array as A, Effect, Either, Match, pipe } from 'effect'; import { Event } from '@dxos/async'; import { live, type Live } from '@dxos/live-object'; import { log } from '@dxos/log'; import { type MaybePromise } from '@dxos/util'; import { type AnyCapability, PluginContext } from './capabilities'; import { type ActivationEvent, eventKey, getEvents, isAllOf } from './events'; import { type PluginModule, type Plugin } from './plugin'; // TODO(wittjosiah): Factor out? const isPromise = (value: unknown): value is Promise => { return value !== null && typeof value === 'object' && 'then' in value; }; export type PluginManagerOptions = { pluginLoader: (id: string) => MaybePromise; plugins?: Plugin[]; core?: string[]; enabled?: string[]; registry?: Registry.Registry; }; type PluginManagerState = { // Plugins plugins: Plugin[]; core: string[]; enabled: string[]; // Modules modules: PluginModule[]; active: string[]; // Events eventsFired: string[]; pendingReset: string[]; }; export class PluginManager { readonly activation = new Event<{ event: string; state: 'activating' | 'activated' | 'error'; error?: any }>(); readonly context: PluginContext; readonly registry: Registry.Registry; // TODO(wittjosiah): Replace with Rx. private readonly _state: Live; private readonly _pluginLoader: PluginManagerOptions['pluginLoader']; private readonly _capabilities = new Map(); constructor({ pluginLoader, plugins = [], core = plugins.map(({ meta }) => meta.id), enabled = [], registry, }: PluginManagerOptions) { this.registry = registry ?? Registry.make(); this.context = new PluginContext({ registry: this.registry, activate: (event) => this._activate(event), reset: (id) => this._reset(id), }); this._pluginLoader = pluginLoader; this._state = live({ plugins, core, enabled, modules: [], active: [], pendingReset: [], eventsFired: [], }); plugins.forEach((plugin) => this._addPlugin(plugin)); core.forEach((id) => this.enable(id)); enabled.forEach((id) => this.enable(id)); } /** * Plugins that are currently registered. * * @reactive */ get plugins(): Live { return this._state.plugins; } /** * Ids of plugins that are core and cannot be removed. * * @reactive */ get core(): Live { return this._state.core; } /** * Ids of plugins that are currently enabled. * * @reactive */ get enabled(): Live { return this._state.enabled; } /** * Modules of plugins which are currently enabled. * * @reactive */ get modules(): Live { return this._state.modules; } /** * Ids of modules which are currently active. * * @reactive */ get active(): Live { return this._state.active; } /** * Ids of events which have been fired. * * @reactive */ get eventsFired(): Live { return this._state.eventsFired; } /** * Ids of modules which are pending reset. * * @reactive */ get pendingReset(): Live { return this._state.pendingReset; } /** * Adds a plugin to the manager via the plugin loader. * @param id The id of the plugin. */ async add(id: string): Promise { return untracked(async () => { log('add plugin', { id }); const plugin = await this._pluginLoader(id); this._addPlugin(plugin); return this.enable(id); }); } /** * Enables a plugin. * @param id The id of the plugin. */ enable(id: string): Promise { return untracked(async () => { log('enable plugin', { id }); const plugin = this._getPlugin(id); if (!plugin) { return false; } if (!this._state.enabled.includes(id)) { this._state.enabled.push(id); } plugin.modules.forEach((module) => { this._addModule(module); this._setPendingResetByModule(module); }); log('pending reset', { events: [...this.pendingReset] }); await Effect.runPromise( Effect.all( this.pendingReset.map((event) => this._activate(event)), { concurrency: 'unbounded' }, ), ); return true; }); } /** * Removes a plugin from the manager. * @param id The id of the plugin. */ remove(id: string): boolean { return untracked(() => { log('remove plugin', { id }); const result = this.disable(id); if (!result) { return false; } this._removePlugin(id); return true; }); } /** * Disables a plugin. * @param id The id of the plugin. */ disable(id: string): Promise { return untracked(async () => { log('disable plugin', { id }); if (this._state.core.includes(id)) { return false; } const plugin = this._getPlugin(id); if (!plugin) { return false; } const enabledIndex = this._state.enabled.findIndex((enabled) => enabled === id); if (enabledIndex !== -1) { this._state.enabled.splice(enabledIndex, 1); await Effect.runPromise(this._deactivate(id)); plugin.modules.forEach((module) => { this._removeModule(module.id); }); } return true; }); } /** * Activates plugins based on the activation event. * @param event The activation event. * @returns Whether the activation was successful. */ activate(event: ActivationEvent | string): Promise { return untracked(() => Effect.runPromise(this._activate(event))); } /** * Deactivates all of the modules for a plugin. * @param id The id of the plugin. * @returns Whether the deactivation was successful. */ deactivate(id: string): Promise { return untracked(() => Effect.runPromise(this._deactivate(id))); } /** * Re-activates the modules that were activated by the event. * @param event The activation event. * @returns Whether the reset was successful. */ reset(event: ActivationEvent | string): Promise { return untracked(() => Effect.runPromise(this._reset(event))); } private _addPlugin(plugin: Plugin): void { untracked(() => { log('add plugin', { id: plugin.meta.id }); if (!this._state.plugins.includes(plugin)) { this._state.plugins.push(plugin); } }); } private _removePlugin(id: string): void { untracked(() => { log('remove plugin', { id }); const pluginIndex = this._state.plugins.findIndex((plugin) => plugin.meta.id === id); if (pluginIndex !== -1) { this._state.plugins.splice(pluginIndex, 1); } }); } private _addModule(module: PluginModule): void { untracked(() => { log('add module', { id: module.id }); if (!this._state.modules.includes(module)) { this._state.modules.push(module); } }); } private _removeModule(id: string): void { untracked(() => { log('remove module', { id }); const moduleIndex = this._state.modules.findIndex((module) => module.id === id); if (moduleIndex !== -1) { this._state.modules.splice(moduleIndex, 1); } }); } private _getPlugin(id: string): Plugin | undefined { return this._state.plugins.find((plugin) => plugin.meta.id === id); } private _getActiveModules(): PluginModule[] { return this._state.modules.filter((module) => this._state.active.includes(module.id)); } private _getInactiveModules(): PluginModule[] { return this._state.modules.filter((module) => !this._state.active.includes(module.id)); } private _getActiveModulesByEvent(key: string): PluginModule[] { return this._getActiveModules().filter((module) => getEvents(module.activatesOn).map(eventKey).includes(key)); } private _getInactiveModulesByEvent(key: string): PluginModule[] { return this._getInactiveModules().filter((module) => getEvents(module.activatesOn).map(eventKey).includes(key)); } private _setPendingResetByModule(module: PluginModule): void { return untracked(() => { const activationEvents = getEvents(module.activatesOn) .map(eventKey) .filter((key) => this._state.eventsFired.includes(key)); const pendingReset = Array.from(new Set(activationEvents)).filter( (event) => !this._state.pendingReset.includes(event), ); if (pendingReset.length > 0) { log('pending reset', { events: pendingReset }); this._state.pendingReset.push(...pendingReset); } }); } /** * @internal */ // TODO(wittjosiah): Improve error typing. _activate(event: ActivationEvent | string): Effect.Effect { return Effect.gen(this, function* () { const key = typeof event === 'string' ? event : eventKey(event); log('activating', { key }); const pendingIndex = this._state.pendingReset.findIndex((event) => event === key); if (pendingIndex !== -1) { this._state.pendingReset.splice(pendingIndex, 1); } const modules = this._getInactiveModulesByEvent(key).filter((module) => { const allOf = isAllOf(module.activatesOn); if (!allOf) { return true; } const events = module.activatesOn.events.filter((event) => eventKey(event) !== key); return events.every((event) => this._state.eventsFired.includes(eventKey(event))); }); if (modules.length === 0) { log('no modules to activate', { key }); if (!this._state.eventsFired.includes(key)) { this._state.eventsFired.push(key); } return false; } log('activating modules', { key, modules: modules.map((module) => module.id) }); this.activation.emit({ event: key, state: 'activating' }); // Concurrently triggers loading of lazy capabilities. const getCapabilities = yield* Effect.all( modules.map(({ activate }) => Effect.tryPromise({ try: async () => activate(this.context), catch: (error) => error as Error, }), ), { concurrency: 'unbounded' }, ); const result = yield* pipe( modules, A.zip(getCapabilities), A.map(([module, getCapabilities]) => this._activateModule(module, getCapabilities)), // TODO(wittjosiah): This currently can't be run in parallel. // Running this with concurrency causes races with `allOf` activation events. Effect.all, Effect.either, ); if (Either.isLeft(result)) { this.activation.emit({ event: key, state: 'error', error: result.left }); yield* Effect.fail(result.left); } if (!this._state.eventsFired.includes(key)) { this._state.eventsFired.push(key); } this.activation.emit({ event: key, state: 'activated' }); log('activated', { key }); return true; }); } private _activateModule( module: PluginModule, getCapabilities: AnyCapability | AnyCapability[] | (() => Promise), ): Effect.Effect { return Effect.gen(this, function* () { yield* Effect.all(module.activatesBefore?.map((event) => this._activate(event)) ?? [], { concurrency: 'unbounded', }); log('activating module...', { module: module.id }); // TODO(wittjosiah): This is not handling errors thrown if this is synchronous. const maybeCapabilities = typeof getCapabilities === 'function' ? getCapabilities() : getCapabilities; const resolvedCapabilities = yield* Match.value(maybeCapabilities).pipe( // TODO(wittjosiah): Activate with an effect? // Match.when(Effect.isEffect, (effect) => effect), Match.when(isPromise, (promise) => Effect.tryPromise({ try: () => promise, catch: (error) => error as Error, }), ), Match.orElse((program) => Effect.succeed(program)), ); const capabilities = Match.value(resolvedCapabilities).pipe( Match.when(Array.isArray, (array) => array), Match.orElse((value) => [value]), ); capabilities.forEach((capability) => { this.context.contributeCapability({ module: module.id, ...capability }); }); this._state.active.push(module.id); this._capabilities.set(module.id, capabilities); log('activated module', { module: module.id }); yield* Effect.all(module.activatesAfter?.map((event) => this._activate(event)) ?? [], { concurrency: 'unbounded', }); }); } private _deactivate(id: string): Effect.Effect { return Effect.gen(this, function* () { const plugin = this._getPlugin(id); if (!plugin) { return false; } const modules = plugin.modules; const results = yield* Effect.all( modules.map((module) => this._deactivateModule(module)), { concurrency: 'unbounded' }, ); return results.every((result) => result); }); } private _deactivateModule(module: PluginModule): Effect.Effect { return Effect.gen(this, function* () { const id = module.id; log('deactivating', { id }); const capabilities = this._capabilities.get(id); if (capabilities) { for (const capability of capabilities) { this.context.removeCapability(capability.interface, capability.implementation); const program = capability.deactivate?.(); yield* Match.value(program).pipe( Match.when(Effect.isEffect, (effect) => effect), Match.when(isPromise, (promise) => Effect.tryPromise({ try: () => promise, catch: (error) => error as Error, }), ), Match.orElse((program) => Effect.succeed(program)), ); } this._capabilities.delete(id); } const activeIndex = this._state.active.findIndex((event) => event === id); if (activeIndex !== -1) { this._state.active.splice(activeIndex, 1); } log('deactivated', { id }); return true; }); } private _reset(event: ActivationEvent | string): Effect.Effect { return Effect.gen(this, function* () { const key = typeof event === 'string' ? event : eventKey(event); log('reset', { key }); const modules = this._getActiveModulesByEvent(key); const results = yield* Effect.all( modules.map((module) => this._deactivateModule(module)), { concurrency: 'unbounded' }, ); if (results.every((result) => result)) { return yield* this._activate(key); } else { return false; } }); } }