import { PLUGIN_API_VERSION } from './pluginApi'; export interface PluginMeta { id: string; name: string; version: string; api: number; } /** * What became of one file in `data/plugins/`; the Plugins tab and the check scripts read it. * `inactive` is switched off, `failed` is switched on but not running. A switched-off plugin is * never loaded, so its `meta` is only known once it has been active in this session. */ export interface PluginRecord { file: string; status: 'active' | 'inactive' | 'failed'; meta: PluginMeta | null; /** Why it is not running although switched on, or what went wrong switching it off. */ error?: string; } /** A file as the server lists it. */ export interface PluginEntry { file: string; updatedAt: number; active: boolean; } /** * What one active plugin left in the editor – a tab, a listener – as the functions that take it * away again. Switching the plugin off runs them, newest first; so does an `activate` that fails * halfway, so a half-built plugin leaves nothing behind. */ export class PluginScope { private readonly cleanups: (() => void)[] = []; add(cleanup: () => void): void { this.cleanups.push(cleanup); } /** Runs every cleanup even when one throws; returns the messages of those that did. */ dispose(): string[] { const errors: string[] = []; for (const cleanup of this.cleanups.splice(0).reverse()) { try { cleanup(); } catch (error) { errors.push(messageOf(error)); } } return errors; } } export interface PluginLoaderDeps { /** The module a file holds; `updatedAt` tells a replaced file from the one loaded before. */ load(file: string, updatedAt: number): Promise; /** A fresh API for a plugin that passed the checks; what it registers goes into `scope`. */ api(meta: PluginMeta, scope: PluginScope): unknown; /** Stores the switch on the server; throws when it was not stored. */ store(file: string, active: boolean): Promise; /** One line for the user about a plugin that could not be switched. */ report(message: string): void; } interface Loaded { module: { activate: (api: unknown) => unknown; deactivate?: unknown }; scope: PluginScope; } function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } /** The module's `meta` as the loader needs it, or why it cannot be used. */ export function readMeta(module: unknown): PluginMeta | string { const exports = (typeof module === 'object' && module !== null ? module : {}) as Record; const meta = exports.meta; if (typeof meta !== 'object' || meta === null) return 'exports no meta'; const { id, name, version, api } = meta as Record; if (typeof id !== 'string' || id === '') return 'meta.id must be a non-empty string'; if (typeof exports.activate !== 'function') return 'exports no activate function'; if (exports.deactivate !== undefined && typeof exports.deactivate !== 'function') return 'deactivate must be a function'; if (api !== PLUGIN_API_VERSION) return `needs plugin API ${String(api)}, this editor offers ${PLUGIN_API_VERSION}`; return { id, name: typeof name === 'string' && name !== '' ? name : id, version: typeof version === 'string' ? version : '', api, }; } /** * The plugins of this editor and their switches (#113, #114). At start every switched-on plugin is * activated, one after the other in file-name order, so that what an earlier one registers (a tab * id, say) is settled before the next runs; one switched on later comes after all of them. * * Nothing a plugin does wrong stops the others or the editor: a file that does not load, a module * that is not a plugin, an API version this editor does not offer, a second plugin of an id that * is active already and an `activate` that throws each leave a failed record and a report. * * Switches run one at a time in the order they were made, so a quick off-on cannot overtake itself. */ export class PluginManager { private list: PluginRecord[] = []; private readonly loaded = new Map(); private readonly entries = new Map(); private readonly listeners = new Set<() => void>(); private queue: Promise = Promise.resolve(); /** False until `start` has been through the list – the tab says "Loading…" until then. */ started = false; constructor(private readonly deps: PluginLoaderDeps) {} get records(): readonly PluginRecord[] { return this.list; } /** Called after every change of a record; returns the unsubscribe. */ onChange(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } start(entries: readonly PluginEntry[]): Promise { return this.enqueue(async () => { const sorted = [...entries].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0)); for (const entry of sorted) this.entries.set(entry.file, entry); this.list = sorted.map((entry) => ({ file: entry.file, status: 'inactive', meta: null })); for (const entry of sorted) if (entry.active) await this.activate(entry.file); this.started = true; this.changed(); }); } /** * Deactivates everything that is running, without touching the switches on the server: the * editor is going away (signing out, #177) and every plugin has to let go of it first. A later * editor starts them again from the same files. */ stopAll(): Promise { return this.enqueue(async () => { for (const file of [...this.loaded.keys()]) this.deactivate(file); this.started = false; this.changed(); await Promise.resolve(); }); } /** Switches a plugin on or off: stored on the server first, and only then applied. */ setActive(file: string, active: boolean): Promise { return this.enqueue(async () => { const record = this.record(file); if (!record || (record.status !== 'inactive') === active) return; try { await this.deps.store(file, active); } catch (error) { this.deps.report(`Plugin ${file} not switched ${active ? 'on' : 'off'}: ${messageOf(error)}`); this.changed(); return; } if (active) await this.activate(file); else this.deactivate(file); this.changed(); }); } private async activate(file: string): Promise { const fail = (meta: PluginMeta | null, error: string) => { this.update(file, { status: 'failed', meta, error }); this.deps.report(`Plugin ${file} not activated: ${error}`); }; let module: unknown; try { module = await this.deps.load(file, this.entries.get(file)?.updatedAt ?? 0); } catch (error) { fail(null, `does not load (${messageOf(error)})`); return; } const meta = readMeta(module); if (typeof meta === 'string') { fail(null, meta); return; } if (this.list.some((r) => r.status === 'active' && r.meta?.id === meta.id)) { fail(meta, `a plugin with the id "${meta.id}" is active already`); return; } const scope = new PluginScope(); const loaded = { module: module as Loaded['module'], scope }; try { await loaded.module.activate(this.deps.api(meta, scope)); } catch (error) { scope.dispose(); fail(meta, `activate failed (${messageOf(error)})`); return; } this.loaded.set(file, loaded); this.update(file, { status: 'active', meta, error: undefined }); } /** * The plugin's own `deactivate` first, while its tabs are still there to be tidied, then * everything it registered. It is off afterwards whatever threw on the way – what threw is * reported and kept on the record. */ private deactivate(file: string): void { const loaded = this.loaded.get(file); this.loaded.delete(file); const errors: string[] = []; if (loaded) { try { if (typeof loaded.module.deactivate === 'function') (loaded.module.deactivate as () => unknown)(); } catch (error) { errors.push(`deactivate failed (${messageOf(error)})`); } errors.push(...loaded.scope.dispose().map((message) => `cleanup failed (${message})`)); } const error = errors.length > 0 ? errors.join('; ') : undefined; this.update(file, { status: 'inactive', error }); if (error) this.deps.report(`Plugin ${file} switched off, but: ${error}`); } private record(file: string): PluginRecord | undefined { return this.list.find((r) => r.file === file); } private update(file: string, change: Partial): void { this.list = this.list.map((r) => { if (r.file !== file) return r; const next: PluginRecord = { ...r, ...change }; if (next.error === undefined) delete next.error; return next; }); } private changed(): void { for (const listener of [...this.listeners]) listener(); } private enqueue(work: () => Promise): Promise { this.queue = this.queue.then(work, work); return this.queue; } }