From 7122cbccf82e028882c69bd7ff5313259dd80f5c Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:49:09 +0000 Subject: [PATCH 1/2] 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 --- docs/writing-plugins.md | 153 +++++----- packages/controller/index.ts | 2 +- .../controller/src/BaseControllerPlugin.ts | 168 +++++++--- packages/controller/src/ControlConnection.ts | 6 +- packages/controller/src/Controller.ts | 42 ++- packages/controller/src/HostConnection.ts | 29 +- packages/controller/src/InstanceManager.ts | 16 +- packages/controller/src/WsServer.ts | 4 +- packages/controller/src/routes.ts | 6 +- .../create/templates/plugin-js/controller.js | 88 +++--- packages/create/templates/plugin-js/ctl.js | 7 +- packages/create/templates/plugin-js/host.js | 38 +-- packages/create/templates/plugin-js/index.js | 2 +- .../create/templates/plugin-js/instance.js | 91 +++--- .../templates/plugin-js/instance_empty.js | 9 +- .../create/templates/plugin-js/web/plugin.jsx | 72 +++-- .../create/templates/plugin-ts/controller.ts | 91 +++--- packages/create/templates/plugin-ts/ctl.ts | 8 +- packages/create/templates/plugin-ts/host.ts | 40 ++- .../create/templates/plugin-ts/instance.ts | 94 +++--- .../templates/plugin-ts/instance_empty.ts | 10 +- .../create/templates/plugin-ts/web/plugin.tsx | 73 +++-- packages/ctl/ctl.ts | 46 ++- packages/ctl/index.ts | 2 +- packages/ctl/src/BaseCtlPlugin.ts | 84 +++-- packages/ctl/src/commands.ts | 8 +- packages/host/index.ts | 4 +- packages/host/src/BaseHostPlugin.ts | 118 ++++--- packages/host/src/BaseInstancePlugin.ts | 186 ++++++----- packages/host/src/Host.ts | 46 ++- packages/host/src/Instance.ts | 73 +++-- packages/host/src/patch.ts | 15 +- packages/lib/browser.ts | 3 +- packages/lib/index.ts | 2 + packages/lib/src/AsyncHook.ts | 184 +++++++++++ packages/lib/src/data/messages_plugin.ts | 2 +- packages/lib/src/loadPlugin.ts | 54 ++++ packages/lib/src/plugin.ts | 61 ++-- packages/lib/src/plugin_loader.ts | 54 ++-- packages/lib/src/rce_ops.ts | 2 +- packages/lib/tsconfig.browser.json | 2 + packages/web_ui/global.d.ts | 5 + packages/web_ui/src/BaseWebPlugin.ts | 288 +++++++++++------- packages/web_ui/src/bootstrap.tsx | 137 +++++++-- .../web_ui/src/components/BaseConfigTree.tsx | 8 +- packages/web_ui/src/components/LoginForm.tsx | 21 +- .../web_ui/src/components/PluginExtra.tsx | 34 +-- .../web_ui/src/components/PluginViewPage.tsx | 20 +- .../web_ui/src/components/PluginsPage.tsx | 12 +- packages/web_ui/src/components/SiteLayout.tsx | 8 +- packages/web_ui/src/index.ts | 2 +- packages/web_ui/src/util/websocket.ts | 18 +- test/controller/BaseControllerPlugin.js | 62 +++- test/controller/routes.js | 3 +- test/file/test_plugin/host.js | 27 +- test/file/test_plugin/instance.js | 11 +- test/host/BaseHostPlugin.js | 50 ++- test/host/BaseInstancePlugin.js | 50 ++- test/integration/index.js | 4 +- test/lib/AsyncHook.js | 132 ++++++++ test/lib/plugin.js | 36 --- test/lib/plugin_loader.js | 124 +++++--- test/messages/plugin.test.js | 4 +- test/mock.js | 11 +- 64 files changed, 1878 insertions(+), 1184 deletions(-) create mode 100644 packages/lib/src/AsyncHook.ts create mode 100644 packages/lib/src/loadPlugin.ts create mode 100644 test/lib/AsyncHook.js delete mode 100644 test/lib/plugin.js diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 84c4e983..3228db2e 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -1,7 +1,7 @@ # Writing Plugins -Plugins for Clusterio are classes written in JavaScript that run under Node.js. -The plugin classes have pre-defined hooks that are called during various stages and operations of Clusterio. +Plugins for Clusterio are JavaScript modules that run under Node.js. +Each entrypoint of a plugin exports a function that attaches handlers to hooks called during various stages and operations of Clusterio. ## Contents @@ -76,7 +76,7 @@ The following properties are recognized: Currently not used. **instanceEntrypoint**: - Path to a Node.js module relative to the plugin directory which contains the InstancePlugin class definition for this plugin. + Path to a Node.js module relative to the plugin directory which exports the instance entrypoint function for this plugin. This is an optional paramater. A plugin may have code only for instances but it must still be loaded on the controller in order for it to be possible to load it on an instance. @@ -85,7 +85,7 @@ The following properties are recognized: See [Plugin Configuration](#plugin-configuration) **controllerEntrypoint**: - Path to a Node.js module relative to the plugin directory which contains the ControllerPlugin class definiton for this plugin. + Path to a Node.js module relative to the plugin directory which exports the controller entrypoint function for this plugin. This is an optional parameter. A plugin can be made that only runs on the controller. @@ -94,7 +94,7 @@ The following properties are recognized: See [Plugin Configuration](#plugin-configuration) **ctlEntrypoint**: - Path to a Node.js module relative to the plugin directory which contains the CtlPlugin class definition for this plugin. + Path to a Node.js module relative to the plugin directory which exports the clusterioctl entrypoint function for this plugin. This is an optional paramater. A plugin can be made that only runs on the clusterioctl side. @@ -110,57 +110,65 @@ The optional module folder contains a Clusterio module that will be patched into See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document. The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin. -While there is no standard for how to organize a plugin it's recommended to put the ControllerPlugin class definition into controller.js and the InstancePlugin class definition into instance.js. +While there is no standard for how to organize a plugin it's recommended to put the controller entrypoint into controller.js and the instance entrypoint into instance.js. You can put them into whatever file you want (even the same one for both). -For both instanceEntrypoint and controllerEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any. +The entrypoint paths must include the .js extension and use forward slashes for directory separators if any. -## Defining the plugin class +## Defining the plugin entrypoint -The plugin class should derive from its respective base class defined in `lib/plugin`. -For example, to define a ControllerPlugin class the following code can be used: +Each entrypoint module exports a default async function which is called once when the plugin is loaded. +It is passed a context object with what the plugin needs to interact with Clusterio, and attaches handlers to the hooks it is interested in. +For example, a controller entrypoint can look like this: ```js -import { BaseControllerPlugin } from "@clusterio/controller"; +export default async function(context) { + const { controller, logger, plugin } = context; -export class ControllerPlugin extends BaseControllerPlugin { - async init() { - this.foo = 42; - await this.startFrobnication(); - } + controller.hooks.save.attach(plugin.name, async () => { + await saveFrobnicationData(); + }); - // ... + controller.hooks.shutdown.attach(plugin.name, async () => { + logger.info("Stopping frobnication"); + }); } ``` -For the instance plugin it's exactly the same except "Controller" is replaced with "Instance", for host plugins "Host" is used and for the clusterioctl plugin "Ctl" is used. -The available hooks that you can override are documented in the base classes. +The context has the following properties: -It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class. -E.g.: +**plugin**: + The plugin's own info, as exported from the main entrypoint. -```js -constructor(...args) { - super(...args); +**logger**: + Logger for this plugin, see [Logging Messages](#logging-messages). - // Code here -} -``` +**controller**, **host**, **instance**, **hooks** or **control**: + The object the entrypoint is loaded for. + Controller entrypoints get `controller`, host entrypoints get `host`, instance entrypoints get both `instance` and `host`, clusterioctl entrypoints get `hooks` and web entrypoints get `control`. -The arguments passed may change, and attempting to modify them will result in unpredicatable behaviour. -The async init method is always called immediatly after the constructor, so there's little reason to do this. +The available hooks are documented on the `ControllerHooks`, `HostHooks`, `InstanceHooks`, `CtlHooks` and `WebHooks` classes. +Handlers are attached under a name, which should be the name of the plugin, and a hook only accepts one handler per name. +An error thrown by a handler is logged and does not affect the other handlers, the same goes for handlers that take more than 15 seconds to complete. + +For TypeScript the context types are exported as `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from their respective packages. + +### Class based plugins + +Plugins written as classes deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin` still load, but this way of defining plugins is deprecated and a warning is logged when such a plugin is loaded. +The base class constructor attaches the `on*` methods overridden by the plugin to the corresponding hooks, and the async `init` method is called immediately after. ## Logging Messages -The base plugin classes provide a winston logger for logging messages to the shared cluster log. +The load context provides a winston logger for logging messages to the shared cluster log. For instances a copy of the log is also stored on the host the instance is on. To use it, pass a string to one of the log levels functions, for example: ```js -async init() { - this.logger.info("Initializing frobbing"); +export default async function(context) { + context.logger.info("Initializing frobbing"); } ``` @@ -190,7 +198,7 @@ For guarding unexpected errors the best option is to log a short description alo try { // Operation that should not throw but may end up throwing } catch (err) { - this.logger.error(`Operation failed:\n${err.stack}`); + logger.error(`Operation failed:\n${err.stack}`); ) ``` @@ -226,12 +234,12 @@ export default { } satisfies lib.PluginDeclaration; ``` -Code inside the `ControllerPlugin` class will then be able to access the level config field through the `Config` object at `this.controller.config`, for example in the ControllerPluginClass: +The controller entrypoint will then be able to access the level config field through the `Config` object at `controller.config`: ```ts -async init() { - let level = this.controller.config.get("foo_frobber.level"); - this.logger.info(`I got a frobnication level of ${level}`); +export default async function(context: ControllerPluginContext) { + let level = context.controller.config.get("foo_frobber.level"); + context.logger.info(`I got a frobnication level of ${level}`); } ``` @@ -241,13 +249,13 @@ See [Configuration System](config-system.md) for more details on how this system ### Handling Invalid Configuration -If the plugin requires a certain feature to be enabled to function it should throw an error during init if this is not the case. +If the plugin requires a certain feature to be enabled to function it should throw an error from the entrypoint if this is not the case. The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio. For example: ```js -async init() { - if (!this.instance.config.get("factorio.enable_save_patching")) { +export default async function(context) { + if (!context.instance.config.get("factorio.enable_save_patching")) { throw new Error("foo_frobber plugin requires save patching."); } } @@ -296,17 +304,17 @@ The older `lib.definePermission()` function still works, but a plugin should not ## Communicating with Factorio For pushing data into Factorio there's RCON, which lets you send arbitrary Lua commands to invoke whatever code you want in the game. -This is done by calling the `sendRcon` method on the plugin object. +This is done by calling the `sendRcon` method on the instance. For example: ```js -async onStart() { - let response = await this.sendRcon( +instance.hooks.start.attach(plugin.name, async () => { + let response = await instance.sendRcon( "/sc rcon.print('data')" ); // Do stuff with response. -} +}); ``` Because data into Factorio is streamed at a rate of 3-6 kB/s by default, it is recommended to avoid sending large commands as much as possible, and to strip down the data on the ones you send to only what's strictly necessary. @@ -319,15 +327,16 @@ From a plugin you listen for an event named `ipc-channel_name` in order to get d For example in the plugin code: ```js -async init() { - this.instance.server.on("ipc-my_plugin_foo", content => - this.handleFoo(content).catch(err => this.logger.error( +export default async function(context) { + const { instance, logger } = context; + instance.server.on("ipc-my_plugin_foo", content => + handleFoo(content).catch(err => logger.error( `Error handling foo:\n${err.stack}` )) ); } -async handleFoo(content) { +async function handleFoo(content) { // Do stuff with content } ``` @@ -344,11 +353,11 @@ clusterio_api.send_json("my_plugin_foo", { data = 123 }) For convience a helper function is exposed on server specifically for handling IPC events. ```js -async init() { - this.instance.server.handle("my_plugin_foo", this.handleFoo.bind(this)); +export default async function(context) { + context.instance.server.handle("my_plugin_foo", handleFoo); } -async handleFoo(content) { +async function handleFoo(content) { // Do stuff with content } ``` @@ -485,9 +494,9 @@ Link messages are sent by calling the `.sendTo()` method on a connection object For example: ```js -// In an InstancePlugin class -async frobnicate() { - const response = await this.instance.sendTo("controller", new messages.Frobnicate({ +// In an instance entrypoint +async function frobnicate(instance) { + const response = await instance.sendTo("controller", new messages.Frobnicate({ verbosity: 2, special: false, })); @@ -504,14 +513,14 @@ Here is what that looks like in practice: ```js // ❌ BAD: The extracted method loses its `this` binding and crashes when called. -const send = this.instance.sendTo; +const send = instance.sendTo; send("controller", new messages.Frobnicate({ foo: "bar" })); // ✅ GOOD: Call it directly on the connection object. (Cast args/results here if needed). -this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" })); +instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" })); // ✅ GOOD: Explicitly bind the receiver if you need to pass a method as a value (like a handler). -this.instance.handle(messages.Frobnicate, this.handleFrobnicate.bind(this)); +instance.handle(messages.Frobnicate, plugin.handleFrobnicate.bind(plugin)); ``` For classes with `static type = "request"` the send method is async and returns a promise that resolves to the response data received from the target it was sent to, or rejects with an error if the request failed. @@ -533,23 +542,23 @@ sendTo({ controlId: 123 }, message); There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems. The most important is the prepare disconnect for the link between controller and host. -This is signaled to `ControllerPlugin` classes via the `onPrepareHostDisconnect` hook and to `InstancePlugin` classes via the `onPrepareControllerDisconnect` hook. +This is signaled to controller plugins via the `prepareHostDisconnect` hook and to instance plugins via the `prepareControllerDisconnect` hook. After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped. Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question. This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `HostConnection` class and `Host` class on the controller and host respectively. -For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code: +For example the sending of an event from an instance plugin can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code: ```js -if (this.host.connected) { - this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" })); +if (host.connected) { + instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" })); } ``` -If the event or request needs to be sent to the controller it can be put into a queue stored on the plugin instance and sent out when the connection is established again. -The re-establishement of the connection is notified to plugins via the `connect` event to the `onControllerConnectionEvent` and `onHostConnectionEvent` hooks. +If the event or request needs to be sent to the controller it can be put into a queue and sent out when the connection is established again. +The re-establishement of the connection is notified to plugins via the `connect` event to the `controllerConnectionEvent` and `hostConnectionEvent` hooks. -The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onControllerConnectionEvent` for `InstancePlugin` classes and through `onHostConnectionEvent` for `ControllerPlugin` classes. +The second connection event which is of lesser importance to respond to is the `drop` connection event served through `controllerConnectionEvent` for instance plugins and through `hostConnectionEvent` for controller plugins. This is raised when the connection between the controller and host in question is lost, most likely due to networking issues. When in the dropped state the host will keep trying to reconnect to the controller in order to re-establish it, and if successful no events or requests will be lost. However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out. @@ -589,14 +598,14 @@ const barMetric = new Gauge( ); // Somewhere in the instance plugin code -barMetric.labels(String(this.instance.id)).set(someValue); +barMetric.labels(String(instance.id)).set(someValue); ``` Metrics are automatically registered to the default registry, and this default registry is automatically polled by the controller on hosts. This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a host. Since the metrics remember their values and would continue to be exported after an instance is shutdown, there's code at instance shutdown that removes all the values where the `instance_id` label matches the id of the instance shut down. -For statistics you need to update on collection there's an `onMetrics` hook on both controller and instance plugins that is run before the metrics in the default registry are collected. +For statistics you need to update on collection there's a `metrics` hook on both controller and instance plugins that is run before the metrics in the default registry are collected. ## Adding Custom Commands to clusterioctl @@ -633,18 +642,14 @@ fooFrobberCommands.add(new Command({ For a command the `definition` is the arguments to pass to [yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) (see also [yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) and [yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) for setting up positional and optional arguments to commands). The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl. -It's possible to optain a reference to the plugin class with `control.plugins.get(info.name)`. - Note that messages sent from clusterioctl needs to have `"control-controller"` as a part of the links array for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller. -To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Ctl plugin: +To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in the `addCommands` hook of the ctl entrypoint: ```js -import { BaseCtlPlugin } from "@clusterio/ctl"; - -export class CtlPlugin extends BaseCtlPlugin { - async addCommands(rootCommand) { +export default async function(context) { + context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => { rootCommand.add(fooFrobberCommands); - } + }); } ``` diff --git a/packages/controller/index.ts b/packages/controller/index.ts index 82b2cbad..6a8937b6 100644 --- a/packages/controller/index.ts +++ b/packages/controller/index.ts @@ -6,7 +6,7 @@ export { default as HostConnection } from "./src/HostConnection.js"; export { default as InstanceRecord } from "./src/InstanceRecord.js"; export { default as InstanceManager } from "./src/InstanceManager.js"; export { default as HostRecord } from "./src/HostRecord.js"; -export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin.js"; +export { BaseControllerPlugin, ControllerHooks, type ControllerPluginContext } from "./src/BaseControllerPlugin.js"; export { default as UserManager } from "./src/UserManager.js"; export { default as UserRecord } from "./src/UserRecord.js"; export { default as User } from "./src/User.js"; diff --git a/packages/controller/src/BaseControllerPlugin.ts b/packages/controller/src/BaseControllerPlugin.ts index 6f4c579c..e515e82d 100644 --- a/packages/controller/src/BaseControllerPlugin.ts +++ b/packages/controller/src/BaseControllerPlugin.ts @@ -1,45 +1,35 @@ -import type { - CollectorResult, Event, InstanceStatus, Logger, - ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo, -} from "@clusterio/lib"; import type Controller from "./Controller.js"; import type InstanceRecord from "./InstanceRecord.js"; import type ControlConnection from "./ControlConnection.js"; import type HostConnection from "./HostConnection.js"; +import * as lib from "@clusterio/lib"; + +export type ControllerPluginContext = lib.PluginLoadContext<{ + controller: Controller; + metrics: any; +}>; /** - * Base class for controller plugins - * - * Controller plugins are subclasses of this class which get instantiated by - * the controller on startup when the plugin is enabled in the config. - * To be discovered the class must be exported under the name `ControllerPlugin` - * in the module specified by the `controllerEntrypoint` in the plugin's - * `plugin` export. + * Collection of controller plugin hooks */ -export default class BaseControllerPlugin { - /** - * Logger for this plugin - * - * Instance of winston Logger for sending log messages from this - * plugin. Supported methods and their corresponding log levels are - * `error`, `warn`, `audit`, `info` and `verbose`. - */ - logger: Logger; - - constructor( - public info: PluginNodeEnvInfo, - public controller: Controller, - public metrics: any, - logger: Logger - ) { - this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger; +export class ControllerHooks extends lib.AsyncHookCollection { + constructor(logger: lib.Logger) { + super(logger); + this.save = this.newHook(); + this.metrics = this.newHook(); + this.shutdown = this.newHook(); + this.instanceStatusChanged = this.newHook(); + this.instanceConfigFieldChanged = this.newHook(); + this.controllerConfigFieldChanged = this.newHook(); + this.controlConnectionEvent = this.newHook(); + this.hostConnectionEvent = this.newHook(); + this.prepareHostDisconnect = this.newHook(); + this.modPacksUpdated = this.newHook(); + this.modsUpdated = this.newHook(); + this.rolesUpdated = this.newHook(); + this.playerEvent = this.newHook(); } - /** - * Called immediately after the class is instantiated - */ - async init() { } - /** * Called when the controller saves data in memory to disk * @@ -51,7 +41,7 @@ export default class BaseControllerPlugin { * BaseControllerPlugin.onShutdown} have been invoked and all links have * been disconnected. */ - async onSaveData() { } + readonly save: lib.AsyncHook<[]>; /** * Called when the status of an instance changes @@ -91,7 +81,7 @@ export default class BaseControllerPlugin { * The instance that changed. * @param prev - the previous status of the instance. */ - async onInstanceStatusChanged(instance: InstanceRecord, prev?: InstanceStatus) { } + readonly instanceStatusChanged: lib.AsyncHook<[instance: InstanceRecord, prev: lib.InstanceStatus | undefined]>; /** * Called when the value of a controller config field changed. @@ -103,7 +93,7 @@ export default class BaseControllerPlugin { * @param curr - The current value of the field. * @param prev - The previous value of the field. */ - async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { } + readonly controllerConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>; /** * Called when the value of an instance config field changed. @@ -117,7 +107,8 @@ export default class BaseControllerPlugin { * @param curr - The current value of the field. * @param prev - The previous value of the field. */ - async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { } + // eslint-disable-next-line max-len + readonly instanceConfigFieldChanged: lib.AsyncHook<[instance: InstanceRecord, field: string, curr: unknown, prev: unknown]>; /** * Called before collecting Prometheus metrics @@ -134,12 +125,12 @@ export default class BaseControllerPlugin { * * @returns an async iterator of prometheus metric results or undefined. */ - async onMetrics(): Promise> { } + readonly metrics: lib.AsyncHook<[], AsyncIterable>; /** * Called when the controller is shutting down */ - async onShutdown() { } + readonly shutdown: lib.AsyncHook<[]>; /** * Called when an event on a host connection happens @@ -174,7 +165,8 @@ export default class BaseControllerPlugin { * The connection the event occured on. * @param event - one of connect, drop, resume and close */ - onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { } + // eslint-disable-next-line max-len + readonly hostConnectionEvent: lib.AsyncHook<[connection: HostConnection, event: "connect" | "drop" | "resume" | "close"]>; /** * Called when an avent on a control connection happens @@ -210,7 +202,8 @@ export default class BaseControllerPlugin { * The connection the event occured on. * @param event - one of connect, drop, resume, and close. */ - onControlConnectionEvent(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close") { } + // eslint-disable-next-line max-len + readonly controlConnectionEvent: lib.AsyncHook<[connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"]>; /** * Called when a host is preparing to disconnect from the controller @@ -224,7 +217,7 @@ export default class BaseControllerPlugin { * @param connection - * The connection to the host preparing to disconnect. */ - async onPrepareHostDisconnect(connection: HostConnection) { } + readonly prepareHostDisconnect: lib.AsyncHook<[connection: HostConnection]>; /** * Called when one or more mod packs are updated @@ -237,7 +230,7 @@ export default class BaseControllerPlugin { * * @param modPacks - Mod packs that updated. */ - async onModPacksUpdated(modPacks: ModPack[]) { } + readonly modPacksUpdated: lib.AsyncHook<[modPacks: lib.ModPack[]]>; /** * Called when one or more mod stored on the controller are updated @@ -249,7 +242,18 @@ export default class BaseControllerPlugin { * * @param mods - Mods that updated. */ - async onModsUpdated(mods: ModInfo[]) { } + readonly modsUpdated: lib.AsyncHook<[mods: lib.ModInfo[]]>; + + /** + * Called when one or more roles stored on the controller are updated + * + * Invoked when one or more roles have been added, updated or deleted. + * + * If a role has been deleted its `.isDeleted` property will be true. + * + * @param roles - Roles that updated. + */ + readonly rolesUpdated: lib.AsyncHook<[roles: lib.Role[]]>; /** * Called when a player joins or leaves an instance @@ -261,7 +265,79 @@ export default class BaseControllerPlugin { * The instance it occured on. * @param event - Information about the event. */ - async onPlayerEvent(instance: InstanceRecord, event: PlayerEvent) { } + readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface BaseControllerPlugin { + onSaveData?(): Promise; + onMetrics?(): Promise>; + onShutdown?(): Promise; + onInstanceStatusChanged?(instance: InstanceRecord, prev?: lib.InstanceStatus): Promise; + onControllerConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise; + onInstanceConfigFieldChanged?(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): Promise; + onHostConnectionEvent?(connection: HostConnection, event: "connect" | "drop" | "resume" | "close"): void; + onControlConnectionEvent?(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"): void; + onPrepareHostDisconnect?(connection: HostConnection): Promise; + onModPacksUpdated?(modPacks: lib.ModPack[]): Promise; + onModsUpdated?(mods: lib.ModInfo[]): Promise; + onRolesUpdated?(roles: lib.Role[]): Promise; + onPlayerEvent?(instance: InstanceRecord, event: lib.PlayerEvent): Promise; +} + +/** + * Base class for controller plugins + * + * Controller plugins are subclasses of this class which get instantiated by + * the controller on startup when the plugin is enabled in the config. + * To be discovered the class must be exported under the name `ControllerPlugin` + * in the module specified by the `controllerEntrypoint` in the plugin's + * `plugin` export. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class BaseControllerPlugin { + constructor( + public info: lib.PluginNodeEnvInfo, + public controller: Controller, + public metrics: any, + public logger: lib.Logger, + ) { + const attach = ( + hook: lib.AsyncHook, + fn?: lib.HookHandler, + ) => { + if (fn) { + hook.attach(info.name, fn.bind(this)); + } + }; + + attach(controller.hooks.save, this.onSaveData); + attach(controller.hooks.instanceStatusChanged, this.onInstanceStatusChanged); + attach(controller.hooks.controllerConfigFieldChanged, this.onControllerConfigFieldChanged); + attach(controller.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged); + attach(controller.hooks.metrics, this.onMetrics); + attach(controller.hooks.shutdown, this.onShutdown); + attach(controller.hooks.hostConnectionEvent, this.onHostConnectionEvent); + attach(controller.hooks.controlConnectionEvent, this.onControlConnectionEvent); + attach(controller.hooks.prepareHostDisconnect, this.onPrepareHostDisconnect); + attach(controller.hooks.modPacksUpdated, this.onModPacksUpdated); + attach(controller.hooks.modsUpdated, this.onModsUpdated); + attach(controller.hooks.rolesUpdated, this.onRolesUpdated); + attach(controller.hooks.playerEvent, this.onPlayerEvent); + } + + static fromContext(context: ControllerPluginContext) { + return new this(context.plugin, context.controller, context.metrics, context.logger); + } + + detachHooks() { + this.controller.hooks.detachAll(this.info.name); + } + + /** + * Called immediately after the class is instantiated + */ + async init() {} /** * Broadcast event to all connected hosts @@ -272,7 +348,7 @@ export default class BaseControllerPlugin { * * @param event - Event to send */ - broadcastEventToHosts(event: Event) { + broadcastEventToHosts(event: lib.Event) { for (let hostConnection of this.controller.wsServer.hostConnections.values()) { if ( !hostConnection.connector.closing diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index cf110b7a..3dbe8934 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -63,9 +63,7 @@ export default class ControlConnection extends BaseConnection { for (let event of ["connect", "drop", "resume", "close"] as const) { this.connector.on(event, () => { - for (let controllerPlugin of this._controller.plugins.values()) { - controllerPlugin.onControlConnectionEvent(this, event); - } + this._controller.hooks.controlConnectionEvent.invoke(this, event); }); } @@ -1310,7 +1308,7 @@ export default class ControlConnection extends BaseConnection { async handlePluginListRequest(request: lib.PluginListRequest) { return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( pluginInfo, - this._controller.plugins.has(pluginInfo.name), + this._controller.loadedPlugins.has(pluginInfo), this._controller.config.get(`${pluginInfo.name}.load_plugin`), )); } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 7c7aa18b..841304a1 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -28,7 +28,7 @@ import UserRecord from "./UserRecord.js"; import UserManager from "./UserManager.js"; import WsServer from "./WsServer.js"; import HostRecord from "./HostRecord.js"; -import BaseControllerPlugin from "./BaseControllerPlugin.js"; +import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js"; import ControllerRouter from "./ControllerRouter.js"; import InstanceManager from "./InstanceManager.js"; @@ -70,8 +70,10 @@ export default class Controller { httpsServer: https.Server | null = null; httpsServerCloser: HttpCloser | null = null; - /** Mapping of plugin name to loaded plugin */ - plugins: Map = new Map(); + /** Hooks which plugins can attach to */ + hooks = new ControllerHooks(logger); + /** Plugins which are currently loaded */ + loadedPlugins: Set = new Set(); /** WebSocket server */ wsServer: WsServer; @@ -359,7 +361,7 @@ export default class Controller { } else if (field === "controller.static_url") { this.app.locals.staticRoot = this.config.get("controller.static_url"); } - lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", field, curr, prev); + this.hooks.controllerConfigFieldChanged.invoke(field, curr, prev); }); // Make sure we're actually going to listen on a port @@ -542,7 +544,7 @@ export default class Controller { await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); }); } - await lib.invokeHook(this.plugins, "onShutdown"); + await this.hooks.shutdown.invoke(); await this.wsServer.stop(); @@ -732,9 +734,8 @@ export default class Controller { this.modPacks.save(), this.roles.save(), this.users.records.save(), + this.hooks.save.invoke(), ]); - - await lib.invokeHook(this.plugins, "onSaveData"); } static migrateSystems(rawJson: unknown[]): Static[] { @@ -1034,7 +1035,7 @@ export default class Controller { modPacksUpdated(modPacks: lib.ModPack[]) { this.subscriptions.broadcast(new lib.ModPackUpdatesEvent(modPacks)); - lib.invokeHook(this.plugins, "onModPacksUpdated", modPacks); + this.hooks.modPacksUpdated.invoke(modPacks); } async handleModPackSubscription(request: lib.SubscriptionRequest) { @@ -1050,7 +1051,7 @@ export default class Controller { modsUpdated(mods: lib.ModInfo[]) { // ModStore sets updatedAtMs for mods this.subscriptions.broadcast(new lib.ModUpdatesEvent(mods)); - lib.invokeHook(this.plugins, "onModsUpdated", mods); + this.hooks.modsUpdated.invoke(mods); } async handleModSubscription(request: lib.SubscriptionRequest) { @@ -1098,7 +1099,7 @@ export default class Controller { rolesUpdated(roles: lib.Role[]) { this.subscriptions.broadcast(new lib.RoleUpdatesEvent(roles)); - // lib.invokeHook(this.plugins, "onRolesUpdated", roles); // This doesn't exist at the moment + this.hooks.rolesUpdated.invoke(roles); // Notify connected control clients with the given role that the permissions may have changed. for (const role of roles) { for (let controlConnection of this.wsServer.controlConnections.values()) { @@ -1128,6 +1129,7 @@ export default class Controller { } async loadPlugins() { + const context = { metrics, logger, controller: this }; for (let pluginInfo of this.pluginInfos) { try { let manifestPath = path.posix.join(pluginInfo.requirePath, "dist", "web", "manifest.json"); @@ -1145,20 +1147,16 @@ export default class Controller { continue; } - let ControllerPluginClass = BaseControllerPlugin; try { - if (pluginInfo.controllerEntrypoint) { - ControllerPluginClass = await lib.loadPluginClass( - pluginInfo.name, - path.posix.join(pluginInfo.requirePath, pluginInfo.controllerEntrypoint), - "ControllerPlugin", - BaseControllerPlugin, - ); - } + await lib.loadPlugin( + pluginInfo, + "controller", + context, + "ControllerPlugin", + BaseControllerPlugin, + ); - let controllerPlugin = new ControllerPluginClass(pluginInfo, this, metrics as any, logger); - await controllerPlugin.init(); - this.plugins.set(pluginInfo.name, controllerPlugin); + this.loadedPlugins.add(pluginInfo); } catch (err: any) { if (err.code === "InstallationError") { diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index c368f8c8..3080c1be 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -47,12 +47,7 @@ export default class HostConnection extends BaseConnection { this._checkPluginVersions(); for (let event of ["connect", "drop", "resume", "close"] as const) { - - this.connector.on(event, () => { - for (let plugin of this._controller.plugins.values()) { - plugin.onHostConnectionEvent(this, event); - } - }); + this.connector.on(event, () => this._controller.hooks.hostConnectionEvent.invoke(this, event)); } this.connector.on("close", () => { @@ -66,7 +61,7 @@ export default class HostConnection extends BaseConnection { instances.push(instance); let prev = instance.status; instance.status = "unknown"; - lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); + this._controller.hooks.instanceStatusChanged.invoke(instance, prev); } this._controller.instances.records.setMany(instances); @@ -143,7 +138,7 @@ export default class HostConnection extends BaseConnection { } async prepareDisconnect() { - await lib.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this); + await this._controller.hooks.prepareHostDisconnect.invoke(this); return await super.prepareDisconnect(); } @@ -216,7 +211,7 @@ export default class HostConnection extends BaseConnection { instance.factorioVersion = request.factorioVersion; this._controller.instances.records.set(instance); logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`); - await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); + await this._controller.hooks.instanceStatusChanged.invoke(instance, prev); } async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) { @@ -256,9 +251,7 @@ export default class HostConnection extends BaseConnection { } instanceUpdates.push(controllerInstance); logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`); - await lib.invokeHook( - this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev - ); + await this._controller.hooks.instanceStatusChanged.invoke(controllerInstance, prev); } continue; } @@ -274,7 +267,7 @@ export default class HostConnection extends BaseConnection { // We suppress the changes made by createInstance so we must manually make them here this._controller.instances.records.setMany(instanceUpdates); await Promise.all(instanceUpdates.flatMap(newInstance => [ - lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance), + this._controller.hooks.instanceStatusChanged.invoke(newInstance, undefined), this.send( new lib.InstanceAssignInternalRequest( newInstance.config.get("instance.id"), newInstance.config.toRemote("host") @@ -350,7 +343,7 @@ export default class HostConnection extends BaseConnection { user.saveRecord(); let instance = this._controller.instances.get(instanceId)!; - await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { + await this._controller.hooks.playerEvent.invoke(instance, { type: event.type, name: event.name, reason: event.reason, @@ -367,7 +360,7 @@ export default class HostConnection extends BaseConnection { user.set("isAdmin", event.admin); const instance = this._controller.instances.get(src.id)!; - await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { + await this._controller.hooks.playerEvent.invoke(instance, { type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"], name: event.name, stats: user.instanceStats.get(src.id)!, @@ -385,11 +378,11 @@ export default class HostConnection extends BaseConnection { user.saveRecord(); const instance = this._controller.instances.get(src.id)!; - await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { + await this._controller.hooks.playerEvent.invoke(instance, { type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"], name: event.name, stats: user.instanceStats.get(src.id)!, - resaon: event.reason, + reason: event.reason, }); } @@ -402,7 +395,7 @@ export default class HostConnection extends BaseConnection { user.set("isWhitelisted", event.whitelisted); const instance = this._controller.instances.get(src.id)!; - await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { + await this._controller.hooks.playerEvent.invoke(instance, { type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"], name: event.name, stats: user.instanceStats.get(src.id)!, diff --git a/packages/controller/src/InstanceManager.ts b/packages/controller/src/InstanceManager.ts index 51292dc6..0736c493 100644 --- a/packages/controller/src/InstanceManager.ts +++ b/packages/controller/src/InstanceManager.ts @@ -83,12 +83,7 @@ export default class InstanceManager { instance: InstanceRecord, prev?: lib.InstanceStatus, ): Promise { - await lib.invokeHook( - this._controller.plugins, - "onInstanceStatusChanged", - instance, - prev, - ); + await this._controller.hooks.instanceStatusChanged.invoke(instance, prev); } private async _notifyConfigFieldChanged( @@ -97,14 +92,7 @@ export default class InstanceManager { curr: unknown, prev: unknown, ): Promise { - await lib.invokeHook( - this._controller.plugins, - "onInstanceConfigFieldChanged", - instance, - field, - curr, - prev, - ); + await this._controller.hooks.instanceConfigFieldChanged.invoke(instance, field, curr, prev); } /** diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts index f6293b84..efaf1061 100644 --- a/packages/controller/src/WsServer.ts +++ b/packages/controller/src/WsServer.ts @@ -135,8 +135,8 @@ export default class WsServer { // Start connection handshake. let loadedPlugins: {[key:string]: string} = {}; - for (let [name, plugin] of this.controller.plugins) { - loadedPlugins[name] = plugin.info.version; + for (const plugin of this.controller.loadedPlugins) { + loadedPlugins[plugin.name] = plugin.version; } socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageConfig.version, loadedPlugins)))); diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index 5e522583..d54b785f 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -49,7 +49,7 @@ async function getMetrics(req: Request, res: Response, next: any) { const controller: Controller = req.app.locals.controller; let results: lib.CollectorResult[] = []; - let pluginResults = await lib.invokeHook(controller.plugins, "onMetrics"); + let pluginResults = await controller.hooks.metrics.collect(); for (let metricIterator of pluginResults) { for await (let metric of metricIterator) { results.push(metric); @@ -117,8 +117,8 @@ function getPlugins(req: Request, res: Response) { let plugins: lib.PluginWebApi[] = []; for (let pluginInfo of req.app.locals.controller.pluginInfos) { let name = pluginInfo.name; - let loaded = req.app.locals.controller.plugins.has(name); - let enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`); + const loaded = req.app.locals.controller.loadedPlugins.has(pluginInfo); + const enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`); // Note: Cast through unknown is needed because load_plugin is // defined at runtime and no other fields in the controller config // currently have the boolean type. diff --git a/packages/create/templates/plugin-js/controller.js b/packages/create/templates/plugin-js/controller.js index 4efb047d..f5df87e1 100644 --- a/packages/create/templates/plugin-js/controller.js +++ b/packages/create/templates/plugin-js/controller.js @@ -1,5 +1,4 @@ import * as lib from "@clusterio/lib"; -import { BaseControllerPlugin } from "@clusterio/controller"; //%if multi_context // Messages requires multi context import { @@ -12,69 +11,56 @@ import { } from "./messages.js"; //%endif -export class ControllerPlugin extends BaseControllerPlugin { +export default async function loadControllerPlugin(context) { + const { controller, logger, plugin } = context; //%if controller & web // Subscribing requires web content and the controller - exampleDatabase; - storageDirty = false; - -//%endif -//%if multi_context // Subscribing requires multi context - async init() { - this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); - this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); -//%endif -//%if controller & web // Subscribing requires web content and the controller - this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this)); - // If needed, replace with loading from database file such as lib.Datastore - this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]); -//%endif -//%if multi_context // Subscribing requires multi context - } + // If needed, replace with loading from database file such as lib.Datastore + const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]); //%endif +//%if multi_context // Messages requires multi context - async onControllerConfigFieldChanged(field, curr, prev) { - this.logger.info(`controller::onControllerConfigFieldChanged ${field}`); - } -//%if instance + controller.handle(PluginExampleEvent, async (event) => { + logger.info(JSON.stringify(event)); + }); - async onInstanceConfigFieldChanged(instance, field, curr, prev) { - this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`); - } -//%endif - - async onSaveData() { - this.logger.info("controller::onSaveData"); - } - - async onShutdown() { - this.logger.info("controller::onShutdown"); - } - - async onPlayerEvent(instance, event) { - this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`); - } -//%if multi_context - - async handlePluginExampleEvent(event) { - this.logger.info(JSON.stringify(event)); - } - - async handlePluginExampleRequest(request) { - this.logger.info(JSON.stringify(request)); + controller.handle(PluginExampleRequest, async (request) => { + logger.info(JSON.stringify(request)); return { myResponseString: request.myString, myResponseNumbers: request.myNumberArray, }; - } + }); //%endif //%if controller & web // Subscribing requires web content and the controller - async handleExampleSubscription(request) { - this.logger.info(JSON.stringify(request)); - const values = [...this.exampleDatabase.values()].filter( + controller.subscriptions.handle(ExampleSubscribableUpdate, async (request) => { + logger.info(JSON.stringify(request)); + const values = [...exampleDatabase.values()].filter( value => value.updatedAtMs > request.lastRequestTimeMs, ); return values.length ? new ExampleSubscribableUpdate(values) : null; - } + }); //%endif + + controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => { + logger.info(`controller::controllerConfigFieldChanged ${field}`); + }); +//%if instance + + controller.hooks.instanceConfigFieldChanged.attach(plugin.name, async (instance, field, curr, prev) => { + logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`); + }); +//%endif + + controller.hooks.save.attach(plugin.name, async () => { + logger.info("controller::save"); + }); + + controller.hooks.shutdown.attach(plugin.name, async () => { + logger.info("controller::shutdown"); + }); + + controller.hooks.playerEvent.attach(plugin.name, async (instance, event) => { + logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`); + }); } diff --git a/packages/create/templates/plugin-js/ctl.js b/packages/create/templates/plugin-js/ctl.js index 03377b92..b3f16594 100644 --- a/packages/create/templates/plugin-js/ctl.js +++ b/packages/create/templates/plugin-js/ctl.js @@ -1,4 +1,3 @@ -import { BaseCtlPlugin } from "@clusterio/ctl"; import { CommandTree, Command } from "@clusterio/lib"; //%// We do not check for multi context here because it doesn't make sense to have a ctl without messages import { PluginExampleEvent, PluginExampleRequest } from "./messages.js"; @@ -20,8 +19,8 @@ pluginCommands.add(new Command({ })); /* eslint-enable no-console */ -export class CtlPlugin extends BaseCtlPlugin { - async addCommands(rootCommand) { +export default async function loadCtlPlugin(context) { + context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => { rootCommand.add(pluginCommands); - } + }); } diff --git a/packages/create/templates/plugin-js/host.js b/packages/create/templates/plugin-js/host.js index 6675acff..d40601ec 100644 --- a/packages/create/templates/plugin-js/host.js +++ b/packages/create/templates/plugin-js/host.js @@ -1,36 +1,30 @@ import * as lib from "@clusterio/lib"; -import { BaseHostPlugin } from "@clusterio/host"; //%if multi_context import { PluginExampleEvent, PluginExampleRequest } from "./messages.js"; //%endif -export class HostPlugin extends BaseHostPlugin { -//%if multi_context - async init() { - this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); - this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); - } -//%endif - - async onHostConfigFieldChanged(field, curr, prev) { - this.logger.info(`host::onInstanceConfigFieldChanged ${field}`); - } - - async onShutdown() { - this.logger.info("host::onShutdown"); - } +export default async function loadHostPlugin(context) { + const { host, logger, plugin } = context; //%if multi_context - async handlePluginExampleEvent(event) { - this.logger.info(JSON.stringify(event)); - } + host.handle(PluginExampleEvent, async (event) => { + logger.info(JSON.stringify(event)); + }); - async handlePluginExampleRequest(request) { - this.logger.info(JSON.stringify(request)); + host.handle(PluginExampleRequest, async (request) => { + logger.info(JSON.stringify(request)); return { myResponseString: request.myString, myResponseNumbers: request.myNumberArray, }; - } + }); //%endif + + host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => { + logger.info(`host::hostConfigFieldChanged ${field}`); + }); + + host.hooks.shutdown.attach(plugin.name, async () => { + logger.info("host::shutdown"); + }); } diff --git a/packages/create/templates/plugin-js/index.js b/packages/create/templates/plugin-js/index.js index 8ac2056f..171341cb 100644 --- a/packages/create/templates/plugin-js/index.js +++ b/packages/create/templates/plugin-js/index.js @@ -1,6 +1,6 @@ import * as lib from "@clusterio/lib"; //%if multi_context -import Messages from "./messages.js"; +import * as Messages from "./messages.js"; //%endif export const plugin = { diff --git a/packages/create/templates/plugin-js/instance.js b/packages/create/templates/plugin-js/instance.js index 8049e468..6b2569ef 100644 --- a/packages/create/templates/plugin-js/instance.js +++ b/packages/create/templates/plugin-js/instance.js @@ -1,69 +1,56 @@ import * as lib from "@clusterio/lib"; -import { BaseInstancePlugin } from "@clusterio/host"; //%if multi_context import { PluginExampleEvent, PluginExampleRequest } from "./messages.js"; //%endif -export class InstancePlugin extends BaseInstancePlugin { -//%if multi_context | module - async init() { -//%if multi_context - this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); - this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); -//%endif -//%if module - this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this)); -//%endif -//%if multi_context | module - } -//%endif - - async onInstanceConfigFieldChanged(field, curr, prev) { - this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`); - } - - async onStart() { - // Called once rcon becomes available - this.logger.info("instance::onStart"); - } - - async onStop() { - // Called during normal exits before rcon becomes unavailable - this.logger.info("instance::onStop"); - } - - onExit() { - // Called during all exits, including crashes and init failures, rcon is not available - this.logger.info("instance::onExit"); - } - - - async onPlayerEvent(event) { - this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`); -//%if module - if (this.instance.status === "running") { - await this.sendRcon("/sc __plugin_name__.foo()"); - } -//%endif - } +export default async function loadInstancePlugin(context) { + const { instance, logger, plugin } = context; //%if multi_context - async handlePluginExampleEvent(event) { - this.logger.info(JSON.stringify(event)); - } + instance.handle(PluginExampleEvent, async (event) => { + logger.info(JSON.stringify(event)); + }); - async handlePluginExampleRequest(request) { - this.logger.info(JSON.stringify(request)); + instance.handle(PluginExampleRequest, async (request) => { + logger.info(JSON.stringify(request)); return { myResponseString: request.myString, myResponseNumbers: request.myNumberArray, }; - } + }); //%endif //%if module - async handlePluginExampleIPC(event) { - this.logger.info(JSON.stringify(event)); - } + instance.server.handle("__plugin_name__-plugin_example_ipc", async (event) => { + logger.info(JSON.stringify(event)); + }); //%endif + + instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => { + logger.info(`instance::instanceConfigFieldChanged ${field}`); + }); + + instance.hooks.start.attach(plugin.name, async () => { + // Called once rcon becomes available + logger.info("instance::start"); + }); + + instance.hooks.stop.attach(plugin.name, async () => { + // Called during normal exits before rcon becomes unavailable + logger.info("instance::stop"); + }); + + instance.hooks.exit.attach(plugin.name, () => { + // Called during all exits, including crashes and init failures, rcon is not available + logger.info("instance::exit"); + }); + + instance.hooks.playerEvent.attach(plugin.name, async (event) => { + logger.info(`instance::playerEvent ${JSON.stringify(event)}`); +//%if module + if (instance.status === "running") { + await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name); + } +//%endif + }); } diff --git a/packages/create/templates/plugin-js/instance_empty.js b/packages/create/templates/plugin-js/instance_empty.js index cf4f0b68..73a295ed 100644 --- a/packages/create/templates/plugin-js/instance_empty.js +++ b/packages/create/templates/plugin-js/instance_empty.js @@ -1,6 +1,3 @@ -import { BaseInstancePlugin } from "@clusterio/host"; - -export class InstancePlugin extends BaseInstancePlugin { - // This class is empty because an instance plugin must be defined for a module to be injected - // This requirement may change in the future to allow for standalone modules -} +// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected +// This requirement may change in the future to allow for standalone modules +export default async function loadInstancePlugin(context) { } diff --git a/packages/create/templates/plugin-js/web/plugin.jsx b/packages/create/templates/plugin-js/web/plugin.jsx index 69ac6364..3cac165e 100644 --- a/packages/create/templates/plugin-js/web/plugin.jsx +++ b/packages/create/templates/plugin-js/web/plugin.jsx @@ -10,7 +10,7 @@ import React, { // } from "antd"; import { - BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler, + PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler, } from "@clusterio/web_ui"; //%if multi_context // Messages requires multi context @@ -25,12 +25,22 @@ import { //%endif import * as lib from "@clusterio/lib"; +//%if controller // Subscribing requires web content and the controller + +// Created when the plugin is loaded, before any page is rendered +let exampleSubscriber; + +function useSubscribableData() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback) => exampleSubscriber.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot()); +} +//%endif function MyTemplatePage() { let control = useContext(ControlContext); //%if controller // Subscribing requires web content and the controller - const plugin = control.plugins.get("__plugin_name__"); - const [subscribableData, synced] = plugin.useSubscribableData(); + const [subscribableData, synced] = useSubscribableData(); //%endif return @@ -41,53 +51,39 @@ function MyTemplatePage() { ; } -export class WebPlugin extends BaseWebPlugin { +export default async function loadWebPlugin(context) { + const { control, logger, plugin } = context; //%if controller // Subscribing requires web content and the controller - subscribableData = new lib.MapSubscriber(ExampleSubscribableUpdate, this.control); - + exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control); //%endif - async init() { - this.pages = [ - { - path: "/__plugin_name__", - sidebarName: "__plugin_name__", - // This permission is client side only, so it must match the permission string of a resource request to be secure - // An undefined value means that the page will always be visible + + control.hooks.pages.attach(plugin.name, () => [ + { + path: "/__plugin_name__", + sidebarName: "__plugin_name__", + // This permission is client side only, so it must match the permission string of a resource request to be secure + // An undefined value means that the page will always be visible //%if controller // Subscribing requires web content and the controller - permission: "__plugin_name__.example.permission.subscribe", + permission: "__plugin_name__.example.permission.subscribe", //%endif //%if !controller - permission: "__plugin_name__.page.view", + permission: "__plugin_name__.page.view", //%endif - content: , - }, - ]; + content: , + }, + ]); //%if multi_context - this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); - this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); -//%endif - } -//%if controller// Subscribing requires web content and the controller + control.handle(PluginExampleEvent, async (event) => { + logger.info(JSON.stringify(event)); + }); - useSubscribableData() { - const control = useContext(ControlContext); - const subscribe = useCallback((callback) => this.subscribableData.subscribe(callback), [control]); - return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot()); - } -//%endif -//%if multi_context - - async handlePluginExampleEvent(event) { - this.logger.info(JSON.stringify(event)); - } - - async handlePluginExampleRequest(request) { - this.logger.info(JSON.stringify(request)); + control.handle(PluginExampleRequest, async (request) => { + logger.info(JSON.stringify(request)); return { myResponseString: request.myString, myResponseNumbers: request.myNumberArray, }; - } + }); //%endif } diff --git a/packages/create/templates/plugin-ts/controller.ts b/packages/create/templates/plugin-ts/controller.ts index f69e5b72..8f1368d4 100644 --- a/packages/create/templates/plugin-ts/controller.ts +++ b/packages/create/templates/plugin-ts/controller.ts @@ -1,5 +1,5 @@ import * as lib from "@clusterio/lib"; -import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller"; +import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller"; //%if multi_context // Messages requires multi context import { @@ -12,69 +12,58 @@ import { } from "./messages.js"; //%endif -export class ControllerPlugin extends BaseControllerPlugin { +export default async function loadControllerPlugin(context: ControllerPluginContext) { + const { controller, logger, plugin } = context; //%if controller & web // Subscribing requires web content and the controller - exampleDatabase!: Map; - storageDirty = false; - -//%endif -//%if multi_context // Subscribing requires multi context - async init() { - this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); - this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); -//%endif -//%if controller & web // Subscribing requires web content and the controller - this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this)); - // If needed, replace with loading from database file such as lib.Datastore - this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]); -//%endif -//%if multi_context // Subscribing requires multi context - } + // If needed, replace with loading from database file such as lib.Datastore + const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]); //%endif +//%if multi_context // Messages requires multi context - async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { - this.logger.info(`controller::onControllerConfigFieldChanged ${field}`); - } -//%if instance + controller.handle(PluginExampleEvent, async (event: PluginExampleEvent) => { + logger.info(JSON.stringify(event)); + }); - async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { - this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`); - } -//%endif - - async onSaveData() { - this.logger.info("controller::onSaveData"); - } - - async onShutdown() { - this.logger.info("controller::onShutdown"); - } - - async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) { - this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`); - } -//%if multi_context - - async handlePluginExampleEvent(event: PluginExampleEvent) { - this.logger.info(JSON.stringify(event)); - } - - async handlePluginExampleRequest(request: PluginExampleRequest) { - this.logger.info(JSON.stringify(request)); + controller.handle(PluginExampleRequest, async (request: PluginExampleRequest) => { + logger.info(JSON.stringify(request)); return { myResponseString: request.myString, myResponseNumbers: request.myNumberArray, }; - } + }); //%endif //%if controller & web // Subscribing requires web content and the controller - async handleExampleSubscription(request: lib.SubscriptionRequest) { - this.logger.info(JSON.stringify(request)); - const values = [...this.exampleDatabase.values()].filter( + controller.subscriptions.handle(ExampleSubscribableUpdate, async (request: lib.SubscriptionRequest) => { + logger.info(JSON.stringify(request)); + const values = [...exampleDatabase.values()].filter( value => value.updatedAtMs > request.lastRequestTimeMs, ); return values.length ? new ExampleSubscribableUpdate(values) : null; - } + }); //%endif + + controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => { + logger.info(`controller::controllerConfigFieldChanged ${field}`); + }); +//%if instance + + controller.hooks.instanceConfigFieldChanged.attach( + plugin.name, async (instance: InstanceRecord, field, curr, prev) => { + logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`); + } + ); +//%endif + + controller.hooks.save.attach(plugin.name, async () => { + logger.info("controller::save"); + }); + + controller.hooks.shutdown.attach(plugin.name, async () => { + logger.info("controller::shutdown"); + }); + + controller.hooks.playerEvent.attach(plugin.name, async (instance: InstanceRecord, event: lib.PlayerEvent) => { + logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`); + }); } diff --git a/packages/create/templates/plugin-ts/ctl.ts b/packages/create/templates/plugin-ts/ctl.ts index 0a42aa8a..6211d37c 100644 --- a/packages/create/templates/plugin-ts/ctl.ts +++ b/packages/create/templates/plugin-ts/ctl.ts @@ -1,4 +1,4 @@ -import { BaseCtlPlugin, type Control } from "@clusterio/ctl"; +import type { Control, CtlPluginContext } from "@clusterio/ctl"; import { CommandTree, Command } from "@clusterio/lib"; //%// We do not check for multi context here because it doesn't make sense to have a ctl without messages import { PluginExampleEvent, PluginExampleRequest } from "./messages.js"; @@ -20,8 +20,8 @@ pluginCommands.add(new Command({ })); /* eslint-enable no-console */ -export class CtlPlugin extends BaseCtlPlugin { - async addCommands(rootCommand: CommandTree) { +export default async function loadCtlPlugin(context: CtlPluginContext) { + context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => { rootCommand.add(pluginCommands); - } + }); } diff --git a/packages/create/templates/plugin-ts/host.ts b/packages/create/templates/plugin-ts/host.ts index ed31b4e5..63df2b5e 100644 --- a/packages/create/templates/plugin-ts/host.ts +++ b/packages/create/templates/plugin-ts/host.ts @@ -1,36 +1,30 @@ -import * as lib from "@clusterio/lib"; -import { BaseHostPlugin } from "@clusterio/host"; +import type { HostPluginContext } from "@clusterio/host"; //%if multi_context import { PluginExampleEvent, PluginExampleRequest } from "./messages.js"; //%endif -export class HostPlugin extends BaseHostPlugin { -//%if multi_context - async init() { - this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); - this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); - } -//%endif - - async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) { - this.logger.info(`host::onInstanceConfigFieldChanged ${field}`); - } - - async onShutdown() { - this.logger.info("host::onShutdown"); - } +export default async function loadHostPlugin(context: HostPluginContext) { + const { host, logger, plugin } = context; //%if multi_context - async handlePluginExampleEvent(event: PluginExampleEvent) { - this.logger.info(JSON.stringify(event)); - } + host.handle(PluginExampleEvent, async (event: PluginExampleEvent) => { + logger.info(JSON.stringify(event)); + }); - async handlePluginExampleRequest(request: PluginExampleRequest) { - this.logger.info(JSON.stringify(request)); + host.handle(PluginExampleRequest, async (request: PluginExampleRequest) => { + logger.info(JSON.stringify(request)); return { myResponseString: request.myString, myResponseNumbers: request.myNumberArray, }; - } + }); //%endif + + host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => { + logger.info(`host::hostConfigFieldChanged ${field}`); + }); + + host.hooks.shutdown.attach(plugin.name, async () => { + logger.info("host::shutdown"); + }); } diff --git a/packages/create/templates/plugin-ts/instance.ts b/packages/create/templates/plugin-ts/instance.ts index 9f3cf81d..72caab87 100644 --- a/packages/create/templates/plugin-ts/instance.ts +++ b/packages/create/templates/plugin-ts/instance.ts @@ -1,76 +1,64 @@ import * as lib from "@clusterio/lib"; -import { BaseInstancePlugin } from "@clusterio/host"; +import type { InstancePluginContext } from "@clusterio/host"; //%if multi_context import { PluginExampleEvent, PluginExampleRequest } from "./messages.js"; //%endif //%if module -type PuginExampleIPC = { +type PluginExampleIPC = { tick: number, player_name: string, }; //%endif -export class InstancePlugin extends BaseInstancePlugin { -//%if multi_context | module - async init() { -//%endif -//%if multi_context - this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); - this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); -//%endif -//%if module - this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this)); -//%endif -//%if multi_context | module - } -//%endif - - async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { - this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`); - } - - async onStart() { - // Called once rcon becomes available - this.logger.info("instance::onStart"); - } - - async onStop() { - // Called during normal exits before rcon becomes unavailable - this.logger.info("instance::onStop"); - } - - onExit() { - // Called during all exits, including crashes and init failures, rcon is not available - this.logger.info("instance::onExit"); - } - - async onPlayerEvent(event: lib.PlayerEvent) { - this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`); -//%if module - if (this.instance.status === "running") { - await this.sendRcon("/sc __plugin_name__.foo()"); - } -//%endif - } +export default async function loadInstancePlugin(context: InstancePluginContext) { + const { instance, logger, plugin } = context; //%if multi_context - async handlePluginExampleEvent(event: PluginExampleEvent) { - this.logger.info(JSON.stringify(event)); - } + instance.handle(PluginExampleEvent, async (event: PluginExampleEvent) => { + logger.info(JSON.stringify(event)); + }); - async handlePluginExampleRequest(request: PluginExampleRequest) { - this.logger.info(JSON.stringify(request)); + instance.handle(PluginExampleRequest, async (request: PluginExampleRequest) => { + logger.info(JSON.stringify(request)); return { myResponseString: request.myString, myResponseNumbers: request.myNumberArray, }; - } + }); //%endif //%if module - async handlePluginExampleIPC(event: PuginExampleIPC) { - this.logger.info(JSON.stringify(event)); - } + instance.server.handle("__plugin_name__-plugin_example_ipc", async (event: PluginExampleIPC) => { + logger.info(JSON.stringify(event)); + }); //%endif + + instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => { + logger.info(`instance::instanceConfigFieldChanged ${field}`); + }); + + instance.hooks.start.attach(plugin.name, async () => { + // Called once rcon becomes available + logger.info("instance::start"); + }); + + instance.hooks.stop.attach(plugin.name, async () => { + // Called during normal exits before rcon becomes unavailable + logger.info("instance::stop"); + }); + + instance.hooks.exit.attach(plugin.name, () => { + // Called during all exits, including crashes and init failures, rcon is not available + logger.info("instance::exit"); + }); + + instance.hooks.playerEvent.attach(plugin.name, async (event: lib.PlayerEvent) => { + logger.info(`instance::playerEvent ${JSON.stringify(event)}`); +//%if module + if (instance.status === "running") { + await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name); + } +//%endif + }); } diff --git a/packages/create/templates/plugin-ts/instance_empty.ts b/packages/create/templates/plugin-ts/instance_empty.ts index e8fa7eeb..6d2ce6e8 100644 --- a/packages/create/templates/plugin-ts/instance_empty.ts +++ b/packages/create/templates/plugin-ts/instance_empty.ts @@ -1,7 +1,5 @@ -import * as lib from "@clusterio/lib"; -import { BaseInstancePlugin } from "@clusterio/host"; +import type { InstancePluginContext } from "@clusterio/host"; -export class InstancePlugin extends BaseInstancePlugin { - // This class is empty because an instance plugin must be defined for a module to be injected - // This requirement may change in the future to allow for standalone modules -} +// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected +// This requirement may change in the future to allow for standalone modules +export default async function loadInstancePlugin(context: InstancePluginContext) { } diff --git a/packages/create/templates/plugin-ts/web/plugin.tsx b/packages/create/templates/plugin-ts/web/plugin.tsx index 92fbb5cd..513dc7fd 100644 --- a/packages/create/templates/plugin-ts/web/plugin.tsx +++ b/packages/create/templates/plugin-ts/web/plugin.tsx @@ -10,7 +10,8 @@ import React, { // } from "antd"; import { - BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler, + PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler, + type WebPluginContext, } from "@clusterio/web_ui"; //%if multi_context // Messages requires multi context @@ -25,12 +26,22 @@ import { //%endif import * as lib from "@clusterio/lib"; +//%if controller // Subscribing requires web content and the controller + +// Created when the plugin is loaded, before any page is rendered +let exampleSubscriber: lib.MapSubscriber; + +function useSubscribableData() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => exampleSubscriber.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot()); +} +//%endif function MyTemplatePage() { const control = useContext(ControlContext); //%if controller // Subscribing requires web content and the controller - const plugin = control.plugins.get("__plugin_name__") as WebPlugin; - const [subscribableData, synced] = plugin.useSubscribableData(); + const [subscribableData, synced] = useSubscribableData(); //%endif return @@ -41,53 +52,39 @@ function MyTemplatePage() { ; } -export class WebPlugin extends BaseWebPlugin { +export default async function loadWebPlugin(context: WebPluginContext) { + const { control, logger, plugin } = context; //%if controller // Subscribing requires web content and the controller - subscribableData = new lib.MapSubscriber(ExampleSubscribableUpdate, this.control); - + exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control); //%endif - async init() { - this.pages = [ - { - path: "/__plugin_name__", - sidebarName: "__plugin_name__", - // This permission is client side only, so it must match the permission string of a resource request to be secure - // An undefined value means that the page will always be visible + + control.hooks.pages.attach(plugin.name, () => [ + { + path: "/__plugin_name__", + sidebarName: "__plugin_name__", + // This permission is client side only, so it must match the permission string of a resource request to be secure + // An undefined value means that the page will always be visible //%if controller // Subscribing requires web content and the controller - permission: "__plugin_name__.example.permission.subscribe", + permission: "__plugin_name__.example.permission.subscribe", //%endif //%if !controller - permission: "__plugin_name__.page.view", + permission: "__plugin_name__.page.view", //%endif - content: , - }, - ]; + content: , + }, + ]); //%if multi_context - this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this)); - this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this)); -//%endif - } -//%if controller // Subscribing requires web content and the controller + control.handle(PluginExampleEvent, async (event: PluginExampleEvent) => { + logger.info(JSON.stringify(event)); + }); - useSubscribableData() { - const control = useContext(ControlContext); - const subscribe = useCallback((callback: () => void) => this.subscribableData.subscribe(callback), [control]); - return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot()); - } -//%endif -//%if multi_context - - async handlePluginExampleEvent(event: PluginExampleEvent) { - this.logger.info(JSON.stringify(event)); - } - - async handlePluginExampleRequest(request: PluginExampleRequest) { - this.logger.info(JSON.stringify(request)); + control.handle(PluginExampleRequest, async (request: PluginExampleRequest) => { + logger.info(JSON.stringify(request)); return { myResponseString: request.myString, myResponseNumbers: request.myNumberArray, }; - } + }); //%endif } diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index 2625ea5a..b81562f4 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -16,7 +16,7 @@ import * as lib from "@clusterio/lib"; import { ConsoleTransport, levels, logger } from "@clusterio/lib"; import * as commands from "./src/commands.js"; -import BaseCtlPlugin from "./src/BaseCtlPlugin.js"; +import { BaseCtlPlugin, CtlHooks } from "./src/BaseCtlPlugin.js"; /** @@ -53,19 +53,15 @@ class ControlConnector extends lib.WebSocketClientConnector { export class Control extends lib.Link { /** Control config used for connecting to the controller. */ config: lib.ControlConfig; - /** Mapping of plugin names to their instance for loaded plugins. */ - plugins: Map; /** Keep the control connection alive after the command completes. */ keepOpen = false; constructor( connector: ControlConnector, controlConfig: lib.ControlConfig, - ctlPlugins: Map ) { super(connector); this.config = controlConfig; - this.plugins = ctlPlugins; this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this)); this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this)); @@ -105,28 +101,23 @@ export class Control extends lib.Link { } } -async function loadPlugins(pluginList: Map) { +async function loadPlugins(pluginList: Map, hooks: CtlHooks) { let pluginInfos = await lib.loadPluginInfos(pluginList); lib.registerPluginMessages(pluginInfos); lib.registerPluginPermissions(pluginInfos); lib.addPluginConfigFields(pluginInfos); - let ctlPlugins = new Map(); + const ctlPlugins = new Set(); for (let pluginInfo of pluginInfos) { if (!pluginInfo.ctlEntrypoint) { continue; } - let CtlPlugin = await lib.loadPluginClass( - pluginInfo.name, - path.posix.join(pluginInfo.requirePath, pluginInfo.ctlEntrypoint), - "CtlPlugin", - BaseCtlPlugin, - ); - let ctlPlugin = new CtlPlugin(pluginInfo, logger); - ctlPlugins.set(pluginInfo.name, ctlPlugin); - await ctlPlugin.init(); + const context = { hooks, logger, plugin: pluginInfo }; + await lib.loadPlugin(pluginInfo, "ctl", context, "CtlPlugin", BaseCtlPlugin); + ctlPlugins.add(pluginInfo); } + return ctlPlugins; } @@ -142,14 +133,14 @@ interface CtlArguments { interface InitializeParameters { args: CtlArguments; shouldRun: boolean; - ctlPlugins?: Map; + ctlHooks: CtlHooks; rootCommands?: lib.CommandTree; controlConfig?: lib.ControlConfig; } export async function initialize( argv: string | string[], - ctlPlugins?: Map, + ctlHooks: CtlHooks = new CtlHooks(logger), noLoggerTransport?: boolean, ): Promise { // Build a fresh, isolated yargs parser each time this function is called. @@ -208,23 +199,23 @@ export async function initialize( lib.handleUnhandledErrors(); } - // Discover and load plugins. This check exists to allow tests to inject plugins. - if (!ctlPlugins || args._[0] === "plugin") { + // Discover and load plugins. ctlHooks.size check exists to allow tests to inject plugins. + if (ctlHooks.addCommands.size === 0 || args._[0] === "plugin") { logger.verbose(`Loading available plugins from ${args.pluginList}`); const pluginList = await lib.loadPluginList(args.pluginList); // If the command is plugin management we don't try to load plugins if (args._[0] === "plugin") { await lib.handlePluginCommand(args, pluginList, args.pluginList); - return { args, shouldRun: false }; + return { args, ctlHooks, shouldRun: false }; } logger.verbose("Loading Plugins"); - ctlPlugins = await loadPlugins(pluginList); + await loadPlugins(pluginList, ctlHooks); } // Add all commands including from plugins and reparse with help and strict checking. - const rootCommands = await commands.registerCommands(ctlPlugins, parser); + const rootCommands = await commands.registerCommands(ctlHooks, parser); args = parser .help() .strict() @@ -261,10 +252,10 @@ export async function initialize( // Handle the control-config command before trying to connect. if (args._[0] === "control-config") { await lib.handleConfigCommand(args, controlConfig, controlConfigLock); - return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false }; + return { args, controlConfig, ctlHooks, rootCommands, shouldRun: false }; } - return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: true }; + return { args, controlConfig, ctlHooks, rootCommands, shouldRun: true }; } export function selectTargetCommand(args: CtlArguments, rootCommands: lib.CommandTree): lib.Command { @@ -281,11 +272,10 @@ async function startControl() { const { args, shouldRun, - ctlPlugins, rootCommands, controlConfig, } = await initialize(process.argv.slice(2)); - if (!shouldRun || !ctlPlugins || !rootCommands || !controlConfig) { + if (!shouldRun || !rootCommands || !controlConfig) { return; } @@ -301,7 +291,7 @@ async function startControl() { controlConfig.get("control.controller_token")!, ); - let control = new Control(controlConnector, controlConfig, ctlPlugins); + let control = new Control(controlConnector, controlConfig); try { await controlConnector.connect(); } catch (err) { diff --git a/packages/ctl/index.ts b/packages/ctl/index.ts index 0852c19b..fe5bb9da 100644 --- a/packages/ctl/index.ts +++ b/packages/ctl/index.ts @@ -1,6 +1,6 @@ import { bootstrap } from "./ctl.js"; export * from "./ctl.js"; -export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin.js"; +export { BaseCtlPlugin, CtlHooks, type CtlPluginContext } from "./src/BaseCtlPlugin.js"; if (import.meta.main) { bootstrap(); diff --git a/packages/ctl/src/BaseCtlPlugin.ts b/packages/ctl/src/BaseCtlPlugin.ts index bed7534b..6b1dbae1 100644 --- a/packages/ctl/src/BaseCtlPlugin.ts +++ b/packages/ctl/src/BaseCtlPlugin.ts @@ -1,38 +1,18 @@ -import type { CommandTree, Logger, PluginNodeEnvInfo } from "@clusterio/lib"; +import * as lib from "@clusterio/lib"; + +export type CtlPluginContext = lib.PluginLoadContext<{ + hooks: CtlHooks +}>; /** - * Base class for clusterioctl plugins - * - * Ctl plugins are subclasses of this class which get instantiated by - * clusterioctl in order to extend its functionallity. To be discovered the - * class must be exported under the name `CtlPlugin` in the module - * specified by the `ctlEntrypoint` in the plugin's `plugin` export. + * Collection of clusterioctl plugin hooks */ -export default class BaseCtlPlugin { - /** - * Logger for this plugin - * - * Instance of winston Logger for sending log messages from this - * plugin. Supported methods and their corresponding log levels are - * `error`, `warn`, `audit`, `info` and `verbose`. - */ - logger: Logger; - - constructor( - /** - * The plugin's own info module - */ - public info: PluginNodeEnvInfo, - logger: Logger, - ) { - this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger; +export class CtlHooks extends lib.AsyncHookCollection { + constructor(logger: lib.Logger) { + super(logger); + this.addCommands = this.newHook(); } - /** - * Called immediately after the class is instantiated - */ - async init() { } - /** * Called to add commands to the command line interface. * @@ -43,5 +23,47 @@ export default class BaseCtlPlugin { * @param rootCommand - * Root of the clusterioctl command tree. */ - async addCommands(rootCommand: CommandTree) { } + readonly addCommands: lib.AsyncHook<[rootCommand: lib.CommandTree]>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface BaseCtlPlugin { + addCommands?(rootCommand: lib.CommandTree): Promise; +} + +/** + * Base class for clusterioctl plugins + * + * Ctl plugins are subclasses of this class which get instantiated by + * clusterioctl in order to extend its functionallity. To be discovered the + * class must be exported under the name `CtlPlugin` in the module + * specified by the `ctlEntrypoint` in the plugin's `plugin` export. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class BaseCtlPlugin { + constructor( + /** + * The plugin's own info module + */ + public info: lib.PluginNodeEnvInfo, + public logger: lib.Logger, + private hooks: CtlHooks, + ) { + if (this.addCommands) { + hooks.addCommands.attach(info.name, this.addCommands.bind(this)); + } + } + + static fromContext(context: CtlPluginContext) { + return new this(context.plugin, context.logger, context.hooks); + } + + detachHooks() { + this.hooks.detachAll(this.info.name); + } + + /** + * Called immediately after the class is instantiated + */ + async init() {} } diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 5e31a507..3bcfdb58 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -1,7 +1,6 @@ import type { Argv } from "yargs"; import * as lib from "@clusterio/lib"; -import type BaseCtlPlugin from "./BaseCtlPlugin.js"; import { controllerCommands } from "./commands_controller.js"; import { hostCommands } from "./commands_host.js"; import { instanceCommands } from "./commands_instance.js"; @@ -12,8 +11,9 @@ import { roleCommands } from "./commands_role.js"; import { userCommands } from "./commands_user.js"; import { logCommands } from "./commands_log.js"; import { debugCommands } from "./commands_debug.js"; +import type { CtlHooks } from "./BaseCtlPlugin.js"; -export async function registerCommands(ctlPlugins: Map, yargs: Argv) { +export async function registerCommands(ctlHooks: CtlHooks, yargs: Argv) { const rootCommands = new lib.CommandTree({ name: "clusterioctl", description: "Manage cluster" }); rootCommands.add(controllerCommands); rootCommands.add(hostCommands); @@ -26,9 +26,7 @@ export async function registerCommands(ctlPlugins: Map, y rootCommands.add(logCommands); rootCommands.add(debugCommands); - for (let controlPlugin of ctlPlugins.values()) { - await controlPlugin.addCommands(rootCommands); - } + await ctlHooks.addCommands.invoke(rootCommands); for (let [name, command] of rootCommands.subCommands) { if (name === command.name) { diff --git a/packages/host/index.ts b/packages/host/index.ts index 75ef3560..c9d81c49 100644 --- a/packages/host/index.ts +++ b/packages/host/index.ts @@ -1,6 +1,6 @@ import { bootstrap } from "./host.js"; -export { default as BaseHostPlugin } from "./src/BaseHostPlugin.js"; -export { default as BaseInstancePlugin } from "./src/BaseInstancePlugin.js"; +export { BaseHostPlugin, HostHooks, type HostPluginContext } from "./src/BaseHostPlugin.js"; +export { BaseInstancePlugin, InstanceHooks, type InstancePluginContext } from "./src/BaseInstancePlugin.js"; export { default as Host, HostRouter } from "./src/Host.js"; export { default as Instance } from "./src/Instance.js"; export { default as InstanceConnection } from "./src/InstanceConnection.js"; diff --git a/packages/host/src/BaseHostPlugin.ts b/packages/host/src/BaseHostPlugin.ts index d85f741a..06afe419 100644 --- a/packages/host/src/BaseHostPlugin.ts +++ b/packages/host/src/BaseHostPlugin.ts @@ -1,45 +1,24 @@ -import type { - CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo, -} from "@clusterio/lib"; import type Host from "./Host.js"; +import * as lib from "@clusterio/lib"; + +export type HostPluginContext = lib.PluginLoadContext<{ + host: Host; +}>; + /** - * Base class for host plugins - * - * Host plugins are subclasses of this class which get instantiated by - * the host when it starts up with the plugin enabled in the config. To be - * discovered the class must be exported under the name `HostPlugin` in the - * module specified by the `hostEntrypoint` in the plugin's `plugin` export. + * Collection of host plugin hooks */ -export default class BaseHostPlugin { - /** - * Logger for this plugin - * - * Instance of winston Logger for sending log messages from this - * plugin. Supported methods and their corresponding log levels are - * `error`, `warn`, `audit`, `info` and `verbose`. - */ - logger: Logger; - - constructor( - /** - * The plugin's own info module - */ - public info: PluginNodeEnvInfo, - /** - * Host the plugin started for - */ - public host: Host, - logger: Logger, - ) { - this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger; +export class HostHooks extends lib.AsyncHookCollection { + constructor(logger: lib.Logger) { + super(logger); + this.metrics = this.newHook(); + this.shutdown = this.newHook(); + this.hostConfigFieldChanged = this.newHook(); + this.controllerConnectionEvent = this.newHook(); + this.prepareControllerDisconnect = this.newHook(); } - /** - * Called immediately after the class is instantiated - */ - async init() { } - /** * Called when the value of a host config field changed. * @@ -50,7 +29,7 @@ export default class BaseHostPlugin { * @param curr - The current value of the field. * @param prev - The previous value of the field. */ - async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) { } + readonly hostConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>; /** * Called before collecting Prometheus metrics @@ -67,12 +46,12 @@ export default class BaseHostPlugin { * * @returns an async iterator of prometheus metric results or undefined. */ - async onMetrics(): Promise> { } + readonly metrics: lib.AsyncHook<[], AsyncIterable>; /** * Called when the host is shutting down - */ - async onShutdown() { } + */ + readonly shutdown: lib.AsyncHook<[]>; /** * Called when an event on the controller connection happens @@ -108,7 +87,7 @@ export default class BaseHostPlugin { * * @param event - one of connect, drop, resume and close */ - onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { } + readonly controllerConnectionEvent: lib.AsyncHook<[event: "connect" | "drop" | "resume" | "close"]>; /** * Called when the controller is preparing to disconnect from the host @@ -123,5 +102,60 @@ export default class BaseHostPlugin { * @param connection - * The connection to the host preparing to disconnect. */ - async onPrepareControllerDisconnect(connection: Host) { } + readonly prepareControllerDisconnect: lib.AsyncHook<[connection: Host]>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface BaseHostPlugin { + onHostConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise; + onMetrics?(): Promise>; + onShutdown?(): Promise; + onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void; + onPrepareControllerDisconnect?(connection: Host): Promise; +} + +/** + * Base class for host plugins + * + * Host plugins are subclasses of this class which get instantiated by + * the host when it starts up with the plugin enabled in the config. To be + * discovered the class must be exported under the name `HostPlugin` in the + * module specified by the `hostEntrypoint` in the plugin's `plugin` export. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class BaseHostPlugin { + constructor( + public info: lib.PluginNodeEnvInfo, + public host: Host, + public logger: lib.Logger, + ) { + const attach = ( + hook: lib.AsyncHook, + fn?: lib.HookHandler, + ) => { + if (fn) { + hook.attach(info.name, fn.bind(this)); + } + }; + + attach(host.hooks.hostConfigFieldChanged, this.onHostConfigFieldChanged); + attach(host.hooks.metrics, this.onMetrics); + attach(host.hooks.shutdown, this.onShutdown); + attach(host.hooks.controllerConnectionEvent, this.onControllerConnectionEvent); + attach(host.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect); + } + + static fromContext(context: HostPluginContext): BaseHostPlugin { + const plugin = new this(context.plugin, context.host, context.logger); + return plugin; + } + + detachHooks() { + this.host.hooks.detachAll(this.info.name); + } + + /** + * Called immediately after the class is instantiated + */ + async init() {} } diff --git a/packages/host/src/BaseInstancePlugin.ts b/packages/host/src/BaseInstancePlugin.ts index 97d9efda..ac88662e 100644 --- a/packages/host/src/BaseInstancePlugin.ts +++ b/packages/host/src/BaseInstancePlugin.ts @@ -1,69 +1,30 @@ -import type { - CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo, -} from "@clusterio/lib"; import type Instance from "./Instance.js"; import type Host from "./Host.js"; +import * as lib from "@clusterio/lib"; + +export type InstancePluginContext = lib.PluginLoadContext<{ + host: Host; + instance: Instance; +}>; + /** - * Base class for instance plugins - * - * Instance plugins are subclasses of this class which get instantiated by - * the host when it brings up an instance with the plugin enabled in the - * config. To be discovered the class must be exported under the name - * `InstancePlugin` in the module specified by the `instanceEntrypoint` in - * the plugin's `plugin` export. - * - * Instances may be started and stopped many times, and many instances may - * be running at the same time, each of which will have their own instance - * of the InstancePlugin class. + * Collection of instance plugin hooks */ -export default class BaseInstancePlugin { - /** - * Logger for this plugin - * - * Instance of winston Logger for sending log messages from this - * plugin. Supported methods and their corresponding log levels are - * `error`, `warn`, `audit`, `info` and `verbose`. - */ - logger: Logger; - - private _pendingRconMessages: { - resolve: (result: string) => void, - reject: (err: Error) => void, - message: string, - expectEmpty: boolean, - }[] = []; - - private _sendingRconMessages = false; - - constructor( - /** - * The plugin's own info module - */ - public info: PluginNodeEnvInfo, - /** - * Instance the plugin started for - */ - public instance: Instance, - /** - * Host running the instance - * - * With the exepction of accessing the host's config you should - * avoid ineracting with the host object directly. - */ - public host: Host, - ) { - this.logger = instance.logger.child({ plugin: this.info.name }) as unknown as Logger; - - this._pendingRconMessages = []; - this._sendingRconMessages = false; +export class InstanceHooks extends lib.AsyncHookCollection { + constructor(logger: lib.Logger) { + super(logger); + this.metrics = this.newHook(); + this.start = this.newHook(); + this.stop = this.newHook(); + this.exit = this.newHook(); + this.output = this.newHook(); + this.instanceConfigFieldChanged = this.newHook(); + this.controllerConnectionEvent = this.newHook(); + this.prepareControllerDisconnect = this.newHook(); + this.playerEvent = this.newHook(); } - /** - * Called immediately after the class is instantiated - */ - async init() { } - /** * Called when the value of a config field changed. * @@ -74,7 +35,7 @@ export default class BaseInstancePlugin { * @param curr - The current value of the field. * @param prev - The previous value of the field. */ - async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { } + readonly instanceConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>; /** * Called before collecting Prometheus metrics @@ -91,12 +52,12 @@ export default class BaseInstancePlugin { * * @returns an async iterator of prometheus metric results or undefined. */ - async onMetrics(): Promise> { } + readonly metrics: lib.AsyncHook<[], AsyncIterable>; /** * Called after the Factorio server is started */ - async onStart() { } + readonly start: lib.AsyncHook<[]>; /** * Called before the Factorio server is stopped @@ -104,7 +65,7 @@ export default class BaseInstancePlugin { * This will not be called if for example the Factorio server crashes or * is killed. */ - async onStop() { } + readonly stop: lib.AsyncHook<[]>; /** * Called when the instance exits @@ -113,7 +74,7 @@ export default class BaseInstancePlugin { * has been called if an error occurs during startup. Note that if * the plugin's init() throws this method will still be invoked. */ - onExit() { } + readonly exit: lib.AsyncHook<[]>; /** * Called when the Factorio outputs a line @@ -125,7 +86,7 @@ export default class BaseInstancePlugin { * @param parsed - parsed server output. * @param line - raw line of server output. */ - async onOutput(parsed: ParsedFactorioOutput, line: string) { } + readonly output: lib.AsyncHook<[parsed: lib.ParsedFactorioOutput, line: string]>; /** * Called when an event on the controller connection happens @@ -161,7 +122,7 @@ export default class BaseInstancePlugin { * * @param event - one of connect, drop, resume and close */ - onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { } + readonly controllerConnectionEvent: lib.AsyncHook<[event: "connect" | "drop" | "resume" | "close"]>; /** * Called when the controller is preparing to disconnect from the host @@ -176,7 +137,7 @@ export default class BaseInstancePlugin { * @param connection - * The connection to the host preparing to disconnect. */ - async onPrepareControllerDisconnect(connection: Instance) { } + readonly prepareControllerDisconnect: lib.AsyncHook<[connection: Instance]>; /** * Called when a player joins or leaves the game @@ -185,7 +146,96 @@ export default class BaseInstancePlugin { * * @param event - Information about the event. */ - async onPlayerEvent(event: PlayerEvent) { } + readonly playerEvent: lib.AsyncHook<[event: lib.PlayerEvent]>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface BaseInstancePlugin { + onInstanceConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise; + onMetrics?(): Promise>; + onStart?(): Promise; + onStop?(): Promise; + onExit?(): void; + onOutput?(parsed: lib.ParsedFactorioOutput, line: string): Promise; + onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void; + onPrepareControllerDisconnect?(connection: Instance): Promise; + onPlayerEvent?(event: lib.PlayerEvent): Promise; +} + +/** + * Base class for instance plugins + * + * Instance plugins are subclasses of this class which get instantiated by + * the host when it brings up an instance with the plugin enabled in the + * config. To be discovered the class must be exported under the name + * `InstancePlugin` in the module specified by the `instanceEntrypoint` in + * the plugin's `plugin` export. + * + * Instances may be started and stopped many times, and many instances may + * be running at the same time, each of which will have their own instance + * of the InstancePlugin class. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class BaseInstancePlugin { + private _pendingRconMessages: { + resolve: (result: string) => void, + reject: (err: Error) => void, + message: string, + expectEmpty: boolean, + }[] = []; + + private _sendingRconMessages = false; + + constructor( + /** + * The plugin's own info module + */ + public info: lib.PluginNodeEnvInfo, + /** + * Instance the plugin started for + */ + public instance: Instance, + /** + * Host running the instance + * + * With the exepction of accessing the host's config you should + * avoid ineracting with the host object directly. + */ + public host: Host, + public logger: lib.Logger, + ) { + const attach = ( + hook: lib.AsyncHook, + fn?: lib.HookHandler, + ) => { + if (fn) { + hook.attach(info.name, fn.bind(this)); + } + }; + + attach(instance.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged); + attach(instance.hooks.metrics, this.onMetrics); + attach(instance.hooks.start, this.onStart); + attach(instance.hooks.stop, this.onStop); + attach(instance.hooks.exit, this.onExit); + attach(instance.hooks.output, this.onOutput); + attach(instance.hooks.controllerConnectionEvent, this.onControllerConnectionEvent); + attach(instance.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect); + attach(instance.hooks.playerEvent, this.onPlayerEvent); + } + + static fromContext(context: InstancePluginContext) { + return new this(context.plugin, context.instance, context.host, context.logger); + } + + detachHooks() { + this.instance.hooks.detachAll(this.info.name); + } + + /** + * Called immediately after the class is instantiated + */ + async init() {} /** * Send RCON message to instance diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 1af79a92..747d3e0e 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import path from "path"; -import events from "events"; import pidusage from "pidusage"; import semver from "semver"; import setBlocking from "set-blocking"; @@ -15,9 +14,7 @@ import { logger } from "@clusterio/lib"; import type { HostConnector } from "../host.js"; import Instance from "./Instance.js"; import InstanceConnection from "./InstanceConnection.js"; -import BaseHostPlugin from "./BaseHostPlugin.js"; - -const finished = util.promisify(stream.finished); +import { BaseHostPlugin, HostHooks } from "./BaseHostPlugin.js"; function checkRequestSaveName(name: string) { @@ -273,8 +270,10 @@ export default class Host extends lib.Link { pluginInfos: lib.PluginNodeEnvInfo[]; config: lib.HostConfig; - /** Mapping of plugin name to loaded plugin */ - plugins: Map = new Map(); + /** Hooks which plugins can attach to */ + hooks = new HostHooks(logger); + /** Plugins which are currently loaded */ + loadedPlugins: Set = new Set(); /** A map from instance id to instance connection. Only present when instance is running. */ instanceConnections = new Map(); @@ -333,7 +332,7 @@ export default class Host extends lib.Link { if (name === "host.name" || name === "host.public_address") { this.sendHostUpdate(); } - lib.invokeHook(this.plugins, "onHostConfigFieldChanged", name, curr, prev); + this.hooks.hostConfigFieldChanged.invoke(name, curr, prev); }); this.connector.on("hello", data => { @@ -385,9 +384,7 @@ export default class Host extends lib.Link { for (let instanceConnection of this.instanceConnections.values()) { instanceConnection.send(message); } - for (let plugin of this.plugins.values()) { - plugin.onControllerConnectionEvent(event); - } + this.hooks.controllerConnectionEvent.invoke(event); }); } @@ -424,6 +421,7 @@ export default class Host extends lib.Link { } async loadPlugins() { + const context = { logger, host: this }; for (let pluginInfo of this.pluginInfos) { if ( !pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint @@ -437,20 +435,16 @@ export default class Host extends lib.Link { continue; } - let HostPluginClass = BaseHostPlugin; try { - if (pluginInfo.hostEntrypoint) { - HostPluginClass = await lib.loadPluginClass( - pluginInfo.name, - path.posix.join(pluginInfo.requirePath, pluginInfo.hostEntrypoint), - "HostPlugin", - BaseHostPlugin, - ); - } + await lib.loadPlugin( + pluginInfo, + "host", + context, + "HostPlugin", + BaseHostPlugin, + ); - let hostPlugin = new HostPluginClass(pluginInfo, this, logger); - await hostPlugin.init(); - this.plugins.set(pluginInfo.name, hostPlugin); + this.loadedPlugins.add(pluginInfo); } catch (err: any) { if (err.code === "InstallationError") { @@ -922,7 +916,7 @@ export default class Host extends lib.Link { } let results = []; - let pluginResults = await lib.invokeHook(this.plugins, "onMetrics"); + let pluginResults = await this.hooks.metrics.collect(); for (let metricIterator of pluginResults) { for await (let metric of metricIterator) { results.push(metric); @@ -1154,7 +1148,7 @@ export default class Host extends lib.Link { async handlePluginListRequest(request: lib.PluginListRequest) { return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( pluginInfo, - this.plugins.has(pluginInfo.name), + this.loadedPlugins.has(pluginInfo), this.config.get(`${pluginInfo.name}.load_plugin`), )); } @@ -1215,7 +1209,7 @@ export default class Host extends lib.Link { } async prepareDisconnect() { - await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect", this); + await this.hooks.prepareControllerDisconnect.invoke(this); for (let instanceConnection of this.instanceConnections.values()) { await instanceConnection.send(new lib.PrepareControllerDisconnectRequest()); } @@ -1232,7 +1226,7 @@ export default class Host extends lib.Link { } this._shuttingDown = true; - await lib.invokeHook(this.plugins, "onShutdown"); + await this.hooks.shutdown.invoke(); for (let instanceConnection of this.instanceConnections.values()) { try { diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 755c7364..4db05ea0 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -13,7 +13,7 @@ import { FactorioServer } from "./server.js"; import { SaveModule, patch } from "./patch.js"; import { exportData } from "./export.js"; import type Host from "./Host.js"; -import BaseInstancePlugin from "./BaseInstancePlugin.js"; +import { BaseInstancePlugin, InstanceHooks } from "./BaseInstancePlugin.js"; const scriptCommands = [ "/cheat", "/editor", @@ -140,7 +140,6 @@ export default class Instance extends lib.Link { * ID of this instance, equivalenet to `instance.config.get("instance.id")`. */ readonly id: number; - plugins: Map; config: lib.InstanceConfig; logger: lib.Logger; server: FactorioServer; @@ -156,6 +155,10 @@ export default class Instance extends lib.Link { * Players currently online on the instance. */ playersOnline: Set = new Set(); + /** Hooks which plugins can attach to */ + hooks: InstanceHooks; + /** Plugins which are currently loaded */ + loadedPlugins: Set = new Set(); _host: Host; @@ -179,7 +182,6 @@ export default class Instance extends lib.Link { this._host = host; this._dir = dir; - this.plugins = new Map(); this.config = instanceConfig; this.id = this.config.get("instance.id"); @@ -189,8 +191,10 @@ export default class Instance extends lib.Link { instance_name: this.name, }); + this.hooks = new InstanceHooks(this.logger); + this._configFieldChanged = (field: string, curr: unknown, prev: unknown) => { - let hook = () => lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", field, curr, prev); + let hook = () => this.hooks.instanceConfigFieldChanged.invoke(field, curr, prev); if (field === "factorio.shutdown_timeout") { this.server.shutdownTimeoutMs = curr as number * 1000; @@ -231,7 +235,7 @@ export default class Instance extends lib.Link { this.server.on("output", (parsed, line) => { this.logger.log("server", { message: line, instance_id: this.id, parsed }); - lib.invokeHook(this.plugins, "onOutput", parsed, line); + this.hooks.output.invoke(parsed, line); }); this.server.on("error", err => { @@ -398,7 +402,7 @@ export default class Instance extends lib.Link { stats, }; this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats)); - lib.invokeHook(this.plugins, "onPlayerEvent", event); + this.hooks.playerEvent.invoke(event); } _recordPlayerLeave(name: string, reason: string) { @@ -419,7 +423,7 @@ export default class Instance extends lib.Link { stats, }; this.sendTo("controller", new lib.InstancePlayerUpdateEvent("leave", name, stats, reason)); - lib.invokeHook(this.plugins, "onPlayerEvent", event); + this.hooks.playerEvent.invoke(event); } async handleInstanceExtractPlayersRequest() { @@ -450,7 +454,7 @@ end`.replace(/\r?\n/g, " "); stats, }; this.sendTo("controller", new lib.InstancePlayerUpdateEvent("import", name, stats)); - lib.invokeHook(this.plugins, "onPlayerEvent", event); + this.hooks.playerEvent.invoke(event); count += 1; } this.logger.info(`Extracted data for ${count} player(s)`); @@ -588,9 +592,7 @@ end`.replace(/\r?\n/g, " "); } // Notify plugins of exit - for (let pluginInstance of this.plugins.values()) { - pluginInstance.onExit(); - } + this.hooks.exit.invoke(); for (let player of this.playersOnline) { this._recordPlayerLeave(player, "server_quit"); @@ -598,21 +600,6 @@ end`.replace(/\r?\n/g, " "); this._saveStats().catch(err => this.logger.error(`Error saving stats:\n${err.stack}`)); } - async _loadPlugin(pluginInfo: lib.PluginNodeEnvInfo, host: Host) { - let pluginLoadStartedMs = Date.now(); - let InstancePluginClass = await lib.loadPluginClass( - pluginInfo.name, - path.posix.join(pluginInfo.requirePath, pluginInfo.instanceEntrypoint!), - "InstancePlugin", - BaseInstancePlugin, - ); - let instancePlugin = new InstancePluginClass(pluginInfo, this, host); - this.plugins.set(pluginInfo.name, instancePlugin); - await instancePlugin.init(); - - this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStartedMs}ms`); - } - async _loadStats() { let instanceStats; try { @@ -667,6 +654,7 @@ end`.replace(/\r?\n/g, " "); } // load plugins + const plugins: Record = {}; for (let pluginInfo of pluginInfos) { if ( !pluginInfo.instanceEntrypoint @@ -682,7 +670,18 @@ end`.replace(/\r?\n/g, " "); } try { - await this._loadPlugin(pluginInfo, this._host); + let pluginLoadStartedMs = Date.now(); + await lib.loadPlugin( + pluginInfo, + "instance", + { logger: this.logger, host: this._host, instance: this }, + "InstancePlugin", + BaseInstancePlugin, + ); + + this.loadedPlugins.add(pluginInfo); + plugins[pluginInfo.name] = pluginInfo.version; + this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStartedMs}ms`); } catch (err) { this.notifyExit(); await this.sendSaveListUpdate(); @@ -690,10 +689,6 @@ end`.replace(/\r?\n/g, " "); } } - let plugins: Record = {}; - for (let [name, plugin] of this.plugins) { - plugins[name] = plugin.info.version; - } this.send(new lib.InstanceInitialisedEvent(plugins)); } @@ -953,8 +948,8 @@ end`.replace(/\r?\n/g, " "); // Find plugin modules to patch in let modules: Map = new Map(); - for (let plugin of this.plugins.values()) { - let module = await SaveModule.fromPlugin(plugin); + for (const pluginInfo of this.loadedPlugins) { + let module = await SaveModule.fromPluginInfo(pluginInfo); if (!module) { continue; } @@ -1008,7 +1003,7 @@ end`.replace(/\r?\n/g, " "); } await this.sendSaveListUpdate(); - await lib.invokeHook(this.plugins, "onStart"); + await this.hooks.start.invoke(); this.notifyStatus("running"); } @@ -1033,7 +1028,7 @@ end`.replace(/\r?\n/g, " "); await this.server.startScenario(scenario, seed, mapGenSettings, mapSettings); this._watchServerLogActions(); - await lib.invokeHook(this.plugins, "onStart"); + await this.hooks.start.invoke(); this.notifyStatus("running"); } @@ -1169,7 +1164,7 @@ end`.replace(/\r?\n/g, " "); // XXX this needs more thought to it if (this.server._state === "running") { - await lib.invokeHook(this.plugins, "onStop"); + await this.hooks.stop.invoke(); await this.server.stop(); await this.sendSaveListUpdate(); } else if ( @@ -1188,17 +1183,17 @@ end`.replace(/\r?\n/g, " "); } async handleControllerConnectionEvent(event: lib.ControllerConnectionEvent) { - await lib.invokeHook(this.plugins, "onControllerConnectionEvent", event.event); + await this.hooks.controllerConnectionEvent.invoke(event.event); } async handlePrepareControllerDisconnectRequest() { - await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect", this); + await this.hooks.prepareControllerDisconnect.invoke(this); } async handleInstanceMetricsRequest() { let results: ReturnType[] = []; if (!["stopped", "stopping"].includes(this._status)) { - let pluginResults = await lib.invokeHook(this.plugins, "onMetrics"); + let pluginResults = await this.hooks.metrics.collect(); for (let metricIterator of pluginResults) { for await (let metric of metricIterator) { results.push(lib.serializeResult(metric)); diff --git a/packages/host/src/patch.ts b/packages/host/src/patch.ts index af89e6b0..2904a8b9 100644 --- a/packages/host/src/patch.ts +++ b/packages/host/src/patch.ts @@ -9,9 +9,6 @@ import { Type, Static } from "@sinclair/typebox"; import * as lib from "@clusterio/lib"; -import BaseInstancePlugin from "./BaseInstancePlugin.js"; - - /** * Describes a module that can be patched into a save */ @@ -90,8 +87,8 @@ export class SaveModule { return module; } - static async fromPlugin(plugin: BaseInstancePlugin) { - let moduleDirectory = path.join(path.dirname(plugin.info.packagePath), "module"); + static async fromPluginInfo(pluginInfo: lib.PluginNodeEnvInfo) { + let moduleDirectory = path.join(path.dirname(pluginInfo.packagePath), "module"); try { await fs.access(moduleDirectory); } catch (err: any) { @@ -102,17 +99,17 @@ export class SaveModule { let moduleJson; try { moduleJson = { - name: plugin.info.name, - version: plugin.info.version, + name: pluginInfo.name, + version: pluginInfo.version, dependencies: { "clusterio": "*" }, ...JSON.parse(await fs.readFile(moduleJsonPath, "utf8")), }; } catch (err: any) { - throw new Error(`Loading module/module.json in plugin ${plugin.info.name} failed: ${err.message}`); + throw new Error(`Loading module/module.json in plugin ${pluginInfo.name} failed: ${err.message}`); } if (!lib.ModuleInfo.validate(moduleJson)) { throw new Error( - `module/module.json in plugin ${plugin.info.name} failed validation:\n` + + `module/module.json in plugin ${pluginInfo.name} failed validation:\n` + `${JSON.stringify(lib.ModuleInfo.validate.errors, null, "\t")}` ); } diff --git a/packages/lib/browser.ts b/packages/lib/browser.ts index 624196fb..153a0370 100644 --- a/packages/lib/browser.ts +++ b/packages/lib/browser.ts @@ -1,4 +1,5 @@ export * from "./src/api"; +export * from "./src/AsyncHook"; export * from "./src/config"; export * from "./src/data"; export * from "./src/errors"; @@ -10,7 +11,7 @@ export * from "./src/link"; export * from "./src/lua_tools"; export * from "./src/logging"; export * from "./src/permissions"; -export * from "./src/plugin"; +export * from "./src/loadPlugin"; export * from "./src/schema"; export * from "./src/subscriptions"; diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 116f289c..c6ffa92d 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -23,6 +23,7 @@ export * from "./src/lua_tools.js"; export * from "./src/permissions.js"; export * from "./src/plugin.js"; export * from "./src/plugin_loader.js"; +export * from "./src/loadPlugin.js"; export * from "./src/prometheus.js"; export * from "./src/schema.js"; export * from "./src/shared_commands.js"; @@ -34,6 +35,7 @@ export * from "./src/datastore.js"; export * from "./src/load_plugin_list.js"; export * from "./src/rce_ops.js"; export * from "./src/LockFile.js"; +export * from "./src/AsyncHook.js"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff.js"; export { default as ModStore } from "./src/ModStore.js"; diff --git a/packages/lib/src/AsyncHook.ts b/packages/lib/src/AsyncHook.ts new file mode 100644 index 00000000..a0b8274c --- /dev/null +++ b/packages/lib/src/AsyncHook.ts @@ -0,0 +1,184 @@ +import { logger } from "./logging.js"; +import * as libHelpers from "./helpers.js"; + +export type HookHandler = (...args: Args) => Result | Promise; + +export class AsyncHook< + Args extends unknown[], + Result = void, +> { + private static readonly _timeoutToken = Symbol("timeout"); + private _handlers = new Map>(); + + constructor( + private _logger = logger, + private _timeoutMs = 15_000, + ) { } + + get size() { + return this._handlers.size; + } + + get attached() { + return this._handlers.keys(); + } + + /** + * Stable event listener reference bound to this hook instance. + * + * This function is pre-bound and guaranteed to remain referentially stable + * for the lifetime of the hook instance. It can safely be passed directly + * into event emitters (e.g. EventEmitter.on / off) without losing context + * or requiring re-binding. + * + * Example: + * `eventEmitter.on("change", hook.listener);` + */ + readonly listener = this.invoke.bind(this); + + /** + * Attach a handler to this hook. + * + * An error is thrown if another handler with the same name is already attached. + */ + attach( + name: string, + handler: HookHandler, + ) { + if (this._handlers.has(name)) { + throw new Error(`Handler with name ${name} is already attached`); + } + this._handlers.set(name, handler); + } + + /** + * Remove a previously attached handler. + * + * If no handler exists, then no action is performed. + */ + detach(name: string) { + this._handlers.delete(name); + } + + /** + * Remove all attached handlers. + */ + clear() { + this._handlers.clear(); + } + + private async _invokeHandler( + name: string, + handler: HookHandler, + args: Args, + ): Promise { + try { + const result = await libHelpers.timeout< + Result | typeof AsyncHook._timeoutToken + >( + Promise.resolve(handler(...args)), + this._timeoutMs, + AsyncHook._timeoutToken, + ); + + if (result === AsyncHook._timeoutToken) { + throw new Error(`Hook ${name} timed out after ${this._timeoutMs}ms`); + } + + return result; + + } catch (err: any) { + this._logger.error(`Ignoring error in hook "${name}":\n${err?.stack ?? err}`); + return undefined; + } + } + + /** + * Invoke all handlers attached to this hook. + * + * Any return values are discarded. + */ + async invoke(...args: Args): Promise { + await Promise.all( + [...this._handlers.entries()].map( + ([name, handler]) => this._invokeHandler(name, handler, args) + ) + ); + } + + /** + * Invoke all handlers attached to this hook. + * + * Returns all successful results. + */ + async collect(...args: Args): Promise { + const results = await Promise.all( + [...this._handlers.entries()].map( + ([name, handler]) => this._invokeHandler(name, handler, args) + ) + ); + + return results.filter( + (result): result is Awaited => result !== undefined + ); + } + + /** + * Invoke all handlers attached to this hook. + * + * Returns all successful results and their source. + */ + async collectEntries(...args: Args): Promise<[string, Result][]> { + const results = await Promise.all( + [...this._handlers.entries()].map(async ([name, handler]) => { + const result = await this._invokeHandler(name, handler, args); + return result !== undefined ? [name, result] as [string, Result] : undefined; + }) + ); + + return results.filter( + (entry): entry is [string, Result] => entry !== undefined + ); + } +} + +export class AsyncHookCollection { + private hooks: AsyncHook[] = []; + + constructor( + private _logger = logger, + ) {} + + protected newHook< + Args extends unknown[], + Result = void, + > ( + _logger = this._logger + ) { + const hook = new AsyncHook(_logger); + this.hooks.push(hook); + return hook; + } + + get size() { + const attached = new Set(this.hooks.flatMap(hook => [...hook.attached])); + return attached.size; + } + + get attached() { + const attached = new Set(this.hooks.flatMap(hook => [...hook.attached])); + return attached.values(); + } + + detachAll(name: string) { + for (const hook of this.hooks) { + hook.detach(name); + } + } + + clearAll() { + for (const hook of this.hooks) { + hook.clear(); + } + } +} diff --git a/packages/lib/src/data/messages_plugin.ts b/packages/lib/src/data/messages_plugin.ts index 2c2a114c..2bce3ab1 100644 --- a/packages/lib/src/data/messages_plugin.ts +++ b/packages/lib/src/data/messages_plugin.ts @@ -1,6 +1,6 @@ import { Type, Static } from "@sinclair/typebox"; import { jsonArray } from "./composites.js"; -import { PluginNodeEnvInfo } from "../plugin.js"; +import type { PluginNodeEnvInfo } from "../plugin.js"; /* This is similar to other plugin definitions but with lots removed */ export class PluginDetails { diff --git a/packages/lib/src/loadPlugin.ts b/packages/lib/src/loadPlugin.ts new file mode 100644 index 00000000..7a6f0a87 --- /dev/null +++ b/packages/lib/src/loadPlugin.ts @@ -0,0 +1,54 @@ +// Contained in its own file to allow use within web build +import type { PluginDeclaration, PluginType, PluginLoadContext, PluginClass } from "./plugin.js"; + + +export async function loadPluginEntrypoint< + Context extends object, + Info extends PluginDeclaration +>( + pluginInfo: Info, + pluginType: PluginType, + context: PluginLoadContext, + module: Record +) { + const init = module.default; + + if (typeof init !== "function") { + throw new Error(`Expected ${pluginType} plugin ${pluginInfo.name} to export a default function`); + } + + await init(context); +} + +export async function loadPluginClass< + Context extends object, + Class extends PluginClass, + Info extends PluginDeclaration +>( + pluginInfo: Info, + pluginType: PluginType, + context: PluginLoadContext, + module: Record, + exportName: string, + baseClass: Class +) { + const pluginClass = module[exportName]; + + if (typeof pluginClass !== "function") { + throw new Error(`Expected ${pluginType} plugin ${pluginInfo.name} to export a class named ${exportName}`); + } + + if (!(pluginClass.prototype instanceof baseClass)) { + throw new Error(`Expected ${exportName} exported from ${pluginInfo.name} to extend ${baseClass.name}`); + } + + const plugin = (pluginClass as Class).fromContext(context); + try { + await plugin.init(); + } catch (err) { + plugin.detachHooks(); + throw err; + } + + return plugin as InstanceType; +} diff --git a/packages/lib/src/plugin.ts b/packages/lib/src/plugin.ts index 0f88f43a..f406e12f 100644 --- a/packages/lib/src/plugin.ts +++ b/packages/lib/src/plugin.ts @@ -2,7 +2,6 @@ * Plugin interfaces and utilities. * @module lib/plugin */ -import * as libHelpers from "./helpers.js"; import type { Logger } from "./logging.js"; import type { FieldDefinition } from "./config/index.js"; import type { PermissionDefinition } from "./permissions.js"; @@ -90,42 +89,24 @@ export interface PlayerEvent { stats: PlayerStats, } -/** - * Invokes the given hook on all plugins - * - * @param plugins - - * Mapping of plugin names to plugins to invoke the hook on. - * @param hook - Name of hook to invoke. - * @param args - Arguments to pass on to the hook. - * @returns Non-undefined return values from the hooks. - */ -export async function invokeHook< - Hook extends string, - R, - Args extends [...any], - Plugin extends { logger: Logger } & Record R | Promise> ->( - plugins: Map, - hook: Hook, - ...args: Args -): Promise>, void>[]> { - let results: any[] = []; - for (let [name, plugin] of plugins) { - try { - const timeout = Symbol("timeout-token"); - let result = await libHelpers.timeout( - plugin[hook](...args) as Promise, - 15000, - timeout - ); - if (result === timeout) { - throw new Error(`Invoking hook ${hook} timed out for plugin ${name}`); - } else if (result !== undefined) { - results.push(result); - } - } catch (err: any) { - plugin.logger.error(`Ignoring error from plugin ${name} in ${hook}:\n${err.stack}`); - } - } - return results; -} +export type PluginLoadContext< + Context extends object, + Info extends PluginDeclaration = PluginNodeEnvInfo +> = Context & { + logger: Logger; + plugin: Info; +}; + +export type PluginClass< + Context extends object, + Info extends PluginDeclaration, +> = { + new (...args: any[]): any; + fromContext(context: PluginLoadContext): { + init(): Promise; + detachHooks(): void; + }; +}; + +export type PluginType = + Extract extends `${infer P}Entrypoint` ? P : never; diff --git a/packages/lib/src/plugin_loader.ts b/packages/lib/src/plugin_loader.ts index 7578e0f7..818efff1 100644 --- a/packages/lib/src/plugin_loader.ts +++ b/packages/lib/src/plugin_loader.ts @@ -8,7 +8,8 @@ import fs from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import * as libErrors from "./errors.js"; import * as libPlugin from "./plugin.js"; -import { logger } from "./logging.js"; +import { type Logger, logger } from "./logging.js"; +import { loadPluginEntrypoint, loadPluginClass } from "./loadPlugin.js"; /** @@ -88,22 +89,41 @@ export async function loadPluginInfos(pluginList: Map) { return plugins; } -export async function loadPluginClass( - pluginName: string, - requirePath: string, - className: string, - pluginClass: Class, -): Promise { - let entrypoint = await import(pathToFileURL(requirePath).href); - if (!entrypoint[className]) { - throw new libErrors.PluginError(pluginName, - new Error(`Expected ${requirePath} to export a class named ${className}`) - ); +export async function loadPlugin< + Context extends { logger: Logger }, + Class extends libPlugin.PluginClass, +> ( + pluginInfo: libPlugin.PluginNodeEnvInfo, + pluginType: libPlugin.PluginType, + context: Context, + exportName: `${string}Plugin`, + baseClass: Class, +) { + const entrypoint = `${pluginType}Entrypoint` as const; + const requirePath = pluginInfo[entrypoint]; + + if (!requirePath) { + return; } - if (!(entrypoint[className].prototype instanceof pluginClass)) { - throw new libErrors.PluginError(pluginName, - new Error(`Expected ${className} exported from ${requirePath} to be a subclass of ${pluginClass.name}`) - ); + + const module = await import(pathToFileURL(path.posix.join(pluginInfo.requirePath, requirePath)).href); + const pluginContext: libPlugin.PluginLoadContext = { + ...context, + plugin: pluginInfo, + logger: context.logger.child({ plugin: pluginInfo.name }), + }; + + if (typeof module.default === "function") { + await loadPluginEntrypoint(pluginInfo, pluginType, pluginContext, module); + return; } - return entrypoint[className]; + + // migrate: accept plugins which export classes + if (module[exportName]) { + pluginContext.logger.warn(`Plugin ${pluginInfo.name} is using deprecated class export`); + await loadPluginClass(pluginInfo, pluginType, pluginContext, module, exportName, baseClass); + return; + } + + throw new Error(`Plugin ${pluginInfo.name} must export either a default function or ${exportName} class`); } diff --git a/packages/lib/src/rce_ops.ts b/packages/lib/src/rce_ops.ts index 92f32adc..d42ed3e7 100644 --- a/packages/lib/src/rce_ops.ts +++ b/packages/lib/src/rce_ops.ts @@ -3,7 +3,7 @@ import path from "path"; import { exec } from "child_process"; import { logger } from "./logging.js"; import { RequestError } from "./errors.js"; -import { PluginNodeEnvInfo } from "./plugin.js"; +import type { PluginNodeEnvInfo } from "./plugin.js"; const execAsync = util.promisify(exec); function isDev() { diff --git a/packages/lib/tsconfig.browser.json b/packages/lib/tsconfig.browser.json index f90faa99..4b6c539b 100644 --- a/packages/lib/tsconfig.browser.json +++ b/packages/lib/tsconfig.browser.json @@ -4,6 +4,7 @@ "browser.ts", "package.json", "./src/api.ts", + "./src/AsyncHook.ts", "./src/config/*.ts", "./src/data/*.ts", "./src/external/*.ts", @@ -21,6 +22,7 @@ "./src/logging.ts", "./src/permissions.ts", "./src/prometheus.ts", + "./src/loadPlugin.ts", "./src/plugin.ts", "./src/schema.ts", "./src/subscriptions.ts", diff --git a/packages/web_ui/global.d.ts b/packages/web_ui/global.d.ts index 90fd88c1..e9d5b8c7 100644 --- a/packages/web_ui/global.d.ts +++ b/packages/web_ui/global.d.ts @@ -3,6 +3,11 @@ declare module "*.png" { export default value; } +declare module "*.css" { + const value: string; + export default value; +} + declare module "*.svg" { const value: string; export default value; diff --git a/packages/web_ui/src/BaseWebPlugin.ts b/packages/web_ui/src/BaseWebPlugin.ts index 35978615..a55a5920 100644 --- a/packages/web_ui/src/BaseWebPlugin.ts +++ b/packages/web_ui/src/BaseWebPlugin.ts @@ -1,6 +1,6 @@ import type React from "react"; -import type { AccountRole, FieldDefinition, Logger, PermissionName, PluginWebpackEnvInfo } from "@clusterio/lib"; import type { Control } from "./util/websocket"; +import * as lib from "@clusterio/lib"; /** * Plugin supplied login form @@ -23,25 +23,17 @@ export interface PluginLoginForm { Component: React.ComponentType<{ setToken(token: string): void }>; } -export interface InputComponentProps { - disabled?: boolean, - fieldDefinition: FieldDefinition, - value: null | boolean | number | string, - onChange: (value: null | boolean | number | string) => void, -} -export type InputComponent = React.ComponentType; - export type UserAccount = { /** Name of the currently logged in account. */ name: string; - /** Roles of the corrently logged in account. */ - roles: AccountRole[]; + /** Roles of the currently logged in account. */ + roles: lib.AccountRole[]; /** Check if the currently logged in account has the given permission. */ - hasPermission: (permission: PermissionName) => boolean | null; + hasPermission: (permission: lib.PermissionName) => boolean | null; /** Check if the currently logged in account has any of the given permissions. */ - hasAnyPermission: (...permissions: PermissionName[]) => boolean | null; + hasAnyPermission: (...permissions: lib.PermissionName[]) => boolean | null; /** Check if the currently logged in account has all of given permissions. */ - hasAllPermission: (...permissions: PermissionName[]) => boolean | null; + hasAllPermission: (...permissions: lib.PermissionName[]) => boolean | null; /** Logs out of the web interface. */ logOut: () => void; }; @@ -75,109 +67,91 @@ export interface PluginPage { /** * Permission to access page. function are expected to throw an error if access is deny. */ - permission?: PermissionName | ((account: UserAccount) => (boolean|null)); + permission?: lib.PermissionName | ((account: UserAccount) => (boolean|null)); }; -/** - * Base class for web interface plugins - */ -export default class BaseWebPlugin { +export interface InputComponentProps { + disabled?: boolean, + fieldDefinition: lib.FieldDefinition, + value: null | boolean | number | string, + onChange: (value: null | boolean | number | string) => void, +} + +export type InputComponent = React.ComponentType; + +export type ExtensionSlotProps = { + /** Placed at the end of the overview page. */ + OverviewPage: Record; + /** Placed at the end of the controller page. */ + ControllerPage: Record; + /** Placed at the end of the hosts list page. */ + HostsPage: Record; /** - * Contents of the plugin's package.json file + * Placed at the end of each host page. + * `host` is the host the page is displayed for. */ + HostViewPage: { + host: lib.HostDetails; + }; + /** Placed at the end of the instance list page. */ + InstancesPage: Record; + /** + * Placed at the end of each instance page. + * `instance` is the instance the page is displayed for. + */ + InstanceViewPage: { + instance: lib.InstanceDetails; + }; + /** Placed at the end of the users list page. */ + UsersPage: Record; + /** + * Placed at the end of each user page. + * `user` is the user the page is displayed for. + */ + UserViewPage: { + user: lib.UserDetails; + }; + /** Placed at the end of the mods list page */ + ModsPage: Record; + /** Placed at the end of the roles list page. */ + RolesPage: Record; + /** + * Placed at the end of each role page. + * `role` is the role the page is displayed for. + */ + RoleViewPage: { + role: lib.Role; + search?: string; + }; +}; + +export type PluginExtensionSlot = keyof ExtensionSlotProps; + +export type PluginExtensionProps = ExtensionSlotProps[K] & { component: K }; + +export type ExtensionComponent = React.ComponentType>; + +export type ExtensionComponents = { [K in PluginExtensionSlot]: Map> }; + +export type WebPluginContext = lib.PluginLoadContext<{ + control: Control; + container: any; package: any; - /** - * Logger for this plugin - * - * Instance of winston Logger for sending log messages from this - * plugin. Supported methods and their corresponding log levels are - * `error`, `warn`, `audit`, `info` and `verbose`. - */ - logger: Logger; - /** - * List of login forms provided by this plugin - */ - loginForms: PluginLoginForm[] = []; - /** - * List of pages provided by this plugin - */ - pages: PluginPage[] = []; - /** - * Additional Config inputComponent types available to render config - * entries with. - */ - inputComponents: Record = {}; - /** - * Extra react component to add to core components - * - * Interface to augment core components of the web UI. Setting a - * component as one of the supported properties of this object will - * cause the web UI to render it when displaying that component, - * usually at the end. Each component will receive a `plugin` param - * which is the instance of the web plugin that contained the - * component extra. - */ - componentExtra: { - /** Placed at the end of the controller page. */ - ControllerPage?: React.ComponentType, - /** Placed at the end of the hosts list page. */ - HostsPage?: React.ComponentType, - /** - * Placed at the end of each host page. Takes a `host` param which - * is the host the page is displayed for. - */ - HostViewPage?: React.ComponentType, - /** Placed at the end of the instance list page. */ - InstancesPage?: React.ComponentType, - /** - * Placed at the end of each instance page. Takes an `instance` - * param which is the instance the page is displayed for. - */ - InstanceViewPage?: React.ComponentType, - /** Placed at the end of the users list page. */ - UsersPage?: React.ComponentType, - /** - * Placed at the end of each user page. Takes a `user` param which - * is the user object the page is displayed for. - */ - UserViewPage?: React.ComponentType, - /** Placed at the end of the roles list page. */ - RolesPage?: React.ComponentType, - /** - * Placed at the end of each role page. Takes a `role` param which - * is the role object the page is displayed for. - */ - RoleViewPage?: React.ComponentType, - } = {}; +}, lib.PluginWebpackEnvInfo>; - - constructor( - /** - * Webpack container for this plugin - */ - public container: any, - packageData: any, - /** - * The plugin's own info module - */ - public info: PluginWebpackEnvInfo, - /** - * Control link to the controller - * - * Not connected at the time init is invoked. - */ - public control: Control, - logger: Logger, - ) { - this.package = packageData; - this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger; +/** + * Collection of web plugin hooks + */ +export class WebHooks extends lib.AsyncHookCollection { + constructor(logger: lib.Logger) { + super(logger); + this.controllerConnectionEvent = this.newHook(); + this.extensionComponents = this.newHook(); + this.inputComponents = this.newHook(); + this.loginForms = this.newHook(); + this.pages = this.newHook(); } - /** - * Called immediately after the class is instantiated. - */ - async init() { } - /** * Called when an event on the controller connection happens * @@ -212,5 +186,99 @@ export default class BaseWebPlugin { * * @param event - one of connect, drop, resume and close */ - onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { } + readonly controllerConnectionEvent: lib.AsyncHook<[event: "connect" | "drop" | "resume" | "close"]>; + + /** + * Collect additional UI components to render in core ui extension points. + * + * Each plugin may return a partial mapping of extension slot names + * to React components. All returned components are rendered. + * + * Multiple plugins may contribute to the same slot. + */ + readonly extensionComponents: lib.AsyncHook<[], Partial<{ + [K in PluginExtensionSlot]: ExtensionComponent; + }>>; + + /** + * Collect additional config input components provided by plugins. + * + * Each plugin may return a mapping of input component names to React components. + * These mappings are merged together to form the final registry. + * + * If multiple plugins define the same key, the last attached handler wins. + */ + readonly inputComponents: lib.AsyncHook<[], Record>; + + /** + * Additional methods used to login to the web ui. + * + * All returned components are rendered. + * Each must has a unique name prefixed with the plugin name. + */ + readonly loginForms: lib.AsyncHook<[], PluginLoginForm[]>; + + /** + * Additional pages accessible on the web ui. + * + * Each page must have a unique path. + */ + readonly pages: lib.AsyncHook<[], PluginPage[]>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface BaseWebPlugin { + onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void; +} + +/** + * Base class for web interface plugins + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class BaseWebPlugin { + /** + * Contents of the plugin's package.json file + */ + package: any; + + pages: PluginPage[] = []; + loginForms: PluginLoginForm[] = []; + inputComponents: Record = {}; + componentExtra: Partial<{ + [K in PluginExtensionSlot]: ExtensionComponent; + }> = {}; + + constructor( + public container: any, + packageData: any, + public info: lib.PluginWebpackEnvInfo, + public control: Control, + public logger: lib.Logger, + ) { + this.package = packageData; // strict mode complains if we don't assign it this way + + if (this.onControllerConnectionEvent) { + control.hooks.controllerConnectionEvent.attach(info.name, this.onControllerConnectionEvent.bind(this)); + } + + // Subclasses populate these fields in init(), which runs after the + // constructor, so the handlers read them when the hook is collected. + control.hooks.inputComponents.attach(info.name, () => this.inputComponents); + control.hooks.extensionComponents.attach(info.name, () => this.componentExtra); + control.hooks.loginForms.attach(info.name, () => this.loginForms); + control.hooks.pages.attach(info.name, () => this.pages); + } + + static fromContext(context: WebPluginContext): BaseWebPlugin { + return new this(context.container, context.package, context.plugin, context.control, context.logger); + } + + detachHooks() { + this.control.hooks.detachAll(this.info.name); + } + + /** + * Called immediately after the class is instantiated. + */ + async init() {} } diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index 74359367..710dc76a 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -6,12 +6,13 @@ import { createRoot } from "react-dom/client"; import * as lib from "@clusterio/lib"; import App from "./components/App"; -import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin"; import InputRole from "./components/InputRole"; import InputModPack from "./components/InputModPack"; import { InputTargetVersion, InputPartialVersion, InputFullVersion } from "./components/InputVersion"; import { Control, ControlConnector } from "./util/websocket"; import { loadedPluginSetKey } from "./util/pluginSet"; +import * as WebPlugin from "./BaseWebPlugin"; +import { pages } from "./pages"; const { ConsoleTransport, WebConsoleFormat, logger } = lib; @@ -78,26 +79,44 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]> return [pluginInfos, pluginSetKey]; } -async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Control) { - let plugins = new Map(); - for (let pluginInfo of pluginInfos) { - if (!pluginInfo.enabled) { +async function loadPlugins( + pluginInfos: lib.PluginWebpackEnvInfo[], + control: Control +) { + const plugins = new Map(); + + for (const pluginInfo of pluginInfos) { + if (!pluginInfo.enabled || !pluginInfo.webEntrypoint) { continue; } + try { - let WebPluginClass = BaseWebPlugin; - if (pluginInfo.webEntrypoint) { - let webModule = (await pluginInfo.container.get(pluginInfo.webEntrypoint))(); - if (!webModule.WebPlugin) { - pluginInfo.error = "Plugin webEntrypoint does not export WebPlugin class"; - throw new Error(pluginInfo.error); - } - WebPluginClass = webModule.WebPlugin; + const moduleFactory = await pluginInfo.container.get(pluginInfo.webEntrypoint); + const webModule = moduleFactory(); + + const pluginContext: WebPlugin.WebPluginContext = { + control, + plugin: pluginInfo, + container: pluginInfo.container, + package: pluginInfo.package, + logger: logger.child({ plugin: pluginInfo.name }), + }; + + if (typeof webModule.default === "function") { + await lib.loadPluginEntrypoint(pluginInfo, "web", pluginContext, webModule); + continue; } - let plugin = new WebPluginClass(pluginInfo.container, pluginInfo.package, pluginInfo, control, logger); - await plugin.init(); - plugins.set(pluginInfo.name, plugin); + // migrate: accept plugins which export classes + if (webModule.WebPlugin) { + logger.warn(`Plugin ${pluginInfo.name} is using deprecated class hooks`); + plugins.set(pluginInfo.name, await lib.loadPluginClass( + pluginInfo, "web", pluginContext, webModule, "WebPlugin", WebPlugin.BaseWebPlugin + )); + continue; + } + + throw new Error(`Plugin ${pluginInfo.name} must export either a default function or WebPlugin`); } catch (err: any) { pluginInfo.error = `Error loading plugin: ${err.message}`; @@ -107,26 +126,80 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con } } } + return plugins; } -function inputComponentsFromPlugins(plugins: Map) { - const inputComponents: Record = { +function mergeWithWarning( + entries: Iterable<[string, Iterable]>, + getKey: (item: V) => string, + label: string +): V[] { + const map = new Map(); + const sources = new Map(); + + for (const [source, items] of entries) { + for (const item of items) { + const key = getKey(item); + if (sources.has(key)) { + lib.logger.warn( + `Plugin ${source} is redefining ${label} "${key}" previously defined by ${sources.get(key)}` + ); + } + + sources.set(key, source); + map.set(key, item); + } + } + + return [...map.values()]; +} + +async function inputComponentsFromHooks(control: Control) { + const result = await control.hooks.inputComponents.collectEntries(); + + result.unshift(["core", { "full_version": InputFullVersion, "partial_version": InputPartialVersion, "target_version": InputTargetVersion, "mod_pack": InputModPack, "role": InputRole, - }; - for (let [pluginName, plugin] of plugins) { - for (const [name, Component] of Object.entries(plugin.inputComponents)) { - if (Object.prototype.hasOwnProperty.call(inputComponents, name)) { - lib.logger.warn(`Plugin ${pluginName} is redefining config inputComponent ${name}`); + }]); + + const iterable = result.map( + v => [v[0], Object.entries(v[1])] as [string, Iterable<[string, WebPlugin.InputComponent]>] + ); + + return new Map(mergeWithWarning(iterable, ([key]) => key, "input component")); +} + +async function extensionComponentsFromHooks(control: Control) { + const extensionComponents = {} as WebPlugin.ExtensionComponents; + + const result = await control.hooks.extensionComponents.collectEntries(); + for (const [source, record] of result) { + for (const [slot, Component] of Object.entries(record) as [ + WebPlugin.PluginExtensionSlot, React.ComponentType, + ][]) { + if (!extensionComponents[slot]) { + extensionComponents[slot] = new Map(); } - inputComponents[name] = Component; + extensionComponents[slot].set(source, Component as any); } } - return inputComponents; + + return extensionComponents; +} + +async function loginFormsFromHooks(control: Control) { + const results = await control.hooks.loginForms.collectEntries(); + return mergeWithWarning(results, item => item.name, "login form"); +} + +async function pagesFromHooks(control: Control) { + const results = await control.hooks.pages.collectEntries(); + results.unshift(["core", pages]); + return mergeWithWarning(results, item => item.path, "page"); } export default async function bootstrap() { @@ -134,16 +207,24 @@ export default async function bootstrap() { level: "verbose", format: new WebConsoleFormat(), })); - let [pluginInfos, pluginSetKey] = await loadPluginInfos(); + const [pluginInfos, pluginSetKey] = await loadPluginInfos(); + const pluginInfoEntries = pluginInfos.map(p => [p.name, p] as const); lib.registerPluginMessages(pluginInfos); lib.registerPluginPermissions(pluginInfos); lib.addPluginConfigFields(pluginInfos); let wsUrl = new URL(webRoot, document.location.href); let controlConnector = new ControlConnector(wsUrl.href, 120); - let control = new Control(controlConnector, new Map(pluginInfos.map(p => [p.name, p])), pluginSetKey); + let control = new Control(controlConnector, new Map(pluginInfoEntries), pluginSetKey); control.plugins = await loadPlugins(pluginInfos, control); - control.inputComponents = inputComponentsFromPlugins(control.plugins); + control.loadedPlugins = new Map(pluginInfoEntries.filter( + ([_, info]) => info.enabled && info.webEntrypoint && !info.error + )); + + control.inputComponents = await inputComponentsFromHooks(control); + control.extensionComponents = await extensionComponentsFromHooks(control); + control.loginForms = await loginFormsFromHooks(control); + control.pages = await pagesFromHooks(control); const root = createRoot(document.getElementById("root") as HTMLDivElement); root.render(); diff --git a/packages/web_ui/src/components/BaseConfigTree.tsx b/packages/web_ui/src/components/BaseConfigTree.tsx index 744ce51d..058487ce 100644 --- a/packages/web_ui/src/components/BaseConfigTree.tsx +++ b/packages/web_ui/src/components/BaseConfigTree.tsx @@ -33,11 +33,11 @@ function getInitialValues(config: lib.Config, props: BaseConfigTreeProps) { return initialValues; } -function renderInput(inputComponents: Record, def: lib.FieldDefinition, readonly: boolean) { - if (def.inputComponent && Object.prototype.hasOwnProperty.call(inputComponents, def.inputComponent)) { +function renderInput(inputComponents: Map, def: lib.FieldDefinition, readonly: boolean) { + if (def.inputComponent && inputComponents.has(def.inputComponent)) { // Field.Item will provide the value and onChange props to the component. type InputPartial = React.ComponentClass>; - const CustomInput = inputComponents[def.inputComponent] as unknown as InputPartial; + const CustomInput = inputComponents.get(def.inputComponent) as unknown as InputPartial; return ; } if (def.type === "boolean") { @@ -291,7 +291,7 @@ function computeTreeData( children: [], }; - if (def.type === "object" && !Object.keys(control.inputComponents).includes(def.inputComponent!)) { + if (def.type === "object" && !control.inputComponents.has(def.inputComponent!)) { let restartRequiredProps = new Set(def.restartRequiredProps || []); childNode.title = diff --git a/packages/web_ui/src/components/LoginForm.tsx b/packages/web_ui/src/components/LoginForm.tsx index 2fd64d3c..40ef4c54 100644 --- a/packages/web_ui/src/components/LoginForm.tsx +++ b/packages/web_ui/src/components/LoginForm.tsx @@ -5,6 +5,7 @@ import LockOutlined from "@ant-design/icons/LockOutlined"; import notify from "../util/notify"; import logo from "../images/logo.png"; import ControlContext from "./ControlContext"; +import { PluginLoginForm } from "../BaseWebPlugin"; const { Paragraph } = Typography; @@ -36,7 +37,7 @@ function TokenAuth(props: SetTokenProps) { } export default function LoginForm(props: SetTokenProps) { - let plugins = useContext(ControlContext).plugins; + const control = useContext(ControlContext); const [clusterName, setClusterName] = useState("Clusterio"); useEffect(() => { @@ -55,16 +56,14 @@ export default function LoginForm(props: SetTokenProps) { })(); }, []); - let loginForms = []; - for (let plugin of plugins.values()) { - loginForms.push(...plugin.loginForms); - } - - loginForms.push({ - name: "core.token", - title: "Token", - Component: TokenAuth, - }); + const loginForms: PluginLoginForm[] = [ + ...control.loginForms, + { + name: "core.token", + title: "Token", + Component: TokenAuth, + }, + ]; return diff --git a/packages/web_ui/src/components/PluginExtra.tsx b/packages/web_ui/src/components/PluginExtra.tsx index 840dc2c4..f7ef261c 100644 --- a/packages/web_ui/src/components/PluginExtra.tsx +++ b/packages/web_ui/src/components/PluginExtra.tsx @@ -1,29 +1,17 @@ import React, { useContext } from "react"; -import * as lib from "@clusterio/lib"; - import ControlContext from "./ControlContext"; -import BaseWebPlugin from "../BaseWebPlugin"; +import { PluginExtensionSlot, ExtensionSlotProps } from "../BaseWebPlugin"; -type PluginExtraProps = { - component: string; - search?: string; - instance?: lib.InstanceDetails; - user?: lib.UserDetails; - host?: lib.HostDetails; - role?: lib.Role; -}; -export default function PluginExtra(props: PluginExtraProps) { - let control = useContext(ControlContext); - - let components = []; - for (let [name, plugin] of control.plugins) { - if (Object.prototype.hasOwnProperty.call(plugin.componentExtra, props.component)) { - type ComponentExtra = {[key: string]: React.ComponentType}; - let ComponentExtra = (plugin.componentExtra as ComponentExtra)[props.component]; - components.push(); - } - } - return components; +export default function PluginExtra< + C extends PluginExtensionSlot +> ( + props: { component: C } & ExtensionSlotProps[C] +) { + const control = useContext(ControlContext); + const components = control.extensionComponents[props.component]; + return components && [...components].map(([source, Component]) => ( + + )); } diff --git a/packages/web_ui/src/components/PluginViewPage.tsx b/packages/web_ui/src/components/PluginViewPage.tsx index 58eba31a..d81220e1 100644 --- a/packages/web_ui/src/components/PluginViewPage.tsx +++ b/packages/web_ui/src/components/PluginViewPage.tsx @@ -26,7 +26,7 @@ export default function PluginViewPage() { })(); }, []); - const plugin = control.plugins.get(pluginName); + const loadedPlugin = control.loadedPlugins.get(pluginName); const pluginInfo = control.pluginInfos.get(pluginName); const pluginTitle = pluginInfo ? pluginInfo.title : pluginName; let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }]; @@ -46,7 +46,7 @@ export default function PluginViewPage() { } const pluginError = pluginMeta.web.error ?? pluginInfo?.error; - if (!plugin) { + if (!loadedPlugin) { return {pluginMeta.version} @@ -81,22 +81,22 @@ export default function PluginViewPage() { {pluginMeta.enabled ? "Yes" : "No"} - {plugin.info.description} - {plugin.package.homepage ? - {plugin.package.homepage} + {loadedPlugin.description} + {loadedPlugin.package.homepage ? + {loadedPlugin.package.homepage} : null} - {plugin.package.author ? - {plugin.package.author} + {loadedPlugin.package.author ? + {loadedPlugin.package.author} : null} - {plugin.package.license} + {loadedPlugin.package.license} - {pluginMeta.version !== plugin.package.version ? export { default as bootstrap } from "./bootstrap"; export { default as notify, notifyErrorHandler } from "./util/notify"; -export { default as BaseWebPlugin } from "./BaseWebPlugin"; +export * from "./BaseWebPlugin"; export * from "./util/websocket"; export * from "./model/account"; diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index b4afb44c..51958105 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -1,6 +1,6 @@ import * as lib from "@clusterio/lib"; import packageJson from "../../package.json"; -import BaseWebPlugin, { type InputComponent } from "../BaseWebPlugin"; +import * as WebPlugin from "../BaseWebPlugin"; const { logFilter, logger } = lib; @@ -51,9 +51,13 @@ export class Control extends lib.Link { /** Roles of the account this control link is connected as. */ accountRoles: lib.AccountRole[] | null = null; - /** Plugins loaded in the web interface */ - public plugins = new Map(); - public inputComponents: Record = {}; + public hooks = new WebPlugin.WebHooks(logger); + public plugins = new Map(); + public loadedPlugins = new Map(); + public inputComponents = new Map(); + public extensionComponents: Partial = {}; + public loginForms: WebPlugin.PluginLoginForm[] = []; + public pages: WebPlugin.PluginPage[] = []; /** Updates not handled by the subscription service */ accountUpdateHandlers: accountHandler[] = []; @@ -101,11 +105,7 @@ export class Control extends lib.Link { }); for (let event of ["connect", "drop", "resume", "close"] as const) { - this.connector.on(event, () => { - for (let plugin of this.plugins.values()) { - plugin.onControllerConnectionEvent(event); - } - }); + this.connector.on(event, this.hooks.controllerConnectionEvent.listener); } this.handle(lib.AccountUpdateEvent, this.handleAccountUpdateEvent.bind(this)); diff --git a/test/controller/BaseControllerPlugin.js b/test/controller/BaseControllerPlugin.js index 0ff45811..6f1a31d5 100644 --- a/test/controller/BaseControllerPlugin.js +++ b/test/controller/BaseControllerPlugin.js @@ -1,21 +1,59 @@ +import assert from "node:assert/strict"; + import * as mock from "../mock.js"; import * as controller from "@clusterio/controller"; describe("controller/src/BaseControllerPlugin", function() { describe("class BaseControllerPlugin", function() { - let controllerPlugin; - it("should be constructible", async function() { - controllerPlugin = new controller.BaseControllerPlugin({}, {}, {}, new mock.MockLogger()); - await controllerPlugin.init(); + const info = { name: "test" }; + let mockController; + beforeEach(function() { + mockController = new mock.MockController(); }); - it("should define defaults for hooks", async function() { - await controllerPlugin.onInstanceStatusChanged({}, "running", "initialized"); - await controllerPlugin.onControllerConfigFieldChanged({}, "foo", null); - await controllerPlugin.onInstanceConfigFieldChanged({}, {}, "foo", null); - await controllerPlugin.onMetrics(); - await controllerPlugin.onShutdown(); - controllerPlugin.onHostConnectionEvent({}, "connect"); - await controllerPlugin.onPrepareHostDisconnect({}); + + it("should be constructible", async function() { + const plugin = new controller.BaseControllerPlugin(info, mockController, {}, new mock.MockLogger()); + await plugin.init(); + assert.equal(mockController.hooks.size, 0); + }); + it("should be constructible from a load context", async function() { + const logger = new mock.MockLogger(); + const plugin = controller.BaseControllerPlugin.fromContext({ + plugin: info, controller: mockController, metrics: {}, logger, + }); + assert.equal(plugin.info, info); + assert.equal(plugin.controller, mockController); + assert.equal(plugin.logger, logger); + }); + it("should attach overridden hooks", async function() { + let calls = []; + class ControllerPlugin extends controller.BaseControllerPlugin { + async onSaveData() { calls.push(["save", this]); } + async onInstanceStatusChanged(instance, prev) { calls.push(["status", instance, prev]); } + onHostConnectionEvent(connection, event) { calls.push(["host", connection, event]); } + } + const plugin = new ControllerPlugin(info, mockController, {}, new mock.MockLogger()); + assert.deepEqual([...mockController.hooks.attached], ["test"]); + assert.equal(mockController.hooks.shutdown.size, 0); + + await mockController.hooks.save.invoke(); + await mockController.hooks.instanceStatusChanged.invoke("instance", "running"); + await mockController.hooks.hostConnectionEvent.invoke("connection", "connect"); + assert.deepEqual(calls, [ + ["save", plugin], + ["status", "instance", "running"], + ["host", "connection", "connect"], + ]); + }); + it("should detach hooks", async function() { + class ControllerPlugin extends controller.BaseControllerPlugin { + async onSaveData() { } + async onShutdown() { } + } + const plugin = new ControllerPlugin(info, mockController, {}, new mock.MockLogger()); + assert.equal(mockController.hooks.size, 1); + plugin.detachHooks(); + assert.equal(mockController.hooks.size, 0); }); }); }); diff --git a/test/controller/routes.js b/test/controller/routes.js index 5e764214..49c63783 100644 --- a/test/controller/routes.js +++ b/test/controller/routes.js @@ -85,7 +85,6 @@ describe("controller/src/routes", function() { let endpoint; beforeEach(function() { endpoint = `http://localhost:${port}/api/plugins`; - controller.plugins = new Map([["foo", {}]]); controller.mockConfigEntries.set("foo.load_plugin", true); }); it("should strip static/ from the plugin bundle path", async function() { @@ -93,6 +92,7 @@ describe("controller/src/routes", function() { name: "foo", version: "1.0.0", npmPackage: "foo", manifest: { "foo.js": "static/foo.abc.js" }, }]; + controller.loadedPlugins = new Set(controller.pluginInfos); let response = await fetch(endpoint); assert.equal(response.status, 200); assert.deepEqual(await response.json(), [{ @@ -106,6 +106,7 @@ describe("controller/src/routes", function() { { name: "bar", version: "1.0.0", npmPackage: "bar", manifest: {} }, { name: "baz", version: "1.0.0", npmPackage: "baz", manifest: { "baz.js": "remoteEntry.js" } }, ]; + controller.loadedPlugins = new Set([controller.pluginInfos[0]]); let response = await fetch(endpoint); assert.equal(response.status, 200); let data = await response.json(); diff --git a/test/file/test_plugin/host.js b/test/file/test_plugin/host.js index 648d8c95..1e48da39 100644 --- a/test/file/test_plugin/host.js +++ b/test/file/test_plugin/host.js @@ -1,21 +1,16 @@ -import { BaseHostPlugin } from "@clusterio/host"; - import { HostEcho, HostEchoReceived } from "./messages.js"; -export class HostPlugin extends BaseHostPlugin { - async init() { - this.logger.info("test_plugin host loaded"); - this.receivedEchoes = new Set(); - this.host.handle(HostEcho, this.handleHostEcho.bind(this)); - this.host.handle(HostEchoReceived, this.handleHostEchoReceived.bind(this)); - } +// Uses the default export entrypoint, the controller and ctl parts of this +// plugin use the deprecated class export so both paths are exercised. +export default async function(context) { + const { host, logger } = context; + logger.info("test_plugin host loaded"); + const receivedEchoes = new Set(); - async handleHostEcho(event) { - this.logger.info(`test_plugin host echo ${event.text}`); - this.receivedEchoes.add(event.text); - } + host.handle(HostEcho, async (event) => { + logger.info(`test_plugin host echo ${event.text}`); + receivedEchoes.add(event.text); + }); - async handleHostEchoReceived(request) { - return this.receivedEchoes.has(request.text); - } + host.handle(HostEchoReceived, async (request) => receivedEchoes.has(request.text)); } diff --git a/test/file/test_plugin/instance.js b/test/file/test_plugin/instance.js index 61ec4c5f..3f467777 100644 --- a/test/file/test_plugin/instance.js +++ b/test/file/test_plugin/instance.js @@ -1,7 +1,8 @@ -import { BaseInstancePlugin } from "@clusterio/host"; +export default async function(context) { + const { instance, plugin, logger } = context; + logger.info("test_plugin instance loaded"); -export class InstancePlugin extends BaseInstancePlugin { - async init() { - this.logger.info("test_plugin instance loaded"); - } + instance.hooks.start.attach(plugin.name, async () => { + logger.info("test_plugin instance started"); + }); } diff --git a/test/host/BaseHostPlugin.js b/test/host/BaseHostPlugin.js index 0394f166..e0afed6e 100644 --- a/test/host/BaseHostPlugin.js +++ b/test/host/BaseHostPlugin.js @@ -1,20 +1,50 @@ +import assert from "node:assert/strict"; import * as mock from "../mock.js"; import * as host from "@clusterio/host"; describe("host/src/BaseHostPlugin", function() { describe("class BaseHostPlugin", function() { - let hostPlugin; - it("should be constructible", async function() { - hostPlugin = new host.BaseHostPlugin({}, new mock.MockHost(), new mock.MockLogger()); - await hostPlugin.init(); + const info = { name: "test" }; + let mockHost; + beforeEach(function() { + mockHost = new mock.MockHost(); }); - it("should define defaults for hooks", async function() { - await hostPlugin.onHostConfigFieldChanged(); - await hostPlugin.onMetrics(); - await hostPlugin.onShutdown(); - hostPlugin.onControllerConnectionEvent("connect"); - await hostPlugin.onPrepareControllerDisconnect(); + + it("should be constructible", async function() { + const plugin = new host.BaseHostPlugin(info, mockHost, new mock.MockLogger()); + await plugin.init(); + assert.equal(mockHost.hooks.size, 0); + }); + it("should be constructible from a load context", async function() { + const logger = new mock.MockLogger(); + const plugin = host.BaseHostPlugin.fromContext({ plugin: info, host: mockHost, logger }); + assert.equal(plugin.info, info); + assert.equal(plugin.host, mockHost); + assert.equal(plugin.logger, logger); + }); + it("should attach overridden hooks", async function() { + let calls = []; + class HostPlugin extends host.BaseHostPlugin { + async onHostConfigFieldChanged(field, curr, prev) { calls.push(["config", field, curr, prev]); } + async onShutdown() { calls.push(["shutdown", this]); } + } + const plugin = new HostPlugin(info, mockHost, new mock.MockLogger()); + assert.deepEqual([...mockHost.hooks.attached], ["test"]); + assert.equal(mockHost.hooks.metrics.size, 0); + + await mockHost.hooks.hostConfigFieldChanged.invoke("host.name", "new", "old"); + await mockHost.hooks.shutdown.invoke(); + assert.deepEqual(calls, [["config", "host.name", "new", "old"], ["shutdown", plugin]]); + }); + it("should detach hooks", async function() { + class HostPlugin extends host.BaseHostPlugin { + async onShutdown() { } + } + const plugin = new HostPlugin(info, mockHost, new mock.MockLogger()); + assert.equal(mockHost.hooks.size, 1); + plugin.detachHooks(); + assert.equal(mockHost.hooks.size, 0); }); }); }); diff --git a/test/host/BaseInstancePlugin.js b/test/host/BaseInstancePlugin.js index 71b6d252..10808b40 100644 --- a/test/host/BaseInstancePlugin.js +++ b/test/host/BaseInstancePlugin.js @@ -5,19 +5,51 @@ import * as host from "@clusterio/host"; describe("host/src/BaseInstancePlugin", function() { describe("class BaseInstancePlugin", function() { + const info = { name: "test" }; let instancePlugin; it("should be constructible", async function() { - instancePlugin = new host.BaseInstancePlugin({}, new mock.MockInstance(), {}); + instancePlugin = new host.BaseInstancePlugin( + info, new mock.MockInstance(), new mock.MockHost(), new mock.MockLogger() + ); await instancePlugin.init(); + assert.equal(instancePlugin.instance.hooks.size, 0); }); - it("should define defaults for hooks", async function() { - await instancePlugin.onMetrics(); - await instancePlugin.onStart(); - await instancePlugin.onStop(); - instancePlugin.onExit(); - await instancePlugin.onOutput({}); - instancePlugin.onControllerConnectionEvent("connect"); - await instancePlugin.onPrepareControllerDisconnect(); + it("should be constructible from a load context", async function() { + const instance = new mock.MockInstance(); + const mockHost = new mock.MockHost(); + const logger = new mock.MockLogger(); + const plugin = host.BaseInstancePlugin.fromContext({ plugin: info, instance, host: mockHost, logger }); + assert.equal(plugin.info, info); + assert.equal(plugin.instance, instance); + assert.equal(plugin.host, mockHost); + assert.equal(plugin.logger, logger); + }); + it("should attach overridden hooks", async function() { + let calls = []; + class InstancePlugin extends host.BaseInstancePlugin { + async onStart() { calls.push(["start", this]); } + onExit() { calls.push(["exit"]); } + async onOutput(parsed, line) { calls.push(["output", parsed, line]); } + } + const instance = new mock.MockInstance(); + const plugin = new InstancePlugin(info, instance, new mock.MockHost(), new mock.MockLogger()); + assert.deepEqual([...instance.hooks.attached], ["test"]); + assert.equal(instance.hooks.stop.size, 0); + + await instance.hooks.start.invoke(); + await instance.hooks.exit.invoke(); + await instance.hooks.output.invoke({ type: "generic" }, "line"); + assert.deepEqual(calls, [["start", plugin], ["exit"], ["output", { type: "generic" }, "line"]]); + }); + it("should detach hooks", async function() { + class InstancePlugin extends host.BaseInstancePlugin { + async onStart() { } + } + const instance = new mock.MockInstance(); + const plugin = new InstancePlugin(info, instance, new mock.MockHost(), new mock.MockLogger()); + assert.equal(instance.hooks.size, 1); + plugin.detachHooks(); + assert.equal(instance.hooks.size, 0); }); describe("sendRcon", function() { it("should send commands out of order", async function() { diff --git a/test/integration/index.js b/test/integration/index.js index 2c6910db..c2816412 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -235,7 +235,7 @@ export async function execCtl(command) { inExecCtl = true; process.chdir("temp/test"); try { - const initArgs = await initializeCtl(command, control.plugins, true); + const initArgs = await initializeCtl(command, control.hooks, true); if (!initArgs.shouldRun) { return; } @@ -394,7 +394,7 @@ before(async function() { const initArgs = await initializeCtl(`--plugin-list ${pluginListPath} control-config list`, undefined, true); control.config = initArgs.controlConfig; - control.plugins = initArgs.ctlPlugins; + control.hooks = initArgs.ctlHooks; const testPack = lib.ModPack.fromJSON({}); testPack.id = 12; diff --git a/test/lib/AsyncHook.js b/test/lib/AsyncHook.js new file mode 100644 index 00000000..e6163e60 --- /dev/null +++ b/test/lib/AsyncHook.js @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; + +import * as mock from "../mock.js"; +import * as lib from "@clusterio/lib"; + + +describe("lib/AsyncHook", function() { + describe("class AsyncHook", function() { + let logger; + let hook; + beforeEach(function() { + logger = new mock.MockLogger(); + logger.errors = []; + logger.error = msg => logger.errors.push(msg); + hook = new lib.AsyncHook(logger); + }); + + it("should track attached handlers", function() { + assert.equal(hook.size, 0); + hook.attach("alpha", () => {}); + hook.attach("beta", () => {}); + assert.equal(hook.size, 2); + assert.deepEqual([...hook.attached], ["alpha", "beta"]); + }); + it("should reject duplicate handler names", function() { + hook.attach("alpha", () => {}); + assert.throws( + () => hook.attach("alpha", () => {}), + new Error("Handler with name alpha is already attached") + ); + }); + it("should detach handlers", async function() { + let called = false; + hook.attach("alpha", () => { called = true; }); + hook.detach("alpha"); + hook.detach("missing"); + await hook.invoke(); + assert.equal(called, false); + assert.equal(hook.size, 0); + }); + it("should clear all handlers", function() { + hook.attach("alpha", () => {}); + hook.attach("beta", () => {}); + hook.clear(); + assert.equal(hook.size, 0); + }); + it("should invoke all handlers with the given arguments", async function() { + let calls = []; + hook.attach("alpha", (a, b) => { calls.push(["alpha", a, b]); }); + hook.attach("beta", async (a, b) => { calls.push(["beta", a, b]); }); + await hook.invoke(1, "two"); + assert.deepEqual(calls, [["alpha", 1, "two"], ["beta", 1, "two"]]); + }); + it("should log and ignore errors thrown by handlers", async function() { + let betaCalled = false; + hook.attach("alpha", () => { throw new Error("sync"); }); + hook.attach("beta", async () => { betaCalled = true; }); + hook.attach("gamma", async () => { throw new Error("async"); }); + await hook.invoke(); + assert(betaCalled, "beta was not called"); + assert.equal(logger.errors.length, 2); + assert(logger.errors[0].startsWith('Ignoring error in hook "alpha":'), logger.errors[0]); + assert(logger.errors[1].startsWith('Ignoring error in hook "gamma":'), logger.errors[1]); + }); + it("should time out handlers that never resolve", async function() { + hook = new lib.AsyncHook(logger, 10); + hook.attach("stuck", () => new Promise(() => {})); + hook.attach("fast", () => 1); + assert.deepEqual(await hook.collect(), [1]); + assert.equal(logger.errors.length, 1); + assert(logger.errors[0].includes("Hook stuck timed out after 10ms"), logger.errors[0]); + }); + it("should collect results from handlers", async function() { + hook.attach("alpha", arg => arg); + hook.attach("beta", () => undefined); + hook.attach("gamma", async arg => arg * 2); + hook.attach("delta", () => { throw new Error("Test"); }); + assert.deepEqual(await hook.collect(21), [21, 42]); + }); + it("should collect results with their source", async function() { + hook.attach("alpha", arg => arg); + hook.attach("beta", () => undefined); + hook.attach("gamma", async arg => arg * 2); + assert.deepEqual(await hook.collectEntries(21), [["alpha", 21], ["gamma", 42]]); + }); + it("should provide a stable listener bound to the hook", async function() { + let calls = []; + hook.attach("alpha", arg => { calls.push(arg); }); + const { listener } = hook; + assert.equal(listener, hook.listener); + await listener("value"); + assert.deepEqual(calls, ["value"]); + }); + }); + + describe("class AsyncHookCollection", function() { + class TestHooks extends lib.AsyncHookCollection { + constructor(logger) { + super(logger); + this.first = this.newHook(); + this.second = this.newHook(); + } + } + + let hooks; + beforeEach(function() { + hooks = new TestHooks(new mock.MockLogger()); + }); + + it("should count each name once across hooks", function() { + hooks.first.attach("alpha", () => {}); + hooks.second.attach("alpha", () => {}); + hooks.second.attach("beta", () => {}); + assert.equal(hooks.size, 2); + assert.deepEqual([...hooks.attached], ["alpha", "beta"]); + }); + it("should detach a name from all hooks", function() { + hooks.first.attach("alpha", () => {}); + hooks.second.attach("alpha", () => {}); + hooks.second.attach("beta", () => {}); + hooks.detachAll("alpha"); + assert.equal(hooks.first.size, 0); + assert.deepEqual([...hooks.second.attached], ["beta"]); + }); + it("should clear all hooks", function() { + hooks.first.attach("alpha", () => {}); + hooks.second.attach("beta", () => {}); + hooks.clearAll(); + assert.equal(hooks.size, 0); + }); + }); +}); diff --git a/test/lib/plugin.js b/test/lib/plugin.js deleted file mode 100644 index b9fd11ac..00000000 --- a/test/lib/plugin.js +++ /dev/null @@ -1,36 +0,0 @@ -import assert from "node:assert/strict"; - -import * as mock from "../mock.js"; -import * as lib from "@clusterio/lib"; - - -describe("lib/plugin", function() { - describe("invokeHook()", function() { - let betaTestCalled = false; - let plugins = new Map([ - ["alpha", { - test: async function() { }, - pass: async function(arg) { return arg; }, - error: async function() { throw new Error("Test"); }, - logger: new mock.MockLogger(), - }], - ["beta", { - test: async function() { betaTestCalled = true; }, - pass: async function() { }, - error: async function() { }, - logger: new mock.MockLogger(), - }], - ]); - it("should invoke the hook on the plugin", async function() { - await lib.invokeHook(plugins, "test"); - assert(betaTestCalled, "Hook was not called"); - }); - it("should pass and return args", async function() { - let result = await lib.invokeHook(plugins, "pass", 1234); - assert.deepEqual(result, [1234]); - }); - it("should ignore errors", async function() { - await lib.invokeHook(plugins, "error"); - }); - }); -}); diff --git a/test/lib/plugin_loader.js b/test/lib/plugin_loader.js index 9d4b3ef2..412638ff 100644 --- a/test/lib/plugin_loader.js +++ b/test/lib/plugin_loader.js @@ -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") ); }); }); diff --git a/test/messages/plugin.test.js b/test/messages/plugin.test.js index 7935cd3c..621da7df 100644 --- a/test/messages/plugin.test.js +++ b/test/messages/plugin.test.js @@ -74,7 +74,7 @@ describe("messages/plugin", function() { description: "Description", npmPackage: "Npm Package", }]; - controller.plugins.set("Name", {}); + controller.loadedPlugins.add(controller.pluginInfos[0]); controller.config.set("Name.load_plugin", true); const response = await controlConnection.handlePluginListRequest(new lib.PluginListRequest()); @@ -89,7 +89,7 @@ describe("messages/plugin", function() { description: "Description", npmPackage: "Npm Package", }]; - host.plugins.set("Name", {}); + host.loadedPlugins.add(host.pluginInfos[0]); host.config.set("Name.load_plugin", true); const response = await host.handlePluginListRequest(new lib.PluginListRequest()); diff --git a/test/mock.js b/test/mock.js index 1efdc3a5..0c52dfdc 100644 --- a/test/mock.js +++ b/test/mock.js @@ -3,7 +3,8 @@ import http from "node:http"; import express from "express"; import * as lib from "@clusterio/lib"; -import { User, UserManager, UserRecord, InstanceManager } from "@clusterio/controller"; +import { User, UserManager, UserRecord, InstanceManager, ControllerHooks } from "@clusterio/controller"; +import { HostHooks, InstanceHooks } from "@clusterio/host"; const addr = lib.Address.fromShorthand; @@ -123,6 +124,8 @@ export class MockInstance extends lib.Link { this.name = "test"; this.id = 7357; this.status = "running"; + this.hooks = new InstanceHooks(this.logger); + this.loadedPlugins = new Set(); this.mockConfigEntries = new Map([ ["instance.id", 7357], ["factorio.enable_save_patching", true], @@ -146,6 +149,8 @@ export class MockInstance extends lib.Link { export class MockHost extends lib.Link { constructor() { super(new MockConnector(addr({ hostId: 1 }), addr("controller"))); + this.hooks = new HostHooks(new MockLogger()); + this.loadedPlugins = new Set(); } } @@ -192,6 +197,8 @@ export class MockController { this.hosts = new lib.KeyValueDatastore(); this.handles = new Map(); + this.hooks = new ControllerHooks(new MockLogger()); + this.loadedPlugins = new Set(); } get authSecret() { @@ -235,7 +242,7 @@ export async function createControllerPlugin(ControllerPluginClass, info) { export async function createInstancePlugin(InstancePluginClass, info) { let instance = new MockInstance(); let host = new MockHost(); - let plugin = new InstancePluginClass(info, instance, host); + let plugin = new InstancePluginClass(info, instance, host, new MockLogger()); await plugin.init(); return plugin; } From 8363b221bbe0782c386bafa1a6a450e2e2fec652 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:29:16 +0000 Subject: [PATCH 2/2] Address review on plugin docs and web plugins map Point the invalid configuration section at the features flags and config validators instead of throwing from the entrypoint, use const in the examples, add a metrics hook example including a returned registry, and mark Control.plugins as deprecated. Co-Authored-By: Claude Fable 5.1 --- docs/writing-plugins.md | 55 +++++++++++++++++++++------ packages/web_ui/src/util/websocket.ts | 1 + 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 3228db2e..b6205037 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -98,6 +98,10 @@ The following properties are recognized: This is an optional paramater. A plugin can be made that only runs on the clusterioctl side. +**features**: + Array of features the plugin needs from the instances it is loaded on, `"SavePatching"` and/or `"ScriptCommands"`. + See [Handling Invalid Configuration](#handling-invalid-configuration) + **messages**: Object with link messages definitions for this plugin. See guide for [defining link messages](#defining-link-messages) below. @@ -238,7 +242,7 @@ The controller entrypoint will then be able to access the level config field thr ```ts export default async function(context: ControllerPluginContext) { - let level = context.controller.config.get("foo_frobber.level"); + const level = context.controller.config.get("foo_frobber.level"); context.logger.info(`I got a frobnication level of ${level}`); } ``` @@ -249,18 +253,22 @@ See [Configuration System](config-system.md) for more details on how this system ### Handling Invalid Configuration -If the plugin requires a certain feature to be enabled to function it should throw an error from the entrypoint if this is not the case. -The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio. -For example: +If the plugin requires save patching or script commands to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export: ```js -export default async function(context) { - if (!context.instance.config.get("factorio.enable_save_patching")) { - throw new Error("foo_frobber plugin requires save patching."); - } -} +export const plugin = { + name: "foo_frobber", + // ... + features: ["SavePatching"], +}; ``` +Enabling the plugin on an instance that has the feature turned off then fails config validation, so the mistake is caught when the config is edited instead of when the instance starts. + +Other constraints on config fields are expressed with a `validator` on the field definition, which throws when the value is not acceptable. +Use `dependsOn` when the check involves other fields so that the validator runs when those change too. +See [Configuration System](config-system.md) for details. + ## Plugin Permissions @@ -309,7 +317,7 @@ For example: ```js instance.hooks.start.attach(plugin.name, async () => { - let response = await instance.sendRcon( + const response = await instance.sendRcon( "/sc rcon.print('data')" ); @@ -605,7 +613,32 @@ Metrics are automatically registered to the default registry, and this default r This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a host. Since the metrics remember their values and would continue to be exported after an instance is shutdown, there's code at instance shutdown that removes all the values where the `instance_id` label matches the id of the instance shut down. -For statistics you need to update on collection there's a `metrics` hook on both controller and instance plugins that is run before the metrics in the default registry are collected. +For statistics you need to update on collection there's a `metrics` hook on the controller, host and instance that is run before the metrics in the default registry are collected. + +```js +import { Gauge } from "@clusterio/lib"; + +const frobnicationsMetric = new Gauge( + "clusterio_foo_frobber_frobnications", "Frobnications currently in progress" +); + +// In the controller entrypoint +controller.hooks.metrics.attach(plugin.name, async () => { + frobnicationsMetric.set(frobnications.size); +}); +``` + +A handler may also return the results of collecting collectors that are not in the default registry, and they are added to the response. + +```js +import { CollectorRegistry, Counter } from "@clusterio/lib"; + +const fooRegistry = new CollectorRegistry(); +const fooCounter = new Counter("clusterio_foo_frobber_foos", "Foos frobbed", { register: false }); +fooRegistry.register(fooCounter); + +controller.hooks.metrics.attach(plugin.name, async () => fooRegistry.collect()); +``` ## Adding Custom Commands to clusterioctl diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index 51958105..265b0c18 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -52,6 +52,7 @@ export class Control extends lib.Link { accountRoles: lib.AccountRole[] | null = null; public hooks = new WebPlugin.WebHooks(logger); + /** @deprecated Only holds plugins using the class export, use loadedPlugins and hooks instead */ public plugins = new Map(); public loadedPlugins = new Map(); public inputComponents = new Map();