Add typed hook system for plugins

Replace invokeHook and the per plugin on* methods with AsyncHook
collections on the controller, host, instance, clusterioctl and web
control objects. Plugin entrypoints export a default function that
receives a load context and attaches handlers to the hooks it needs.
Class exports still load through fromContext with a deprecation
warning. The create templates and docs use the entrypoint function.

This is clusterio/clusterio#921 recreated on top of master after the
ESM migration and the PluginDeclaration permissions change.

Closes #730

Co-Authored-By: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
committed 2026-09-11 22:49:09 +00:00
1 parent 8c03ac15ab
commit 7122cbccf8
64 files changed
+1878 -1184

No files matched your search

+89 -35
View File
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import * as mock from "../mock.js";
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
@@ -72,51 +73,104 @@ describe("lib/plugin_loader", function() {
);
});
});
describe("loadPluginClass()", function() {
let baseDir = path.join("temp", "test", "plugin");
let missingClass = path.join(baseDir, "missing_class_plugin");
let wrongParentClass = path.join(baseDir, "wrong_parent_class_plugin");
describe("loadPlugin()", function() {
const baseDir = path.join("temp", "test", "plugin");
const functionPlugin = path.resolve(baseDir, "function_plugin");
const classPlugin = path.resolve(baseDir, "class_plugin");
const throwingClassPlugin = path.resolve(baseDir, "throwing_class_plugin");
const missingClassPlugin = path.resolve(baseDir, "missing_class_plugin");
const wrongParentClassPlugin = path.resolve(baseDir, "wrong_parent_class_plugin");
let controller;
let logger;
let context;
before(async function() {
async function writeEntrypoint(pluginPath, content) {
await fs.mkdir(pluginPath, { recursive: true });
await fs.writeFile(path.join(pluginPath, "controller.js"), content);
}
await writeEntrypoint(missingClass, "");
await writeEntrypoint(
wrongParentClass, "export class ControllerPlugin {};\n"
await writeEntrypoint(functionPlugin, `
export default async function(context) {
context.controller.loadedWith = context;
}
`);
await writeEntrypoint(classPlugin, `
import { BaseControllerPlugin } from "@clusterio/controller";
export class ControllerPlugin extends BaseControllerPlugin {
async init() { this.controller.loadedWith = this; }
async onSaveData() { }
}
`);
await writeEntrypoint(throwingClassPlugin, `
import { BaseControllerPlugin } from "@clusterio/controller";
export class ControllerPlugin extends BaseControllerPlugin {
async init() { throw new Error("init failed"); }
async onSaveData() { }
}
`);
await writeEntrypoint(missingClassPlugin, "");
await writeEntrypoint(wrongParentClassPlugin, "export class ControllerPlugin {};\n");
});
beforeEach(function() {
controller = new mock.MockController();
logger = new mock.MockLogger();
logger.warnings = [];
logger.warn = msg => logger.warnings.push(msg);
context = { controller, metrics: {}, logger };
});
function info(requirePath, entrypoint = "controller.js") {
return { name: "test", requirePath, controllerEntrypoint: entrypoint };
}
it("should do nothing when the entrypoint is not set", async function() {
await lib.loadPlugin(
{ name: "test", requirePath: missingClassPlugin }, "controller", context,
"ControllerPlugin", BaseControllerPlugin,
);
assert.equal(controller.loadedWith, undefined);
});
it("should call the default export with the load context", async function() {
const pluginInfo = info(functionPlugin);
await lib.loadPlugin(pluginInfo, "controller", context, "ControllerPlugin", BaseControllerPlugin);
assert.equal(controller.loadedWith.controller, controller);
assert.equal(controller.loadedWith.plugin, pluginInfo);
assert.equal(controller.loadedWith.logger, logger);
assert.deepEqual(logger.warnings, []);
});
it("should load a deprecated class export", async function() {
const pluginInfo = info(classPlugin);
await lib.loadPlugin(pluginInfo, "controller", context, "ControllerPlugin", BaseControllerPlugin);
assert(controller.loadedWith instanceof BaseControllerPlugin);
assert.equal(controller.loadedWith.info, pluginInfo);
assert.deepEqual([...controller.hooks.save.attached], ["test"]);
assert.deepEqual(logger.warnings, ["Plugin test is using deprecated class export"]);
});
it("should detach hooks if init throws", async function() {
await assert.rejects(
lib.loadPlugin(
info(throwingClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin
),
new Error("init failed")
);
assert.equal(controller.hooks.size, 0);
});
it("should throw if neither a function nor a class is exported", async function() {
await assert.rejects(
lib.loadPlugin(
info(missingClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin
),
new Error("Plugin test must export either a default function or ControllerPlugin class")
);
});
it("should throw if class is missing from entrypoint", async function() {
const requirePath = path.resolve(missingClass);
it("should throw if the class is not a subclass of the base class", async function() {
await assert.rejects(
lib.loadPluginClass(
"test",
path.posix.join(requirePath, "controller.js"),
"ControllerPlugin",
BaseControllerPlugin,
lib.loadPlugin(
info(wrongParentClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin
),
{
message:
`PluginError: Expected ${path.posix.join(requirePath, "controller.js")} ` +
"to export a class named ControllerPlugin",
}
);
});
it("should throw if class is not a subclass of BaseControllerPlugin", async function() {
const requirePath = path.resolve(wrongParentClass);
await assert.rejects(
lib.loadPluginClass(
"test",
path.posix.join(requirePath, "controller.js"),
"ControllerPlugin",
BaseControllerPlugin,
),
{
message:
"PluginError: Expected ControllerPlugin exported from " +
`${path.posix.join(requirePath, "controller.js")} to be a subclass of BaseControllerPlugin`,
}
new Error("Expected ControllerPlugin exported from test to extend BaseControllerPlugin")
);
});
});