Files
feuerwerk/src/editor/plugins/loader.ts
T

249 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}