Plugins are switched on and off in a Plugins tab, the server keeps the state in data/plugins/plugins.json, and switched-off plugins are never delivered (#114)
game-maker / gate (push) Successful in 1m38s
game-maker / suite (push) Successful in 5m43s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 20:15:51 +02:00
1 parent 57a97ba94f
commit c17b2b5426
17 files changed
+994 -129

No files matched your search

+172 -33
View File
@@ -7,24 +7,68 @@ export interface PluginMeta {
api: number;
}
/** What became of one file in `data/plugins/`; the check scripts and a later settings list read it. */
/**
* 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' | 'failed';
status: 'active' | 'inactive' | 'failed';
meta: PluginMeta | null;
/** Why it is not active. */
/** 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. */
load(file: string): Promise<unknown>;
/** A fresh API for a plugin that passed the checks. */
api(meta: PluginMeta): unknown;
/** One line for the user about a plugin that could not be activated. */
/** 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);
}
@@ -37,6 +81,8 @@ export function readMeta(module: unknown): PluginMeta | string {
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 {
@@ -48,48 +94,141 @@ export function readMeta(module: unknown): PluginMeta | string {
}
/**
* Loads and activates every plugin, 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. 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 the same id and an `activate`
* that throws each leave a failed record and a report.
* 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 async function activatePlugins(
files: readonly string[],
deps: PluginLoaderDeps,
): Promise<PluginRecord[]> {
const records: PluginRecord[] = [];
const ids = new Set<string>();
for (const file of [...files].sort()) {
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();
});
}
/** 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) => {
records.push({ file, status: 'failed', meta, error });
deps.report(`Plugin ${file} not activated: ${error}`);
this.update(file, { status: 'failed', meta, error });
this.deps.report(`Plugin ${file} not activated: ${error}`);
};
let module: unknown;
try {
module = await deps.load(file);
module = await this.deps.load(file, this.entries.get(file)?.updatedAt ?? 0);
} catch (error) {
fail(null, `does not load (${messageOf(error)})`);
continue;
return;
}
const meta = readMeta(module);
if (typeof meta === 'string') {
fail(null, meta);
continue;
return;
}
if (ids.has(meta.id)) {
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`);
continue;
return;
}
const scope = new PluginScope();
const loaded = { module: module as Loaded['module'], scope };
try {
const activate = (module as { activate: (api: unknown) => unknown }).activate;
await activate(deps.api(meta));
await loaded.module.activate(this.deps.api(meta, scope));
} catch (error) {
scope.dispose();
fail(meta, `activate failed (${messageOf(error)})`);
continue;
return;
}
ids.add(meta.id);
records.push({ file, status: 'active', meta });
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;
}
return records;
}