Add typed hook system for plugins

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

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

Closes #730

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

No files matched your search

+79 -74
View File
@@ -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);
}
});
}
```
+1 -1
View File
@@ -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";
+122 -46
View File
@@ -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<void | AsyncIterable<CollectorResult>> { }
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult>>;
/**
* 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<void>;
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
onShutdown?(): Promise<void>;
onInstanceStatusChanged?(instance: InstanceRecord, prev?: lib.InstanceStatus): Promise<void>;
onControllerConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise<void>;
onInstanceConfigFieldChanged?(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): Promise<void>;
onHostConnectionEvent?(connection: HostConnection, event: "connect" | "drop" | "resume" | "close"): void;
onControlConnectionEvent?(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"): void;
onPrepareHostDisconnect?(connection: HostConnection): Promise<void>;
onModPacksUpdated?(modPacks: lib.ModPack[]): Promise<void>;
onModsUpdated?(mods: lib.ModInfo[]): Promise<void>;
onRolesUpdated?(roles: lib.Role[]): Promise<void>;
onPlayerEvent?(instance: InstanceRecord, event: lib.PlayerEvent): Promise<void>;
}
/**
* 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 = <Args extends unknown[], Return>(
hook: lib.AsyncHook<Args, Return>,
fn?: lib.HookHandler<Args, Return>,
) => {
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<T>(event: Event<T>) {
broadcastEventToHosts<T>(event: lib.Event<T>) {
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
if (
!hostConnection.connector.closing
+2 -4
View File
@@ -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`),
));
}
+20 -22
View File
@@ -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<string, BaseControllerPlugin> = new Map();
/** Hooks which plugins can attach to */
hooks = new ControllerHooks(logger);
/** Plugins which are currently loaded */
loadedPlugins: Set<lib.PluginNodeEnvInfo> = 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<typeof lib.SystemInfo.jsonSchema>[] {
@@ -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") {
+11 -18
View File
@@ -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)!,
+2 -14
View File
@@ -83,12 +83,7 @@ export default class InstanceManager {
instance: InstanceRecord,
prev?: lib.InstanceStatus,
): Promise<void> {
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<void> {
await lib.invokeHook(
this._controller.plugins,
"onInstanceConfigFieldChanged",
instance,
field,
curr,
prev,
);
await this._controller.hooks.instanceConfigFieldChanged.invoke(instance, field, curr, prev);
}
/**
+2 -2
View File
@@ -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))));
+3 -3
View File
@@ -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.
@@ -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)}`);
});
}
+3 -4
View File
@@ -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);
}
});
}
+16 -22
View File
@@ -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");
});
}
+1 -1
View File
@@ -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 = {
+39 -52
View File
@@ -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
});
}
@@ -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) { }
@@ -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 <PageLayout nav={[{ name: "__plugin_name__" }]}>
@@ -41,53 +51,39 @@ function MyTemplatePage() {
</PageLayout>;
}
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: <MyTemplatePage/>,
},
];
content: <MyTemplatePage/>,
},
]);
//%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
}
@@ -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<string, ExampleSubscribableValue>;
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)}`);
});
}
+4 -4
View File
@@ -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);
}
});
}
+17 -23
View File
@@ -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");
});
}
+41 -53
View File
@@ -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
});
}
@@ -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) { }
@@ -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<ExampleSubscribableUpdate>;
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 <PageLayout nav={[{ name: "__plugin_name__" }]}>
@@ -41,53 +52,39 @@ function MyTemplatePage() {
</PageLayout>;
}
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: <MyTemplatePage/>,
},
];
content: <MyTemplatePage/>,
},
]);
//%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
}
+18 -28
View File
@@ -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<string, BaseCtlPlugin>;
/** Keep the control connection alive after the command completes. */
keepOpen = false;
constructor(
connector: ControlConnector,
controlConfig: lib.ControlConfig,
ctlPlugins: Map<string, BaseCtlPlugin>
) {
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<string, string>) {
async function loadPlugins(pluginList: Map<string, string>, hooks: CtlHooks) {
let pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let ctlPlugins = new Map<string, BaseCtlPlugin>();
const ctlPlugins = new Set<lib.PluginNodeEnvInfo>();
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<string, BaseCtlPlugin>;
ctlHooks: CtlHooks;
rootCommands?: lib.CommandTree;
controlConfig?: lib.ControlConfig;
}
export async function initialize(
argv: string | string[],
ctlPlugins?: Map<string, BaseCtlPlugin>,
ctlHooks: CtlHooks = new CtlHooks(logger),
noLoggerTransport?: boolean,
): Promise<InitializeParameters> {
// 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) {
+1 -1
View File
@@ -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();
+53 -31
View File
@@ -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<void>;
}
/**
* 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() {}
}
+3 -5
View File
@@ -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<string, BaseCtlPlugin>, 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<string, BaseCtlPlugin>, 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) {
+2 -2
View File
@@ -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";
+76 -42
View File
@@ -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<void | AsyncIterable<CollectorResult>> { }
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult>>;
/**
* 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<void>;
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
onShutdown?(): Promise<void>;
onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void;
onPrepareControllerDisconnect?(connection: Host): Promise<void>;
}
/**
* 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 = <Args extends unknown[], Return>(
hook: lib.AsyncHook<Args, Return>,
fn?: lib.HookHandler<Args, Return>,
) => {
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() {}
}
+118 -68
View File
@@ -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<void | AsyncIterable<CollectorResult>> { }
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult>>;
/**
* 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<void>;
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
onStart?(): Promise<void>;
onStop?(): Promise<void>;
onExit?(): void;
onOutput?(parsed: lib.ParsedFactorioOutput, line: string): Promise<void>;
onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void;
onPrepareControllerDisconnect?(connection: Instance): Promise<void>;
onPlayerEvent?(event: lib.PlayerEvent): Promise<void>;
}
/**
* 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 = <Args extends unknown[], Return>(
hook: lib.AsyncHook<Args, Return>,
fn?: lib.HookHandler<Args, Return>,
) => {
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
+20 -26
View File
@@ -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<string, BaseHostPlugin> = new Map();
/** Hooks which plugins can attach to */
hooks = new HostHooks(logger);
/** Plugins which are currently loaded */
loadedPlugins: Set<lib.PluginNodeEnvInfo> = new Set();
/** A map from instance id to instance connection. Only present when instance is running. */
instanceConnections = new Map<number, InstanceConnection>();
@@ -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 {
+34 -39
View File
@@ -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<string, BaseInstancePlugin>;
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<string> = new Set();
/** Hooks which plugins can attach to */
hooks: InstanceHooks;
/** Plugins which are currently loaded */
loadedPlugins: Set<lib.PluginNodeEnvInfo> = 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<string, string> = {};
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<string, string> = {};
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<string, SaveModule> = 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<typeof lib.serializeResult>[] = [];
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));
+6 -9
View File
@@ -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")}`
);
}
+2 -1
View File
@@ -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";
+2
View File
@@ -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";
+184
View File
@@ -0,0 +1,184 @@
import { logger } from "./logging.js";
import * as libHelpers from "./helpers.js";
export type HookHandler<Args extends unknown[], Result = void> = (...args: Args) => Result | Promise<Result>;
export class AsyncHook<
Args extends unknown[],
Result = void,
> {
private static readonly _timeoutToken = Symbol("timeout");
private _handlers = new Map<string, HookHandler<Args, Result>>();
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<Args, Result>,
) {
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, Result>,
args: Args,
): Promise<Result | undefined> {
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<void> {
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<Result[]> {
const results = await Promise.all(
[...this._handlers.entries()].map(
([name, handler]) => this._invokeHandler(name, handler, args)
)
);
return results.filter(
(result): result is Awaited<Result> => 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<any[], any>[] = [];
constructor(
private _logger = logger,
) {}
protected newHook<
Args extends unknown[],
Result = void,
> (
_logger = this._logger
) {
const hook = new AsyncHook<Args, Result>(_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();
}
}
}
+1 -1
View File
@@ -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 {
+54
View File
@@ -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<Context, Info>,
module: Record<string, unknown>
) {
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<Context, Info>,
Info extends PluginDeclaration
>(
pluginInfo: Info,
pluginType: PluginType,
context: PluginLoadContext<Context, Info>,
module: Record<string, unknown>,
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<Class>;
}
+21 -40
View File
@@ -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<Hook, (...hookArgs: Args) => R | Promise<R>>
>(
plugins: Map<string, Plugin>,
hook: Hook,
...args: Args
): Promise<Exclude<Awaited<ReturnType<Plugin[Hook]>>, void>[]> {
let results: any[] = [];
for (let [name, plugin] of plugins) {
try {
const timeout = Symbol("timeout-token");
let result = await libHelpers.timeout<R | typeof timeout>(
plugin[hook](...args) as Promise<R>,
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<Context, Info>): {
init(): Promise<void>;
detachHooks(): void;
};
};
export type PluginType =
Extract<keyof PluginDeclaration, `${string}Entrypoint`> extends `${infer P}Entrypoint` ? P : never;
+37 -17
View File
@@ -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<string, string>) {
return plugins;
}
export async function loadPluginClass<Class extends { new (...args: any): any }>(
pluginName: string,
requirePath: string,
className: string,
pluginClass: Class,
): Promise<Class> {
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<Context, libPlugin.PluginNodeEnvInfo>,
> (
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> = {
...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`);
}
+1 -1
View File
@@ -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() {
+2
View File
@@ -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",
+5
View File
@@ -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;
+178 -110
View File
@@ -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<InputComponentProps>;
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<InputComponentProps>;
export type ExtensionSlotProps = {
/** Placed at the end of the overview page. */
OverviewPage: Record<never, never>;
/** Placed at the end of the controller page. */
ControllerPage: Record<never, never>;
/** Placed at the end of the hosts list page. */
HostsPage: Record<never, never>;
/**
* 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<never, never>;
/**
* 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<never, never>;
/**
* 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<never, never>;
/** Placed at the end of the roles list page. */
RolesPage: Record<never, never>;
/**
* 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<K extends PluginExtensionSlot> = ExtensionSlotProps[K] & { component: K };
export type ExtensionComponent<K extends PluginExtensionSlot> = React.ComponentType<PluginExtensionProps<K>>;
export type ExtensionComponents = { [K in PluginExtensionSlot]: Map<string, ExtensionComponent<K>> };
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<string, InputComponent> = {};
/**
* 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<K>;
}>>;
/**
* 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<string, InputComponent>>;
/**
* 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<string, InputComponent> = {};
componentExtra: Partial<{
[K in PluginExtensionSlot]: ExtensionComponent<K>;
}> = {};
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() {}
}
+109 -28
View File
@@ -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<string, BaseWebPlugin>();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.enabled) {
async function loadPlugins(
pluginInfos: lib.PluginWebpackEnvInfo[],
control: Control
) {
const plugins = new Map<string, WebPlugin.BaseWebPlugin>();
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<string, BaseWebPlugin>) {
const inputComponents: Record<string, InputComponent> = {
function mergeWithWarning<V>(
entries: Iterable<[string, Iterable<V>]>,
getKey: (item: V) => string,
label: string
): V[] {
const map = new Map<string, V>();
const sources = new Map<string, string>();
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<WebPlugin.PluginLoginForm>(results, item => item.name, "login form");
}
async function pagesFromHooks(control: Control) {
const results = await control.hooks.pages.collectEntries();
results.unshift(["core", pages]);
return mergeWithWarning<WebPlugin.PluginPage>(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(<App control={control}/>);
@@ -33,11 +33,11 @@ function getInitialValues(config: lib.Config<any>, props: BaseConfigTreeProps) {
return initialValues;
}
function renderInput(inputComponents: Record<string, InputComponent>, def: lib.FieldDefinition, readonly: boolean) {
if (def.inputComponent && Object.prototype.hasOwnProperty.call(inputComponents, def.inputComponent)) {
function renderInput(inputComponents: Map<string, InputComponent>, 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<Omit<InputComponentProps, "value" | "onChange">>;
const CustomInput = inputComponents[def.inputComponent] as unknown as InputPartial;
const CustomInput = inputComponents.get(def.inputComponent) as unknown as InputPartial;
return <CustomInput fieldDefinition={def} disabled={readonly} />;
}
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 = <Form.Item
label={<>
+10 -11
View File
@@ -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<string>("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 <Card style={{ maxWidth: "30em" }}>
<Space style={{ width: "100%" }} direction="vertical" align="center">
+11 -23
View File
@@ -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<PluginExtraProps & {plugin:BaseWebPlugin}>};
let ComponentExtra = (plugin.componentExtra as ComponentExtra)[props.component];
components.push(<ComponentExtra key={name} plugin={plugin} {...props}/>);
}
}
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]) => (
<Component key={source} {...props} />
));
}
@@ -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 <PageLayout nav={nav}>
<Descriptions bordered size="small" title={pluginTitle}>
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
@@ -81,22 +81,22 @@ export default function PluginViewPage() {
<Descriptions.Item label="Enabled on controller" span={2}>
{pluginMeta.enabled ? "Yes" : "No"}
</Descriptions.Item>
<Descriptions.Item label="Description" span={3}>{plugin.info.description}</Descriptions.Item>
{plugin.package.homepage ? <Descriptions.Item label="Homepage" span={3}>
<a href={plugin.package.homepage}>{plugin.package.homepage}</a>
<Descriptions.Item label="Description" span={3}>{loadedPlugin.description}</Descriptions.Item>
{loadedPlugin.package.homepage ? <Descriptions.Item label="Homepage" span={3}>
<a href={loadedPlugin.package.homepage}>{loadedPlugin.package.homepage}</a>
</Descriptions.Item> : null}
{plugin.package.author ? <Descriptions.Item label="Author" span={3}>
{plugin.package.author}
{loadedPlugin.package.author ? <Descriptions.Item label="Author" span={3}>
{loadedPlugin.package.author}
</Descriptions.Item> : null}
<Descriptions.Item label="License" span={3}>{plugin.package.license}</Descriptions.Item>
<Descriptions.Item label="License" span={3}>{loadedPlugin.package.license}</Descriptions.Item>
</Descriptions>
{pluginMeta.version !== plugin.package.version ? <Alert
{pluginMeta.version !== loadedPlugin.package.version ? <Alert
style={{
marginTop: "1em",
}}
message="Version missmatch detected"
description={
`The version of the web interface module for this plugin (${plugin.package.version}) `+
`The version of the web interface module for this plugin (${loadedPlugin.package.version}) `+
`does not match the version running on the controller (${pluginMeta.version}), `+
"spurious errors may occur. This usually happens when the plugin is updated but the "+
"controller has not been restarted yet, but may also be due to an outdated build."
@@ -47,17 +47,15 @@ export default function PluginsPage() {
let tableContents: PluginRow[] = [];
for (let meta of pluginList) {
if (control.plugins.has(meta.name)) {
let plugin = control.plugins.get(meta.name)!;
const info = control.loadedPlugins.get(meta.name);
if (info) {
tableContents.push({
meta,
info: plugin.info,
package: plugin.package,
meta, info,
package: info.package,
});
} else {
tableContents.push({
meta,
info: control.pluginInfos.get(meta.name),
meta, info: control.pluginInfos.get(meta.name),
});
}
}
@@ -10,7 +10,6 @@ import ChangeLogModal from "./ChangeLogModal";
import AboutModal from "./AboutModal";
import Link from "./Link";
import { pages } from "../pages";
import { saveJson } from "../util/save_file";
import { useAccount } from "../model/account";
import { DraggingContext } from "../model/is_dragging";
@@ -60,7 +59,6 @@ export default function SiteLayout() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
let account = useAccount();
let plugins = useContext(ControlContext).plugins;
const control = useContext(ControlContext);
const [dragging, setDragging] = useState(0);
const [aboutOpen, setAboutOpen] = useState(false);
@@ -110,13 +108,9 @@ export default function SiteLayout() {
],
};
let combinedPages = [...pages];
for (let plugin of plugins.values()) {
combinedPages.push(...plugin.pages);
}
let menuItems: MenuItem[] = [];
let menuGroups = new Map();
const combinedPages = [...control.pages];
for (let { sidebarName, sidebarGroup, permission, path } of combinedPages) {
if (
!sidebarName || permission && (
+1 -1
View File
@@ -1,7 +1,7 @@
/// <reference path="../global.d.ts" preserve="true" />
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";
+9 -9
View File
@@ -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<string, BaseWebPlugin>();
public inputComponents: Record<string, InputComponent> = {};
public hooks = new WebPlugin.WebHooks(logger);
public plugins = new Map<string, WebPlugin.BaseWebPlugin>();
public loadedPlugins = new Map<string, lib.PluginWebpackEnvInfo>();
public inputComponents = new Map<string, WebPlugin.InputComponent>();
public extensionComponents: Partial<WebPlugin.ExtensionComponents> = {};
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));
+50 -12
View File
@@ -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);
});
});
});
+2 -1
View File
@@ -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();
+11 -16
View File
@@ -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));
}
+6 -5
View File
@@ -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");
});
}
+40 -10
View File
@@ -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);
});
});
});
+41 -9
View File
@@ -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() {
+2 -2
View File
@@ -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;
+132
View File
@@ -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);
});
});
});
-36
View File
@@ -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");
});
});
});
+89 -35
View File
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import * as mock from "../mock.js";
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
@@ -72,51 +73,104 @@ describe("lib/plugin_loader", function() {
);
});
});
describe("loadPluginClass()", function() {
let baseDir = path.join("temp", "test", "plugin");
let missingClass = path.join(baseDir, "missing_class_plugin");
let wrongParentClass = path.join(baseDir, "wrong_parent_class_plugin");
describe("loadPlugin()", function() {
const baseDir = path.join("temp", "test", "plugin");
const functionPlugin = path.resolve(baseDir, "function_plugin");
const classPlugin = path.resolve(baseDir, "class_plugin");
const throwingClassPlugin = path.resolve(baseDir, "throwing_class_plugin");
const missingClassPlugin = path.resolve(baseDir, "missing_class_plugin");
const wrongParentClassPlugin = path.resolve(baseDir, "wrong_parent_class_plugin");
let controller;
let logger;
let context;
before(async function() {
async function writeEntrypoint(pluginPath, content) {
await fs.mkdir(pluginPath, { recursive: true });
await fs.writeFile(path.join(pluginPath, "controller.js"), content);
}
await writeEntrypoint(missingClass, "");
await writeEntrypoint(
wrongParentClass, "export class ControllerPlugin {};\n"
await writeEntrypoint(functionPlugin, `
export default async function(context) {
context.controller.loadedWith = context;
}
`);
await writeEntrypoint(classPlugin, `
import { BaseControllerPlugin } from "@clusterio/controller";
export class ControllerPlugin extends BaseControllerPlugin {
async init() { this.controller.loadedWith = this; }
async onSaveData() { }
}
`);
await writeEntrypoint(throwingClassPlugin, `
import { BaseControllerPlugin } from "@clusterio/controller";
export class ControllerPlugin extends BaseControllerPlugin {
async init() { throw new Error("init failed"); }
async onSaveData() { }
}
`);
await writeEntrypoint(missingClassPlugin, "");
await writeEntrypoint(wrongParentClassPlugin, "export class ControllerPlugin {};\n");
});
beforeEach(function() {
controller = new mock.MockController();
logger = new mock.MockLogger();
logger.warnings = [];
logger.warn = msg => logger.warnings.push(msg);
context = { controller, metrics: {}, logger };
});
function info(requirePath, entrypoint = "controller.js") {
return { name: "test", requirePath, controllerEntrypoint: entrypoint };
}
it("should do nothing when the entrypoint is not set", async function() {
await lib.loadPlugin(
{ name: "test", requirePath: missingClassPlugin }, "controller", context,
"ControllerPlugin", BaseControllerPlugin,
);
assert.equal(controller.loadedWith, undefined);
});
it("should call the default export with the load context", async function() {
const pluginInfo = info(functionPlugin);
await lib.loadPlugin(pluginInfo, "controller", context, "ControllerPlugin", BaseControllerPlugin);
assert.equal(controller.loadedWith.controller, controller);
assert.equal(controller.loadedWith.plugin, pluginInfo);
assert.equal(controller.loadedWith.logger, logger);
assert.deepEqual(logger.warnings, []);
});
it("should load a deprecated class export", async function() {
const pluginInfo = info(classPlugin);
await lib.loadPlugin(pluginInfo, "controller", context, "ControllerPlugin", BaseControllerPlugin);
assert(controller.loadedWith instanceof BaseControllerPlugin);
assert.equal(controller.loadedWith.info, pluginInfo);
assert.deepEqual([...controller.hooks.save.attached], ["test"]);
assert.deepEqual(logger.warnings, ["Plugin test is using deprecated class export"]);
});
it("should detach hooks if init throws", async function() {
await assert.rejects(
lib.loadPlugin(
info(throwingClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin
),
new Error("init failed")
);
assert.equal(controller.hooks.size, 0);
});
it("should throw if neither a function nor a class is exported", async function() {
await assert.rejects(
lib.loadPlugin(
info(missingClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin
),
new Error("Plugin test must export either a default function or ControllerPlugin class")
);
});
it("should throw if class is missing from entrypoint", async function() {
const requirePath = path.resolve(missingClass);
it("should throw if the class is not a subclass of the base class", async function() {
await assert.rejects(
lib.loadPluginClass(
"test",
path.posix.join(requirePath, "controller.js"),
"ControllerPlugin",
BaseControllerPlugin,
lib.loadPlugin(
info(wrongParentClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin
),
{
message:
`PluginError: Expected ${path.posix.join(requirePath, "controller.js")} ` +
"to export a class named ControllerPlugin",
}
);
});
it("should throw if class is not a subclass of BaseControllerPlugin", async function() {
const requirePath = path.resolve(wrongParentClass);
await assert.rejects(
lib.loadPluginClass(
"test",
path.posix.join(requirePath, "controller.js"),
"ControllerPlugin",
BaseControllerPlugin,
),
{
message:
"PluginError: Expected ControllerPlugin exported from " +
`${path.posix.join(requirePath, "controller.js")} to be a subclass of BaseControllerPlugin`,
}
new Error("Expected ControllerPlugin exported from test to extend BaseControllerPlugin")
);
});
});
+2 -2
View File
@@ -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());
+9 -2
View File
@@ -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;
}