249 lines
9.0 KiB
TypeScript
249 lines
9.0 KiB
TypeScript
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<unknown>;
|
||
/** 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<void>;
|
||
/** 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<string, unknown>;
|
||
const meta = exports.meta;
|
||
if (typeof meta !== 'object' || meta === null) return 'exports no meta';
|
||
const { id, name, version, api } = meta as Record<string, unknown>;
|
||
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<string, Loaded>();
|
||
private readonly entries = new Map<string, PluginEntry>();
|
||
private readonly listeners = new Set<() => void>();
|
||
private queue: Promise<void> = 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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<PluginRecord>): 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<void>): Promise<void> {
|
||
this.queue = this.queue.then(work, work);
|
||
return this.queue;
|
||
}
|
||
}
|