diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index e0e3405e..6888df99 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -95,8 +95,8 @@ The following properties are recognized: Subclass of `PluginConfigGroup` for defining the controller configuration fields for this plugin. See [Plugin Configuration](#plugin-configuration) -**controlEntrypoint**: - Path to a Node.js module relative to the plugin directory which contains the ControlPlugin class definition for this plugin. +**ctlEntrypoint**: + Path to a Node.js module relative to the plugin directory which contains the CtlPlugin class definition for this plugin. This is an optional paramater. A plugin can be made that only runs on the clusterioctl side. @@ -120,9 +120,9 @@ The plugin class should derive from its respective base class defined in `lib/pl For example, to define a ControllerPlugin class the following code can be used: ```js -const lib = require("@clusterio/lib"); +const { BaseControllerPlugin } = require("@clusterio/controller"); -class ControllerPlugin extends lib.BaseControllerPlugin { +class ControllerPlugin extends BaseControllerPlugin { async init() { this.foo = 42; await this.startFrobnication(); @@ -136,8 +136,8 @@ module.exports = { } ``` -For the instance plugin it's exactly the same except "Controller" is replaced with "Instance", and for the clusterioctl plugin "Control" is used. -The available hooks that you can override are documented in the base class [in lib/plugin.js](/packages/lib/plugin.js). +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. It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class. E.g.: @@ -602,18 +602,18 @@ It's possible to optain a reference to the plugin class with `control.plugins.ge 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 Control plugin: +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: ```js -const lib = require("@clusterio/lib"); +const { BaseCtlPlugin } = require("@clusterio/ctl"); -class ControlPlugin extends lib.BaseControlPlugin { +class CtlPlugin extends BaseCtlPlugin { async addCommands(rootCommand) { rootCommand.add(fooFrobberCommands); } } module.exports = { - ControlPlugin, + CtlPlugin, } ``` \ No newline at end of file diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index 17039a85..e177a12d 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -420,10 +420,7 @@ async function startup() { await controller.start(args); } -module.exports = { -}; - -if (module === require.main) { +export function bootstrap() { // eslint-disable-next-line no-console console.warn(` +==========================================================+ @@ -460,3 +457,7 @@ ${err.stack}` process.exitCode = 1; }); } + +if (module === require.main) { + bootstrap(); +} diff --git a/packages/controller/index.ts b/packages/controller/index.ts new file mode 100644 index 00000000..cd3388a7 --- /dev/null +++ b/packages/controller/index.ts @@ -0,0 +1,9 @@ +import { bootstrap } from "./controller"; +export { default as Controller } from "./src/Controller"; +export { default as ControlConnection } from "./src/ControlConnection"; +export { default as InstanceInfo } from "./src/InstanceInfo"; +export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin"; + +if (module === require.main) { + bootstrap(); +} diff --git a/packages/controller/package.json b/packages/controller/package.json index 7161c89e..cb9cad53 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -4,7 +4,7 @@ "author": "Hornwitser", "license": "MIT", "version": "2.0.0-alpha.13", - "main": "dist/controller.js", + "main": "dist/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --env production", "test": "echo \"Error: no test specified\" && exit 1" diff --git a/packages/controller/src/BaseControllerPlugin.ts b/packages/controller/src/BaseControllerPlugin.ts new file mode 100644 index 00000000..1beb1728 --- /dev/null +++ b/packages/controller/src/BaseControllerPlugin.ts @@ -0,0 +1,274 @@ +import type { + CollectorResult, ConfigGroup, Event, InstanceStatus, Logger, + ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo, +} from "@clusterio/lib"; +import type Controller from "./Controller"; +import type InstanceInfo from "./InstanceInfo"; +import type ControlConnection from "./ControlConnection"; +import type HostConnection from "./HostConnection"; + +/** + * 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 info.js + * file. + */ +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; + } + + /** + * Called immediately after the class is instantiated + */ + async init() { } + + /** + * Called when the status of an instance changes + * + * Invoked when the controller has changed the status of an instance + * or received notice from a host that the status of an instance has + * changed. The possible statuses that can be notified about are: + * - `unassigned:`: Instance is no longer asssigned to a host. + * - `unknown`: Host assigned to instance is offline. + * - `stopped`: Instance is no longer running or was just assigned to a + * host. + * - `starting`: Instance is in the process of starting up. + * - `running`: Instance startup completed and is now running. + * - `stopping`: Instance is in the processing stopping. + * - `creating_save`: Instance is in the process of creating a save. + * - `exporting_data`: Instance is in the process of exporting item + * icons and locale data. + * - `deleted`: Instance was deleted. + * + * On controller startup all known instances gets the status `unknown` if + * they are assigned to a host, when the host then connects to the + * controller and informs of the current status of its instances this hook + * is invoked for all those instances. You can detect this situation by + * checking if prev equals `unknown`. If the host disconnects the + * instances will again get the `unknown` status. + * + * When instances are created on the controller they will notify of a status + * change with prev set to null. While the status of new instances is + * in most cases `unassigned` it's possible for the created instance to + * start with any state in some host connection corner cases. + * + * Note that it's possible for status change notification to get lost in + * the case of network outages if reconnect fails to re-establish the + * session between the controller and the host. + * + * @param instance - + * The instance that changed. + * @param prev - the previous status of the instance. + */ + async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { } + + /** + * Called when the value of a controller config field changed. + * + * Invoked after the value of the config field given by `field` has + * changed on the controller. + * + * @param group - + * The group who's field got changed. + * @param field - Name of the field that changed. + * @param prev - The previous value of the field. + */ + async onControllerConfigFieldChanged(group: ConfigGroup, field: string, prev: unknown) { } + + /** + * Called when the value of an instance config field changed. + * + * Invoked after the value of the config field given by `field` has + * changed on an instance. + * + * @param instance - + * The instance the config changed on. + * @param group - + * The group who's field got changed. + * @param field - Name of the field that changed. + * @param prev - The previous value of the field. + */ + async onInstanceConfigFieldChanged(instance: InstanceInfo, group: ConfigGroup, field: string, prev: unknown) { } + + /** + * Called before collecting Prometheus metrics + * + * Invoked before the default metrics of prometheus is collected. Note + * that since this is done while replying to /metrics this should not + * take a long time, otherwise the requst will time out. + * + * Although you can return the results of collecting your prometheus + * collectors directly, all gauges in the default registry is already + * automatically collected. It's therefore recommended to only update + * the Gauges/Counters/etc in onMetric and let Clusterio deal will + * collecting the values. + * + * @returns an async iterator of prometheus metric results or undefined. + */ + async onMetrics(): Promise> { } + + /** + * Called when the controller is shutting down + */ + async onShutdown() { } + + /** + * Called when an event on a host connection happens + * + * The event param may be one of connect, drop, resume and close and has + * the following meaning: + * + * ##### connect + * + * Invoked when a new connection to the host has been established. + * + * ##### drop + * + * Invoked when a connection loss is detected between the controller and a + * host. Plugins should respond to this event by throtteling messages + * it is sending to the given host connection to an absolute minimum. + * + * Messages sent over a dropped host connection will get queued up in + * memory on the controller and sent all in one go when the connection is + * re-established again. + * + * ##### resume + * + * Invoked when a connection that had previously been dropped is + * re-established + * + * ##### close + * + * Invoked when the connection to the host has been closed. + * + * @param connection - + * The connection the event occured on. + * @param event - one of connect, drop, resume and close + */ + onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { } + + /** + * Called when an avent on a control connection happens + * + * The event param may be one of connect, drop, resume and close and has + * the following meaning: + * + * ##### connect + * + * Invoked when a new connection to the control has been established. + * + * ##### drop + * + * Invoked when a connection loss is detected between the controller and a + * control. Plugins should respond to this event by throtteling + * messages it is sending to the given control connection to an absolute + * minimum. + * + * Messages sent over a dropped control connection will get queued up in + * memory on the controller and sent all in one go when the connection is + * re-established again. + * + * ##### resume + * + * Invoked when a connection that had previously been dropped is + * re-established. + * + * ##### close + * + * Invoked when the connection to the control has been closed. + * + * @param connection - + * The connection the event occured on. + * @param event - one of connect, drop, resume, and close. + */ + onControlConnectionEvent(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close") { } + + /** + * Called when a host is preparing to disconnect from the controller + * + * Invoked when a host has requested the disconnection from the controller. + * This typically happens when it is in the process of shutting down. + * + * Plugins must stop sending messages to the host in question after the + * prepare disconnect has been handled. + * + * @param connection - + * The connection to the host preparing to disconnect. + */ + async onPrepareHostDisconnect(connection: HostConnection) { } + + /** + * Called when a mod pack is updated + * + * Invoked when a mod pack has been added, updated or deleted in the + * cluster. + * + * If the mod pack has been deleted its `.isDeleted` property will be + * true. + * + * @param modPack - Mod pack that updated. + */ + async onModPackUpdated(modPack: ModPack) { } + + /** + * Called when a mod stored on the controller is updated + * + * Invoked when a mod has been added, updated or deleted from the pool + * of shared mods stored on the cluster. + * + * If a mod has been deleted its `.isDeleted` property will be true. + * + * @param mod - Mod that updated. + */ + async onModUpdated(mod: ModInfo) { } + + /** + * Called when a player joins or leaves an instance + * + * Invoked when a player either joins or leaves an instance in the + * cluster. + * + * @param instance - + * The instance it occured on. + * @param event - Information about the event. + */ + async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { } + + /** + * Broadcast event to all connected hosts + * + * Sends the given event to all hosts connected to the controller. + * This does not include hosts that are in the process of closing the + * connection, which typically happens when they are shutting down. + * + * @param event - Event to send + */ + broadcastEventToHosts(event: Event) { + for (let hostConnection of this.controller.wsServer.hostConnections.values()) { + if ( + !hostConnection.connector.closing + && (!event.constructor.plugin || hostConnection.plugins.has(event.constructor.plugin)) + ) { + hostConnection.send(event); + } + } + } +} diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 48a31b3a..8d26c24e 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -22,6 +22,7 @@ import * as routes from "./routes"; import UserManager from "./UserManager"; import WsServer from "./WsServer"; import HostConnection, { type HostInfo } from "./HostConnection"; +import BaseControllerPlugin from "./BaseControllerPlugin"; const endpointDurationSummary = new Summary( "clusterio_controller_http_endpoint_duration_seconds", @@ -64,7 +65,7 @@ export default class Controller { httpsServerCloser: HttpCloser | null = null; /** Mapping of plugin name to loaded plugin */ - plugins: Map = new Map(); + plugins: Map = new Map(); /** WebSocket server */ wsServer: WsServer; @@ -660,7 +661,7 @@ export default class Controller { let instance = new InstanceInfo({ config: instanceConfig, status: "unassigned" }); this.instances!.set(instanceId, instance); - await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance, null); + await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance); this.addInstanceHooks(instance); return instance; } @@ -877,10 +878,15 @@ export default class Controller { continue; } - let ControllerPluginClass = lib.BaseControllerPlugin; + let ControllerPluginClass = BaseControllerPlugin; try { if (pluginInfo.controllerEntrypoint) { - ControllerPluginClass = await lib.loadControllerPluginClass(pluginInfo); + ControllerPluginClass = await lib.loadPluginClass( + pluginInfo.name, + path.posix.join(pluginInfo.requirePath, pluginInfo.controllerEntrypoint), + "ControllerPlugin", + BaseControllerPlugin, + ); } let controllerPlugin = new ControllerPluginClass(pluginInfo, this, metrics as any, logger); diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 76a7019b..5d4f3b13 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -233,7 +233,7 @@ export default class HostConnection extends BaseConnection { instanceConfig.get("instance.id"), instanceConfig.serialize("host") ) ); - await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance, null); + await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance); } // Push lists to make sure they are in sync. @@ -289,11 +289,12 @@ export default class HostConnection extends BaseConnection { user.recalculatePlayerStats(); this._controller.userUpdated(user); - let instance = this._controller.instances!.get(instanceId); + let instance = this._controller.instances!.get(instanceId)!; await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { type: event.type, name: event.name, reason: event.reason, + stats: event.stats, }); } } diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index 1c9c441c..d5f9dfbe 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -37,8 +37,7 @@ async function getMetrics(req: Request, res: Response, next: any) { const controller: Controller = req.app.locals.controller; let results: lib.CollectorResult[] = []; - type ResultIterator = AsyncIterable | Iterable - let pluginResults = await lib.invokeHook(controller.plugins, "onMetrics") as ResultIterator[]; + let pluginResults = await lib.invokeHook(controller.plugins, "onMetrics"); for (let metricIterator of pluginResults) { for await (let metric of metricIterator) { results.push(metric); diff --git a/packages/controller/tsconfig.json b/packages/controller/tsconfig.json index 51e67a4a..335b8b16 100644 --- a/packages/controller/tsconfig.json +++ b/packages/controller/tsconfig.json @@ -3,7 +3,7 @@ "references": [ { "path": "../lib" }, ], - "include": ["controller.ts", "package.json", "src/**/*.ts"], + "include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"], "compilerOptions": { "outDir": "dist", }, diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index 8cb5bd4d..86512ff7 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -6,6 +6,7 @@ */ import fs from "fs-extra"; import yargs from "yargs"; +import path from "path"; import { version } from "./package.json"; import setBlocking from "set-blocking"; import { strict as assert } from "assert"; @@ -16,6 +17,7 @@ import * as lib from "@clusterio/lib"; import { ConsoleTransport, levels, logger } from "@clusterio/lib"; import * as commands from "./src/commands"; +import BaseCtlPlugin from "./src/BaseCtlPlugin"; /** @@ -56,7 +58,7 @@ export class Control extends lib.Link { /** Certificate authority used to validate TLS connections to the controller. */ tlsCa?: string; /** Mapping of plugin names to their instance for loaded plugins. */ - plugins: Map; + plugins: Map; /** Keep the control connection alive after the command completes. */ keepOpen = false; @@ -64,12 +66,12 @@ export class Control extends lib.Link { connector: ControlConnector, controlConfig: lib.ControlConfig, tlsCa: string | undefined, - controlPlugins: Map + ctlPlugins: Map ) { super(connector); this.config = controlConfig; this.tlsCa = tlsCa; - this.plugins = controlPlugins; + this.plugins = ctlPlugins; this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this)); this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this)); @@ -115,18 +117,23 @@ async function loadPlugins(pluginList: Map) { lib.registerPluginConfigGroups(pluginInfos); lib.finalizeConfigs(); - let controlPlugins = new Map(); + let ctlPlugins = new Map(); for (let pluginInfo of pluginInfos) { - if (!pluginInfo.controlEntrypoint) { + if (!pluginInfo.ctlEntrypoint) { continue; } - let ControlPluginClass = await lib.loadControlPluginClass(pluginInfo); - let controlPlugin = new ControlPluginClass(pluginInfo, logger); - controlPlugins.set(pluginInfo.name, controlPlugin); - await controlPlugin.init(); + 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(); } - return controlPlugins; + return ctlPlugins; } interface CtlArguments { @@ -199,10 +206,10 @@ async function startControl() { } logger.verbose("Loading Plugins"); - let controlPlugins = await loadPlugins(pluginList); + let ctlPlugins = await loadPlugins(pluginList); // Add all cluster management commands including ones from plugins - let rootCommands = await commands.registerCommands(controlPlugins, yargs); + let rootCommands = await commands.registerCommands(ctlPlugins, yargs); // Reparse after commands have been added with help and strict checking. args = yargs @@ -264,7 +271,7 @@ async function startControl() { tlsCa, controlConfig.get("control.controller_token") as string, ); - let control = new Control(controlConnector, controlConfig, tlsCa, controlPlugins); + let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins); try { await controlConnector.connect(); } catch (err) { @@ -312,8 +319,7 @@ async function startControl() { } } - -if (module === require.main) { +export function bootstrap() { // eslint-disable-next-line no-console console.warn(` +==========================================================+ @@ -346,3 +352,7 @@ ${err.stack}` process.exitCode = 1; }); } + +if (module === require.main) { + bootstrap(); +} diff --git a/packages/ctl/index.ts b/packages/ctl/index.ts new file mode 100644 index 00000000..6a70dd21 --- /dev/null +++ b/packages/ctl/index.ts @@ -0,0 +1,6 @@ +import { bootstrap } from "./ctl"; +export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin"; + +if (module === require.main) { + bootstrap(); +} diff --git a/packages/ctl/package.json b/packages/ctl/package.json index 8efcadd1..abadf277 100644 --- a/packages/ctl/package.json +++ b/packages/ctl/package.json @@ -4,7 +4,7 @@ "author": "Hornwitser", "license": "MIT", "version": "2.0.0-alpha.13", - "main": "dist/ctl.js", + "main": "dist/index.js", "scripts": { "prepare": "tsc --build", "test": "echo \"Error: no test specified\" && exit 1" diff --git a/packages/ctl/src/BaseCtlPlugin.ts b/packages/ctl/src/BaseCtlPlugin.ts new file mode 100644 index 00000000..7171f86a --- /dev/null +++ b/packages/ctl/src/BaseCtlPlugin.ts @@ -0,0 +1,47 @@ +import type { CommandTree, Logger, PluginNodeEnvInfo } from "@clusterio/lib"; + +/** + * 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 info.js file. + */ +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; + } + + /** + * Called immediately after the class is instantiated + */ + async init() { } + + /** + * Called to add commands to the command line interface. + * + * Invoked by clusterioctl to let plugins add commands. `rootCommand` is + * the top level command node which the plugin should add its own {@link + * CommandTree} to. + * + * @param rootCommand - + * Root of the clusterioctl command tree. + */ + async addCommands(rootCommand: CommandTree) { } +} diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 89eb5145..1a76859e 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -13,6 +13,7 @@ import type { Argv } from "yargs"; import * as lib from "@clusterio/lib"; import { ConsoleTransport, levels, logger } from "@clusterio/lib"; import type { Control } from "../ctl"; +import type BaseCtlPlugin from "./BaseCtlPlugin"; const asTable = asTableModule.configure({ delimiter: " | " }); const finished = util.promisify(stream.finished); @@ -1722,7 +1723,7 @@ debugCommands.add(new lib.Command({ }, })); -export async function registerCommands(controlPlugins: Map, yargs: Argv) { +export async function registerCommands(ctlPlugins: Map, yargs: Argv) { const rootCommands = new lib.CommandTree({ name: "clusterioctl", description: "Manage cluster" }); rootCommands.add(controllerCommands); rootCommands.add(hostCommands); @@ -1735,7 +1736,7 @@ export async function registerCommands(controlPlugins: Map { logger.add(new lib.LinkTransport({ link: host })); }); @@ -245,7 +252,7 @@ async function startHost() { logger.info("Started host"); } -if (module === require.main) { +export function bootstrap() { // eslint-disable-next-line no-console console.warn(` +==========================================================+ @@ -287,3 +294,7 @@ ${err.stack}` process.exitCode = 1; }); } + +if (module === require.main) { + bootstrap(); +} diff --git a/packages/host/index.ts b/packages/host/index.ts new file mode 100644 index 00000000..2b01bc21 --- /dev/null +++ b/packages/host/index.ts @@ -0,0 +1,7 @@ +import { bootstrap } from "./host"; +export { default as BaseHostPlugin } from "./src/BaseHostPlugin"; +export { default as BaseInstancePlugin } from "./src/BaseInstancePlugin"; + +if (module === require.main) { + bootstrap(); +} diff --git a/packages/host/package.json b/packages/host/package.json index 4baa983f..2df4aab9 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -4,7 +4,7 @@ "author": "Hornwitser", "license": "MIT", "version": "2.0.0-alpha.13", - "main": "dist/host.js", + "main": "dist/index.js", "scripts": { "prepare": "tsc --build", "test": "echo \"Error: no test specified\" && exit 1" diff --git a/packages/host/src/BaseHostPlugin.ts b/packages/host/src/BaseHostPlugin.ts new file mode 100644 index 00000000..d161fcab --- /dev/null +++ b/packages/host/src/BaseHostPlugin.ts @@ -0,0 +1,128 @@ +import type { + ConfigGroup, CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo, +} from "@clusterio/lib"; +import type Host from "./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 info.js file. + */ +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; + } + + /** + * Called immediately after the class is instantiated + */ + async init() { } + + /** + * Called when the value of a host config field changed. + * + * Invoked after the value of the config field given by `field` has + * changed on this host. + * + * @param group - + * The group who's field got changed. + * @param field - Name of the field that changed. + * @param prev - The previous value of the field. + */ + async onHostConfigFieldChanged(group: ConfigGroup, field: string, prev: unknown) { } + + /** + * Called before collecting Prometheus metrics + * + * Invoked before the default metrics of prometheus is collected. Note + * that since this is done while replying to /metrics this should not + * take a long time, otherwise the requst will time out. + * + * Although you can return the results of collecting your prometheus + * collectors directly, all gauges in the default registry is already + * automatically collected. It's therefore recommended to only update + * the Gauges/Counters/etc in onMetric and let Clusterio deal will + * collecting the values. + * + * @returns an async iterator of prometheus metric results or undefined. + */ + async onMetrics(): Promise> { } + + /** + * Called when the host is shutting down + */ + async onShutdown() { } + + /** + * Called when an event on the controller connection happens + * + * The event param may be one of connect, drop, resume and close and has + * the following meaning: + * + * ##### connect + * + * Invoked when a new connection to the controller has been established. + * + * ##### drop + * + * Invoked when a connection loss is detected between the host and the + * controller. Plugins should respond to this event by throtteling messages + * it is sending to the controller to an absolute minimum. + * + * Messages sent over a dropped controller connection will get queued up in + * memory on the host and sent all in one go when the connection is + * re-established again. + * + * ##### resume + * + * Invoked when the connection that had previously dropped is + * re-established. + * + * ##### close + * + * Invoked when the connection to the controller has been closed. This + * typically means the controller has shut down. Plugins should not + * send any messages that goes to or via the controller after the + * connection has been closed and before a new one is established. + * + * @param event - one of connect, drop, resume and close + */ + onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { } + + /** + * Called when the controller is preparing to disconnect from the host + * + * Invoked when the controller has requested the disconnection from + * the host. This typically happens when it is in the process of + * shutting down. + * + * Plugins must stop sending messages to the controller, or are forwarded + * via the controller after the prepare disconnect has been handled. + * + * @param connection - + * The connection to the host preparing to disconnect. + */ + async onPrepareControllerDisconnect(connection: Host) { } +} diff --git a/packages/host/src/BaseInstancePlugin.ts b/packages/host/src/BaseInstancePlugin.ts new file mode 100644 index 00000000..dd5f929d --- /dev/null +++ b/packages/host/src/BaseInstancePlugin.ts @@ -0,0 +1,257 @@ +import type { + ConfigGroup, CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo, +} from "@clusterio/lib"; +import type Instance from "./Instance"; +import type Host from "./Host"; + +/** + * 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 info.js file. + * + * 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. + */ +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; + } + + /** + * Called immediately after the class is instantiated + */ + async init() { } + + /** + * Called when the value of a config field changed. + * + * Invoked after the value of the config field given by `field` has + * changed. + * + * @param group - + * The group who's field got changed. + * @param field - Name of the field that changed. + * @param prev - The previous value of the field. + */ + async onInstanceConfigFieldChanged(group: ConfigGroup, field: string, prev: unknown) { } + + /** + * Called before collecting Prometheus metrics + * + * Invoked before the default metrics of prometheus is collected. Note + * that since this is done while replying to /metrics this should not + * take a long time, otherwise the requst will time out. + * + * Although you can return the results of collecting your prometheus + * collectors directly, all gauges in the default registry is already + * automatically collected. It's therefore recommended to only update + * the Gauges/Counters/etc in onMetric and let Clusterio deal will + * collecting the values. + * + * @returns an async iterator of prometheus metric results or undefined. + */ + async onMetrics(): Promise> { } + + /** + * Called after the Factorio server is started + */ + async onStart() { } + + /** + * Called before the Factorio server is stopped + * + * This will not be called if for example the Factorio server crashes or + * is killed. + */ + async onStop() { } + + /** + * Called when the instance exits + * + * Invoked when the instance is shut down. This may occur after init + * has been called if an error occurs during startup. Note that if + * the plugin's init() throws this method will still be invoked. + */ + onExit() { } + + /** + * Called when the Factorio outputs a line + * + * Called when the Factorio server outputs a line on either stdout or + * stderr. The output is a parsed form of the line and contains the + * following properties: + * + * @param parsed - parsed server output. + * @param line - raw line of server output. + */ + async onOutput(parsed: ParsedFactorioOutput, line: string) { } + + /** + * Called when an event on the controller connection happens + * + * The event param may be one of connect, drop, resume and close and has + * the following meaning: + * + * ##### connect + * + * Invoked when a new connection to the controller has been established. + * + * ##### drop + * + * Invoked when a connection loss is detected between the host and the + * controller. Plugins should respond to this event by throtteling messages + * it is sending to the controller to an absolute minimum. + * + * Messages sent over a dropped controller connection will get queued up in + * memory on the host and sent all in one go when the connection is + * re-established again. + * + * ##### resume + * + * Invoked when the connection that had previously dropped is + * re-established. + * + * ##### close + * + * Invoked when the connection to the controller has been closed. This + * typically means the controller has shut down. Plugins should not + * send any messages that goes to or via the controller after the + * connection has been closed and before a new one is established. + * + * @param event - one of connect, drop, resume and close + */ + onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { } + + /** + * Called when the controller is preparing to disconnect from the host + * + * Invoked when the controller has requested the disconnection from + * the host. This typically happens when it is in the process of + * shutting down. + * + * Plugins must stop sending messages to the controller, or are forwarded + * via the controller after the prepare disconnect has been handled. + * + * @param connection - + * The connection to the host preparing to disconnect. + */ + async onPrepareControllerDisconnect(connection: Instance) { } + + /** + * Called when a player joins or leaves the game + * + * Invoked when a player either joins or leaves the instance. + * + * @param event - Information about the event. + */ + async onPlayerEvent(event: PlayerEvent) { } + + /** + * Send RCON message to instance + * + * Send a message or command to the server which can potentially be + * executed out of order in relation to other RCON commands. + * + * This should not be called before onStart or after onStop. A simple + * way to achieve this is to check that the instance status is running + * before sending commands. Note that the instance status is set to + * running some time after `onStart` and some time before `onStop` is + * invoked. + * + * @param message - message to send to server over RCON. + * @param expectEmpty - + * if true throw if the response is not empty. Useful for detecting + * errors that might have been sent in response. + * @returns response from server. + */ + async sendRcon(message: string, expectEmpty=false): Promise { + return await this.instance.sendRcon(message, expectEmpty, this.info.name); + } + + /** + * Send serially ordered RCON message to instance + * + * Send a message or command to the server which will not be executed + * out of order in relation to other RCON commands sent with this + * method. The ordering applies per plugin, and two plugins sending + * commands with this method may execute out of order in relation to + * each other. + * + * This should not be called before onStart or after onStop. A simple + * way to achieve this is to check that the instance status is running + * before sending commands. Note that the instance status is set to + * running some time after `onStart` and some time before `onStop` is + * invoked. + * + * @param message - message to send to server over RCON. + * @param [expectEmpty=false] - + * if true throw if the response is not empty. Useful for detecting + * errors that might have been sent in response. + * @returns response from server. + */ + async sendOrderedRcon(message: string, expectEmpty=false) { + let promise = new Promise((resolve, reject) => { + this._pendingRconMessages.push({resolve, reject, message, expectEmpty}); + }); + if (!this._sendingRconMessages) { + this._sendPendingRconMessages(); + } + return await promise; + } + + async _sendPendingRconMessages() { + this._sendingRconMessages = true; + while (this._pendingRconMessages.length) { + let task = this._pendingRconMessages.shift()!; + try { + let result = await this.sendRcon(task.message, task.expectEmpty); + task.resolve(result); + } catch (err: any) { + task.reject(err); + } + } + this._sendingRconMessages = false; + } +} diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 2d80952c..c170e1ad 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -14,6 +14,7 @@ import { logger } from "@clusterio/lib"; import type { HostConnector } from "../host"; import Instance from "./Instance"; import InstanceConnection from "./InstanceConnection"; +import BaseHostPlugin from "./BaseHostPlugin"; const finished = util.promisify(stream.finished); @@ -241,8 +242,12 @@ export default class Host extends lib.Link { */ tlsCa?: string; pluginInfos: lib.PluginNodeEnvInfo[]; + configPath: string; config: lib.HostConfig; + /** Mapping of plugin name to loaded plugin */ + plugins: Map = new Map(); + instanceConnections = new Map(); discoveredInstanceInfos = new Map(); instanceInfos = new Map(); @@ -260,6 +265,7 @@ export default class Host extends lib.Link { constructor( connector: HostConnector, + hostConfigPath: string, hostConfig: lib.HostConfig, tlsCa: string | undefined, pluginInfos: lib.PluginNodeEnvInfo[] @@ -268,8 +274,13 @@ export default class Host extends lib.Link { this.tlsCa = tlsCa; this.pluginInfos = pluginInfos; + this.configPath = hostConfigPath; this.config = hostConfig; + this.config.on("fieldChanged", (group, field, prev) => { + lib.invokeHook(this.plugins, "onHostConfigFieldChanged", group, field, prev); + }); + this.connector.on("hello", data => { this.serverVersion = data.version; this.serverPlugins = new Map(Object.entries(data.plugins)); @@ -308,12 +319,15 @@ export default class Host extends lib.Link { } }); - for (let event of ["connect", "drop", "resume", "close"]) { + for (let event of ["connect", "drop", "resume", "close"] as const) { this.connector.on(event, () => { let message = new lib.ControllerConnectionEvent(event); for (let instanceConnection of this.instanceConnections.values()) { instanceConnection.send(message); } + for (let plugin of this.plugins.values()) { + plugin.onControllerConnectionEvent(event); + } }); } @@ -334,6 +348,35 @@ export default class Host extends lib.Link { this.handle(lib.InstanceDeleteInternalRequest, this.handleInstanceDeleteInternalRequest.bind(this)); } + async loadPlugins() { + for (let pluginInfo of this.pluginInfos) { + if (!this.config.group(pluginInfo.name).get("load_plugin")) { + continue; + } + + let HostPluginClass = BaseHostPlugin; + try { + if (pluginInfo.hostEntrypoint) { + HostPluginClass = await lib.loadPluginClass( + pluginInfo.name, + path.posix.join(pluginInfo.requirePath, pluginInfo.hostEntrypoint), + "HostPlugin", + BaseHostPlugin, + ); + } + + let hostPlugin = new HostPluginClass(pluginInfo, this, logger); + await hostPlugin.init(); + this.plugins.set(pluginInfo.name, hostPlugin); + + } catch (err: any) { + throw new lib.PluginError(pluginInfo.name, err); + } + + logger.info(`Loaded plugin ${pluginInfo.name}`); + } + } + async _createNewInstanceDir(name: string) { name = lib.cleanFilename(name); try { @@ -551,6 +594,13 @@ export default class Host extends lib.Link { } let results = []; + let pluginResults = await lib.invokeHook(this.plugins, "onMetrics"); + for (let metricIterator of pluginResults) { + for await (let metric of metricIterator) { + results.push(metric); + } + } + for (let response of await Promise.all(requests)) { results.push(...response.results); } @@ -808,6 +858,7 @@ export default class Host extends lib.Link { } async prepareDisconnect() { + await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect", this); for (let instanceConnection of this.instanceConnections.values()) { await instanceConnection.send(new lib.PrepareControllerDisconnectRequest()); } @@ -824,6 +875,8 @@ export default class Host extends lib.Link { } this._shuttingDown = true; + await lib.invokeHook(this.plugins, "onShutdown"); + for (let instanceConnection of this.instanceConnections.values()) { try { await instanceConnection.instance.stop(); @@ -840,6 +893,9 @@ export default class Host extends lib.Link { } } + logger.info("Saving config"); + await lib.safeOutputFile(this.configPath, JSON.stringify(this.config.serialize(), null, "\t")); + try { // Clear silly interval in pidfile library. pidusage.clear(); diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index e25266c2..39945525 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -12,6 +12,7 @@ import { FactorioServer } from "./server"; import { SaveModule, patch } from "./patch"; import { exportData } from "./export"; import type Host from "./Host"; +import BaseInstancePlugin from "./BaseInstancePlugin"; const instanceRconCommandDuration = new lib.Histogram( @@ -100,7 +101,7 @@ export default class Instance extends lib.Link { * ID of this instance, equivalenet to `instance.config.get("instance.id")`. */ readonly id: number; - plugins: Map; + plugins: Map; config: lib.InstanceConfig; logger: lib.Logger; server: FactorioServer; @@ -302,13 +303,12 @@ export default class Instance extends lib.Link { stats.lastJoinAt = new Date(); stats.joinCount += 1; - let event = { - instance_id: this.id, + let event: lib.PlayerEvent = { type: "join", name, stats, }; - this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, undefined, stats)); + this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats)); lib.invokeHook(this.plugins, "onPlayerEvent", event); } @@ -323,14 +323,13 @@ export default class Instance extends lib.Link { stats.onlineTimeMs += stats.lastLeaveAt.getTime() - stats.lastJoinAt!.getTime(); this._hadPlayersOnline = true; - let event = { - instance_id: this.id, + let event: lib.PlayerEvent = { type: "leave", name, reason, stats, }; - this.sendTo("controller", new lib.InstancePlayerUpdateEvent("leave", name, reason, stats)); + this.sendTo("controller", new lib.InstancePlayerUpdateEvent("leave", name, stats, reason)); lib.invokeHook(this.plugins, "onPlayerEvent", event); } @@ -352,13 +351,12 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } stats.onlineTimeMs = onlineTimeTicks * 1000 / 60; - let event = { - instance_id: this.id, + let event: lib.PlayerEvent = { type: "import", name, - stats: stats.toJSON(), + stats, }; - this.sendTo("controller", new lib.InstancePlayerUpdateEvent("import", name, undefined, stats)); + this.sendTo("controller", new lib.InstancePlayerUpdateEvent("import", name, stats)); lib.invokeHook(this.plugins, "onPlayerEvent", event); count += 1; } @@ -492,7 +490,12 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); async _loadPlugin(pluginInfo: lib.PluginNodeEnvInfo, host: Host) { let pluginLoadStarted = Date.now(); - let InstancePluginClass = await lib.loadInstancePluginClass(pluginInfo); + 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(); @@ -962,14 +965,13 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } async handlePrepareControllerDisconnectRequest() { - await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect"); + await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect", this); } async handleInstanceMetricsRequest() { let results: ReturnType[] = []; if (!["stopped", "stopping"].includes(this._status)) { - type ResultIterator = AsyncIterable | Iterable - let pluginResults = await lib.invokeHook(this.plugins, "onMetrics") as ResultIterator[]; + let pluginResults = await lib.invokeHook(this.plugins, "onMetrics"); for (let metricIterator of pluginResults) { for await (let metric of metricIterator) { results.push(lib.serializeResult(metric)); diff --git a/packages/host/src/patch.ts b/packages/host/src/patch.ts index 620aca9b..c1f9c085 100644 --- a/packages/host/src/patch.ts +++ b/packages/host/src/patch.ts @@ -9,6 +9,8 @@ import { Type, Static } from "@sinclair/typebox"; import * as lib from "@clusterio/lib"; +import BaseInstancePlugin from "./BaseInstancePlugin"; + /** * Describes a module that can be patched into a save @@ -81,7 +83,7 @@ export class SaveModule { return module; } - static async fromPlugin(plugin: lib.BasePlugin) { + static async fromPlugin(plugin: BaseInstancePlugin) { let pluginPackagePath = require.resolve(path.posix.join(plugin.info.requirePath, "package.json")); let moduleDirectory = path.join(path.dirname(pluginPackagePath), "module"); if (!await fs.pathExists(moduleDirectory)) { diff --git a/packages/host/tsconfig.json b/packages/host/tsconfig.json index 1ad44b08..bc67208a 100644 --- a/packages/host/tsconfig.json +++ b/packages/host/tsconfig.json @@ -3,7 +3,7 @@ "references": [ { "path": "../lib" }, ], - "include": ["host.ts", "package.json", "src/**/*.ts", "src/**/*.js"], + "include": ["index.ts", "host.ts", "package.json", "src/**/*.ts", "src/**/*.js"], "compilerOptions": { "outDir": "dist", }, diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 9c878065..91a4277f 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -505,7 +505,7 @@ ControlConfig.registerGroup(ControlGroup); function validateGroup( pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo, - groupName: "ControllerConfigGroup"|"InstanceConfigGroup", + groupName: "ControllerConfigGroup" | "HostConfigGroup" | "InstanceConfigGroup", ) { if (!(pluginInfo[groupName]?.prototype instanceof classes.PluginConfigGroup)) { throw new Error( @@ -525,32 +525,31 @@ function validateGroup( * * @param {Array} pluginInfos - Array of plugin info objects. */ -export function registerPluginConfigGroups(pluginInfos: PluginNodeEnvInfo[]|PluginWebpackEnvInfo[]) { - for (let pluginInfo of pluginInfos) { - if (pluginInfo.ControllerConfigGroup) { - validateGroup(pluginInfo, "ControllerConfigGroup"); - ControllerConfig.registerGroup(pluginInfo.ControllerConfigGroup); +export function registerPluginConfigGroups(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) { + function pluginConfig( + pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo, + group: "ControllerConfigGroup" | "HostConfigGroup" | "InstanceConfigGroup", + Config: typeof classes.Config, + ) { + if (pluginInfo[group]) { + validateGroup(pluginInfo, group); + Config.registerGroup(pluginInfo[group]!); } else { - class ControllerConfigGroup extends classes.PluginConfigGroup { } - ControllerConfigGroup.defaultAccess = ["controller", "host", "control"]; - ControllerConfigGroup.groupName = pluginInfo.name; - ControllerConfigGroup.finalize(); - ControllerConfig.registerGroup(ControllerConfigGroup); + class ConfigGroup extends classes.PluginConfigGroup { } + ConfigGroup.defaultAccess = ["controller", "host", "control"]; + ConfigGroup.groupName = pluginInfo.name; + ConfigGroup.finalize(); + Config.registerGroup(ConfigGroup); + } + } + for (let pluginInfo of pluginInfos) { + pluginConfig(pluginInfo, "ControllerConfigGroup", ControllerConfig); + if (pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint) { + pluginConfig(pluginInfo, "HostConfigGroup", HostConfig); } - if (pluginInfo.instanceEntrypoint) { - if (pluginInfo.InstanceConfigGroup) { - validateGroup(pluginInfo, "InstanceConfigGroup"); - InstanceConfig.registerGroup(pluginInfo.InstanceConfigGroup); - - } else { - class InstanceConfigGroup extends classes.PluginConfigGroup { } - InstanceConfigGroup.defaultAccess = ["controller", "host", "control"]; - InstanceConfigGroup.groupName = pluginInfo.name; - InstanceConfigGroup.finalize(); - InstanceConfig.registerGroup(InstanceConfigGroup); - } + pluginConfig(pluginInfo, "InstanceConfigGroup", InstanceConfig); } } } diff --git a/packages/lib/src/data/messages_core.ts b/packages/lib/src/data/messages_core.ts index a2cab960..a5c2d54b 100644 --- a/packages/lib/src/data/messages_core.ts +++ b/packages/lib/src/data/messages_core.ts @@ -3,7 +3,6 @@ import { TSchema, Type, Static } from "@sinclair/typebox"; // eslint-disable-next-line node/no-missing-import import messageValidate from "./message_validate"; // generated file import { StringEnum } from "./composites"; -import { AccountRole } from "../plugin"; export type AddressType = "controller" | "host" | "instance" | "control" | "broadcast"; export type AddressShorthand = @@ -287,6 +286,12 @@ export class MessageRegisterControl extends Message { } }; +export type AccountRole = { + name: string; + id: number; + permissions: string[]; +}; + export class AccountDetails { constructor( public name: string, @@ -632,3 +637,28 @@ export class PingRequest { static dst = ["controller", "host", "control"] as const; static permission = null; } + +export class AccountUpdateEvent { + declare ["constructor"]: typeof AccountUpdateEvent; + static type = "event" as const; + static src = "controller" as const; + static dst = "control" as const; + + constructor( + public roles?: AccountRole[], + ) { } + + static jsonSchema = Type.Object({ + roles: Type.Array( + Type.Object({ + name: Type.String(), + id: Type.Integer(), + permissions: Type.Array(Type.String()), + }), + ), + }); + + static fromJSON(json: Static) { + return new this(json.roles); + } +} diff --git a/packages/lib/src/data/messages_host.ts b/packages/lib/src/data/messages_host.ts index 92b5452f..bf91c5a4 100644 --- a/packages/lib/src/data/messages_host.ts +++ b/packages/lib/src/data/messages_host.ts @@ -96,11 +96,11 @@ export class ControllerConnectionEvent { static dst = "instance" as const; constructor( - public event: string, + public event: "connect" | "drop" | "resume" | "close", ) { } static jsonSchema = Type.Object({ - "event": Type.String(), + "event": StringEnum(["connect", "drop", "resume", "close"]), }); static fromJSON(json: Static) { diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index d97ca0be..608ba06b 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -853,10 +853,10 @@ export class InstancePlayerUpdateEvent { static dst = "controller" as const; constructor( - public type: string, + public type: "join" | "leave" | "import", public name: string, + public stats: PlayerStats, public reason?: string, - public stats?: PlayerStats, ) { } static jsonSchema = Type.Object({ @@ -867,6 +867,6 @@ export class InstancePlayerUpdateEvent { }); static fromJSON(json: Static) { - return new this(json.name, json.name, json.reason, PlayerStats.fromJSON(json.stats)); + return new this(json.type, json.name, PlayerStats.fromJSON(json.stats), json.reason); } } diff --git a/packages/lib/src/data/messages_user.ts b/packages/lib/src/data/messages_user.ts index c50a9339..a524a39f 100644 --- a/packages/lib/src/data/messages_user.ts +++ b/packages/lib/src/data/messages_user.ts @@ -1,7 +1,6 @@ import { Type, Static } from "@sinclair/typebox"; import PlayerStats from "../PlayerStats"; import { JsonNumber, jsonArray } from "./composites"; -import { AccountRole } from "../plugin"; export class RawPermission { // TODO refactor into lib/user.Permission constructor( @@ -407,32 +406,6 @@ export class UserDeleteRequest { } } - -export class AccountUpdateEvent { - declare ["constructor"]: typeof AccountUpdateEvent; - static type = "event" as const; - static src = "controller" as const; - static dst = "control" as const; - - constructor( - public roles?: AccountRole[], - ) { } - - static jsonSchema = Type.Object({ - roles: Type.Array( - Type.Object({ - name: Type.String(), - id: Type.Integer(), - permissions: Type.Array(Type.String()), - }), - ), - }); - - static fromJSON(json: Static) { - return new this(json.roles); - } -} - export class UserUpdateEvent { declare ["constructor"]: typeof UserUpdateEvent; static type = "event" as const; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 73868284..e9b1cc5e 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -15,6 +15,7 @@ import type { RequestClass, EventClass } from "./link"; */ export const dataClasses: (RequestClass | EventClass)[] = [ core.PingRequest, + core.AccountUpdateEvent, controller.ControllerConfigGetRequest, controller.ControllerConfigSetFieldRequest, @@ -105,6 +106,5 @@ export const dataClasses: (RequestClass | EventClass) user.UserSetWhitelistedRequest, user.UserSetBannedRequest, user.UserDeleteRequest, - user.AccountUpdateEvent, user.UserUpdateEvent, ]; diff --git a/packages/lib/src/plugin.ts b/packages/lib/src/plugin.ts index 261a684d..f9baea7d 100644 --- a/packages/lib/src/plugin.ts +++ b/packages/lib/src/plugin.ts @@ -4,21 +4,8 @@ */ import * as libHelpers from "./helpers"; import type { Logger } from "./logging"; -import type { ConfigGroup, PluginConfigGroup } from "./config"; -import type { CollectorResult } from "./prometheus"; -import type { ParsedFactorioOutput } from "./logging_utils"; -import type { ModPack, ModInfo } from "./data"; -import type { Link } from "./link"; -import type { CommandTree } from "./command"; - -/** - * Conceptual base for controller and instance plugins. - */ -export type BasePlugin = - | BaseControllerPlugin - | BaseInstancePlugin - | BaseControlPlugin -; +import type { PluginConfigGroup } from "./config"; +import type PlayerStats from "./PlayerStats"; // TODO Add proper typing for plugins /* Used to define the export of info.ts from plugins */ @@ -27,20 +14,23 @@ export type PluginDeclaration = { title: string; description?: string; - instanceEntrypoint?: string; - InstanceConfigGroup?: typeof PluginConfigGroup; - controllerEntrypoint?: string; ControllerConfigGroup?: typeof PluginConfigGroup; - controlEntrypoint?: string; + hostEntrypoint?: string; + HostConfigGroup?: typeof PluginConfigGroup; + + instanceEntrypoint?: string; + InstanceConfigGroup?: typeof PluginConfigGroup; + + ctlEntrypoint?: string; + webEntrypoint?: string; ControlConfigGroup?: typeof PluginConfigGroup; messages?: any[]; - - webEntrypoint?: string; routes?: string[]; } + export type PluginNodeEnvInfo = PluginDeclaration & { requirePath: string; version: string; @@ -53,19 +43,10 @@ export type PluginWebpackEnvInfo = PluginDeclaration & { enabled?: boolean; }; -type Controller = any; -type Instance = any; -type Host = any; -type InstanceInfo = any; -type HostConnection = any; -type ControlConnection = any; -type Event = any; -type React = any; - /** * Information about the event. */ -interface PlayerEvent { +export interface PlayerEvent { type: "join" | "leave" | "import"; /** Name of the player that joined/left */ name: string, @@ -75,787 +56,11 @@ interface PlayerEvent { * or "server_quit" if the server exits while the player is online. */ reason?: string; -} -type InstanceStatus = - | "unassigned" - | "unknown" - | "stopped" - | "starting" - | "running" - | "stopping" - | "creating_save" - | "exporting_data" - | "deleted" -; - -/** - * 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 info.js file. - * - * 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. - */ -export 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`. + * Statistics recorded for this player on the instance this event + * originated from. */ - 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; - } - - /** - * Called immediately after the class is instantiated - */ - async init() { } - - /** - * Called when the value of a config field changed. - * - * Invoked after the value of the config field given by `field` has - * changed. - * - * @param group - - * The group who's field got changed. - * @param field - Name of the field that changed. - * @param prev - The previous value of the field. - */ - async onInstanceConfigFieldChanged(group: ConfigGroup, field: string, prev: unknown) { } - - /** - * Called before collecting Prometheus metrics - * - * Invoked before the default metrics of prometheus is collected. Note - * that since this is done while replying to /metrics this should not - * take a long time, otherwise the requst will time out. - * - * Although you can return the results of collecting your prometheus - * collectors directly, all gauges in the default registry is already - * automatically collected. It's therefore recommended to only update - * the Gauges/Counters/etc in onMetric and let Clusterio deal will - * collecting the values. - * - * @returns an async iterator of prometheus metric results or undefined. - */ - async onMetrics(): Promise> { } - - /** - * Called after the Factorio server is started - */ - async onStart() { } - - /** - * Called before the Factorio server is stopped - * - * This will not be called if for example the Factorio server crashes or - * is killed. - */ - async onStop() { } - - /** - * Called when the instance exits - * - * Invoked when the instance is shut down. This may occur after init - * has been called if an error occurs during startup. Note that if - * the plugin's init() throws this method will still be invoked. - */ - onExit() { } - - /** - * Called when the Factorio outputs a line - * - * Called when the Factorio server outputs a line on either stdout or - * stderr. The output is a parsed form of the line and contains the - * following properties: - * - * @param parsed - parsed server output. - * @param line - raw line of server output. - */ - async onOutput(parsed: ParsedFactorioOutput, line: string) { } - - /** - * Called when an event on the controller connection happens - * - * The event param may be one of connect, drop, resume and close and has - * the following meaning: - * - * ##### connect - * - * Invoked when a new connection to the controller has been established. - * - * ##### drop - * - * Invoked when a connection loss is detected between the host and the - * controller. Plugins should respond to this event by throtteling messages - * it is sending to the controller to an absolute minimum. - * - * Messages sent over a dropped controller connection will get queued up in - * memory on the host and sent all in one go when the connection is - * re-established again. - * - * ##### resume - * - * Invoked when the connection that had previously dropped is - * re-established. - * - * ##### close - * - * Invoked when the connection to the controller has been closed. This - * typically means the controller has shut down. Plugins should not - * send any messages that goes to or via the controller after the - * connection has been closed and before a new one is established. - * - * @param event - one of connect, drop, resume and close - */ - onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { } - - /** - * Called when the controller is preparing to disconnect from the host - * - * Invoked when the controller has requested the disconnection from - * the host. This typically happens when it is in the process of - * shutting down. - * - * Plugins must stop sending messages to the controller, or are forwarded - * via the controller after the prepare disconnect has been handled. - * - * @param connection - - * The connection to the host preparing to disconnect. - */ - async onPrepareControllerDisconnect(connection: HostConnection) { } - - /** - * Called when a player joins or leaves the game - * - * Invoked when a player either joins or leaves the instance. - * - * @param event - Information about the event. - */ - async onPlayerEvent(event: PlayerEvent) { } - - /** - * Send RCON message to instance - * - * Send a message or command to the server which can potentially be - * executed out of order in relation to other RCON commands. - * - * This should not be called before onStart or after onStop. A simple - * way to achieve this is to check that the instance status is running - * before sending commands. Note that the instance status is set to - * running some time after `onStart` and some time before `onStop` is - * invoked. - * - * @param message - message to send to server over RCON. - * @param expectEmpty - - * if true throw if the response is not empty. Useful for detecting - * errors that might have been sent in response. - * @returns response from server. - */ - async sendRcon(message: string, expectEmpty=false): Promise { - return await this.instance.sendRcon(message, expectEmpty, this.info.name); - } - - /** - * Send serially ordered RCON message to instance - * - * Send a message or command to the server which will not be executed - * out of order in relation to other RCON commands sent with this - * method. The ordering applies per plugin, and two plugins sending - * commands with this method may execute out of order in relation to - * each other. - * - * This should not be called before onStart or after onStop. A simple - * way to achieve this is to check that the instance status is running - * before sending commands. Note that the instance status is set to - * running some time after `onStart` and some time before `onStop` is - * invoked. - * - * @param message - message to send to server over RCON. - * @param [expectEmpty=false] - - * if true throw if the response is not empty. Useful for detecting - * errors that might have been sent in response. - * @returns response from server. - */ - async sendOrderedRcon(message: string, expectEmpty=false) { - let promise = new Promise((resolve, reject) => { - this._pendingRconMessages.push({resolve, reject, message, expectEmpty}); - }); - if (!this._sendingRconMessages) { - this._sendPendingRconMessages(); - } - return await promise; - } - - async _sendPendingRconMessages() { - this._sendingRconMessages = true; - while (this._pendingRconMessages.length) { - let task = this._pendingRconMessages.shift()!; - try { - let result = await this.sendRcon(task.message, task.expectEmpty); - task.resolve(result); - } catch (err: any) { - task.reject(err); - } - } - this._sendingRconMessages = false; - } -} - -/** - * 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 info.js - * file. - */ -export 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; - } - - /** - * Called immediately after the class is instantiated - */ - async init() { } - - /** - * Called when the status of an instance changes - * - * Invoked when the controller has changed the status of an instance - * or received notice from a host that the status of an instance has - * changed. The possible statuses that can be notified about are: - * - `unassigned:`: Instance is no longer asssigned to a host. - * - `unknown`: Host assigned to instance is offline. - * - `stopped`: Instance is no longer running or was just assigned to a - * host. - * - `starting`: Instance is in the process of starting up. - * - `running`: Instance startup completed and is now running. - * - `stopping`: Instance is in the processing stopping. - * - `creating_save`: Instance is in the process of creating a save. - * - `exporting_data`: Instance is in the process of exporting item - * icons and locale data. - * - `deleted`: Instance was deleted. - * - * On controller startup all known instances gets the status `unknown` if - * they are assigned to a host, when the host then connects to the - * controller and informs of the current status of its instances this hook - * is invoked for all those instances. You can detect this situation by - * checking if prev equals `unknown`. If the host disconnects the - * instances will again get the `unknown` status. - * - * When instances are created on the controller they will notify of a status - * change with prev set to null. While the status of new instances is - * in most cases `unassigned` it's possible for the created instance to - * start with any state in some host connection corner cases. - * - * Note that it's possible for status change notification to get lost in - * the case of network outages if reconnect fails to re-establish the - * session between the controller and the host. - * - * @param instance - - * The instance that changed. - * @param prev - the previous status of the instance. - */ - async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { } - - /** - * Called when the value of a controller config field changed. - * - * Invoked after the value of the config field given by `field` has - * changed on the controller. - * - * @param group - - * The group who's field got changed. - * @param field - Name of the field that changed. - * @param prev - The previous value of the field. - */ - async onControllerConfigFieldChanged(group: ConfigGroup, field: string, prev: unknown) { } - - /** - * Called when the value of an instance config field changed. - * - * Invoked after the value of the config field given by `field` has - * changed on an instance. - * - * @param instance - - * The instance the config changed on. - * @param group - - * The group who's field got changed. - * @param field - Name of the field that changed. - * @param prev - The previous value of the field. - */ - async onInstanceConfigFieldChanged(instance: InstanceInfo, group: ConfigGroup, field: string, prev: unknown) { } - - /** - * Called before collecting Prometheus metrics - * - * Invoked before the default metrics of prometheus is collected. Note - * that since this is done while replying to /metrics this should not - * take a long time, otherwise the requst will time out. - * - * Although you can return the results of collecting your prometheus - * collectors directly, all gauges in the default registry is already - * automatically collected. It's therefore recommended to only update - * the Gauges/Counters/etc in onMetric and let Clusterio deal will - * collecting the values. - * - * @returns an async iterator of prometheus metric results or undefined. - */ - async onMetrics(): Promise> { } - - /** - * Called when the controller is shutting down - */ - async onShutdown() { } - - /** - * Called when an event on a host connection happens - * - * The event param may be one of connect, drop, resume and close and has - * the following meaning: - * - * ##### connect - * - * Invoked when a new connection to the host has been established. - * - * ##### drop - * - * Invoked when a connection loss is detected between the controller and a - * host. Plugins should respond to this event by throtteling messages - * it is sending to the given host connection to an absolute minimum. - * - * Messages sent over a dropped host connection will get queued up in - * memory on the controller and sent all in one go when the connection is - * re-established again. - * - * ##### resume - * - * Invoked when a connection that had previously been dropped is - * re-established - * - * ##### close - * - * Invoked when the connection to the host has been closed. - * - * @param connection - - * The connection the event occured on. - * @param event - one of connect, drop, resume and close - */ - onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { } - - /** - * Called when an avent on a control connection happens - * - * The event param may be one of connect, drop, resume and close and has - * the following meaning: - * - * ##### connect - * - * Invoked when a new connection to the control has been established. - * - * ##### drop - * - * Invoked when a connection loss is detected between the controller and a - * control. Plugins should respond to this event by throtteling - * messages it is sending to the given control connection to an absolute - * minimum. - * - * Messages sent over a dropped control connection will get queued up in - * memory on the controller and sent all in one go when the connection is - * re-established again. - * - * ##### resume - * - * Invoked when a connection that had previously been dropped is - * re-established. - * - * ##### close - * - * Invoked when the connection to the control has been closed. - * - * @param connection - - * The connection the event occured on. - * @param event - one of connect, drop, resume, and close. - */ - onControlConnectionEvent(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close") { } - - /** - * Called when a host is preparing to disconnect from the controller - * - * Invoked when a host has requested the disconnection from the controller. - * This typically happens when it is in the process of shutting down. - * - * Plugins must stop sending messages to the host in question after the - * prepare disconnect has been handled. - * - * @param connection - - * The connection to the host preparing to disconnect. - */ - async onPrepareHostDisconnect(connection: HostConnection) { } - - /** - * Called when a mod pack is updated - * - * Invoked when a mod pack has been added, updated or deleted in the - * cluster. - * - * If the mod pack has been deleted its `.isDeleted` property will be - * true. - * - * @param modPack - Mod pack that updated. - */ - async onModPackUpdated(modPack: ModPack) { } - - /** - * Called when a mod stored on the controller is updated - * - * Invoked when a mod has been added, updated or deleted from the pool - * of shared mods stored on the cluster. - * - * If a mod has been deleted its `.isDeleted` property will be true. - * - * @param mod - Mod that updated. - */ - async onModUpdated(mod: ModInfo) { } - - /** - * Called when a player joins or leaves an instance - * - * Invoked when a player either joins or leaves an instance in the - * cluster. - * - * @param instance - - * The instance it occured on. - * @param event - Information about the event. - */ - async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { } - - /** - * Broadcast event to all connected hosts - * - * Sends the given event to all hosts connected to the controller. - * This does not include hosts that are in the process of closing the - * connection, which typically happens when they are shutting down. - * - * @param event - Event to send - */ - broadcastEventToHosts(event: Event) { - for (let hostConnection of this.controller.wsServer.hostConnections.values()) { - if ( - !hostConnection.connector.closing - && (!event.constructor.plugin || hostConnection.plugins.has(event.constructor.plugin)) - ) { - hostConnection.send(event); - } - } - } -} - -/** - * Base class for clusterioctl plugins - * - * Control 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 `ControlPlugin` in the module - * specified by the `controlEntrypoint` in the plugin's info.js file. - */ -export class BaseControlPlugin { - /** - * 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; - } - - /** - * Called immediately after the class is instantiated - */ - async init() { } - - /** - * Called to add commands to the command line interface. - * - * Invoked by clusterioctl to let plugins add commands. `rootCommand` is - * the top level command node which the plugin should add its own {@link - * CommandTree} to. - * - * @param rootCommand - - * Root of the clusterioctl command tree. - */ - async addCommands(rootCommand: CommandTree) { } -} - - -/** - * Plugin supplied login form - */ -export interface PluginLoginForm { - /** - * Internal name of the login form, this should start with the - * plugin name followed by a dot. - */ - name: string; - - /** Name displayed above this form in the login window. */ - title: string; - - /** - * React component that's rendered for this login form. This is - * supplied the setToken function via its props which should be called - * when an authentication token is aquired via this form. - */ - Component: React["Component"]; -}; - -export type AccountRole = { - name: string; - id: number; - permissions: string[]; -}; - -export type UserAccount = { - /** Name of the currently logged in account. */ - name: string; - /** Roles of the corrently logged in account. */ - roles: AccountRole[]; - /** Check if the currently logged in account has the given permission. */ - hasPermission: (permission: string) => boolean | null; - /** Check if the currently logged in account has any of the given permissions. */ - hasAnyPermission: (...permissions: string[]) => boolean | null; - /** Check if the currently logged in account has all of given permissions. */ - hasAllPermission: (...permissions: string[]) => boolean | null; - /** Logs out of the web interface. */ - logOut: () => void; -}; - -/** - * Plugin supplied pages - */ -export interface PluginPage { - /** URL path to this page. */ - path: string; - /** - * If present and this path matches one of the pages in the sidebar it - * will cause that sidebar entry to be highlighted as active. - */ - sidebarPath?: string; - /** - * If present group this entry under a group of the given name in the - * sidebar. - */ - sidebarGroup?: string; - /** - * If present creates an entry in the sidebar for this page with the - * given text. - */ - sidebarName?: string; - /** - * A react node which is rendered when this page is navigated to. - * Should render a PageLayout. - */ - content?: React["ReactNode"]; - /** - * Permission to access page. function are expected to throw an error if access is deny. - */ - permission?: string | ((account: UserAccount) => (boolean|null)); -}; - -/** - * Base class for web interface plugins - */ -export class BaseWebPlugin { - /** - * Contents of the plugin's package.json file - */ - package: any; - /** - * Control link to the controller, not available until the - * connect event in onControllerConnectionEvent is signaled. - */ - control?: Link; - /** - * 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[] = []; - /** - * 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"], - } = {}; - - - constructor( - /** - * Webpack container for this plugin - */ - public container: any, - packageData: any, - /** - * The plugin's own info module - */ - public info: PluginWebpackEnvInfo, - logger: Logger, - ) { - this.package = packageData; - this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger; - } - - /** - * Called immediately after the class is instantiated. - */ - async init() { } - - /** - * Called when an event on the controller connection happens - * - * The event param may be one of connect, drop, resume and close and has - * the following meaning: - * - * ##### connect - * - * Invoked when a new connection to the controller has been established. - * - * ##### drop - * - * Invoked when a connection loss is detected between the control link - * and the controller. Plugins should respond to this event by throtteling - * messages it is sending to the controller to an absolute minimum. - * - * Messages sent over a dropped controller connection will get queued up in - * memory in the browser and sent all in one go when the connection is - * re-established again. - * - * ##### resume - * - * Invoked when the connection that had previously dropped is - * re-established. - * - * ##### close - * - * Invoked when the connection to the controller has been closed. This - * typically means the controller has shut down. Plugins should not - * send any messages that goes to or via the controller after the - * connection has been closed and before a new one is established. - * - * @param event - one of connect, drop, resume and close - */ - onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { } + stats: PlayerStats, } /** @@ -867,14 +72,22 @@ export class BaseWebPlugin { * @param args - Arguments to pass on to the hook. * @returns Non-undefined return values from the hooks. */ -export async function invokeHook(plugins: Map, hook: string, ...args: any[]) { - let results: unknown[] = []; +export async function invokeHook< + Hook extends string, + R, + Args extends [...any], + Plugin extends { logger: Logger } & Record R | Promise> +>( + plugins: Map, + hook: Hook, + ...args: Args +): Promise>, void>[]> { + let results: any[] = []; for (let [name, plugin] of plugins) { try { - // Use an object to detect if the hook failed on timeout or tried to return a value looking like an error - const timeout = {}; - let result = await libHelpers.timeout( - (plugin as any)[hook](...args), + const timeout = Symbol("timeout-token"); + let result = await libHelpers.timeout( + plugin[hook](...args) as Promise, 15000, timeout ); diff --git a/packages/lib/src/plugin_loader.ts b/packages/lib/src/plugin_loader.ts index bb6c3573..e99ac0c2 100644 --- a/packages/lib/src/plugin_loader.ts +++ b/packages/lib/src/plugin_loader.ts @@ -52,65 +52,22 @@ export async function loadPluginInfos(pluginList: Map) { return plugins; } -function loadPluginClass( - entrypointName: "controllerEntrypoint"|"instanceEntrypoint"|"controlEntrypoint", +export async function loadPluginClass( + pluginName: string, + requirePath: string, className: string, - pluginClass: any, - pluginInfo: libPlugin.PluginNodeEnvInfo -) { - let resolvedPath = path.posix.join(pluginInfo.requirePath, pluginInfo[entrypointName]!); - let entrypoint = require(resolvedPath); + pluginClass: Class, +): Promise { + let entrypoint = require(requirePath); if (!entrypoint[className]) { - throw new libErrors.PluginError(pluginInfo.name, - new Error(`Expected ${resolvedPath} to export a class named ${className}`) + throw new libErrors.PluginError(pluginName, + new Error(`Expected ${requirePath} to export a class named ${className}`) ); } if (!(entrypoint[className].prototype instanceof pluginClass)) { - throw new libErrors.PluginError(pluginInfo.name, - new Error(`Expected ${className} exported from ${resolvedPath} to be a subclass of ${pluginClass.name}`) + throw new libErrors.PluginError(pluginName, + new Error(`Expected ${className} exported from ${requirePath} to be a subclass of ${pluginClass.name}`) ); } return entrypoint[className]; } - -/** - * Load controller plugin class of a plugin - * - * @param pluginInfo - - * Plugin info object returned from {@link - * loadPluginInfos} to load class from. - * @returns plugin class - */ -export async function loadControllerPluginClass( - pluginInfo: libPlugin.PluginNodeEnvInfo -): Promise { - return loadPluginClass("controllerEntrypoint", "ControllerPlugin", libPlugin.BaseControllerPlugin, pluginInfo); -} - -/** - * Load instance plugin class of a plugin - * - * @param pluginInfo - - * Plugin info object returned from {@link - * loadPluginInfos} to load class from. - * @returns plugin class - */ -export async function loadInstancePluginClass( - pluginInfo: libPlugin.PluginNodeEnvInfo -): Promise { - return loadPluginClass("instanceEntrypoint", "InstancePlugin", libPlugin.BaseInstancePlugin, pluginInfo); -} - -/** - * Load control plugin class of a plugin - * - * @param pluginInfo - - * Plugin info object returned from {@link - * loadPluginInfos} to load class from. - * @returns plugin class - */ -export async function loadControlPluginClass( - pluginInfo: libPlugin.PluginNodeEnvInfo -): Promise { - return loadPluginClass("controlEntrypoint", "ControlPlugin", libPlugin.BaseControlPlugin, pluginInfo); -} diff --git a/packages/web_ui/src/BaseWebPlugin.ts b/packages/web_ui/src/BaseWebPlugin.ts new file mode 100644 index 00000000..755aac9b --- /dev/null +++ b/packages/web_ui/src/BaseWebPlugin.ts @@ -0,0 +1,201 @@ +import type React from "react"; +import type { AccountRole, Link, Logger, PluginWebpackEnvInfo } from "@clusterio/lib"; + +/** + * Plugin supplied login form + */ +export interface PluginLoginForm { + /** + * Internal name of the login form, this should start with the + * plugin name followed by a dot. + */ + name: string; + + /** Name displayed above this form in the login window. */ + title: string; + + /** + * React component that's rendered for this login form. This is + * supplied the setToken function via its props which should be called + * when an authentication token is aquired via this form. + */ + Component: typeof React.Component; +}; + +export type UserAccount = { + /** Name of the currently logged in account. */ + name: string; + /** Roles of the corrently logged in account. */ + roles: AccountRole[]; + /** Check if the currently logged in account has the given permission. */ + hasPermission: (permission: string) => boolean | null; + /** Check if the currently logged in account has any of the given permissions. */ + hasAnyPermission: (...permissions: string[]) => boolean | null; + /** Check if the currently logged in account has all of given permissions. */ + hasAllPermission: (...permissions: string[]) => boolean | null; + /** Logs out of the web interface. */ + logOut: () => void; +}; + +/** + * Plugin supplied pages + */ +export interface PluginPage { + /** URL path to this page. */ + path: string; + /** + * If present and this path matches one of the pages in the sidebar it + * will cause that sidebar entry to be highlighted as active. + */ + sidebarPath?: string; + /** + * If present group this entry under a group of the given name in the + * sidebar. + */ + sidebarGroup?: string; + /** + * If present creates an entry in the sidebar for this page with the + * given text. + */ + sidebarName?: string; + /** + * A react node which is rendered when this page is navigated to. + * Should render a PageLayout. + */ + content?: React.ReactElement; + /** + * Permission to access page. function are expected to throw an error if access is deny. + */ + permission?: string | ((account: UserAccount) => (boolean|null)); +}; + +/** + * Base class for web interface plugins + */ +export default class BaseWebPlugin { + /** + * Contents of the plugin's package.json file + */ + package: any; + /** + * Control link to the controller, not available until the + * connect event in onControllerConnectionEvent is signaled. + */ + control?: Link; + /** + * 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[] = []; + /** + * 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, + } = {}; + + + constructor( + /** + * Webpack container for this plugin + */ + public container: any, + packageData: any, + /** + * The plugin's own info module + */ + public info: PluginWebpackEnvInfo, + logger: Logger, + ) { + this.package = packageData; + this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger; + } + + /** + * Called immediately after the class is instantiated. + */ + async init() { } + + /** + * Called when an event on the controller connection happens + * + * The event param may be one of connect, drop, resume and close and has + * the following meaning: + * + * ##### connect + * + * Invoked when a new connection to the controller has been established. + * + * ##### drop + * + * Invoked when a connection loss is detected between the control link + * and the controller. Plugins should respond to this event by throtteling + * messages it is sending to the controller to an absolute minimum. + * + * Messages sent over a dropped controller connection will get queued up in + * memory in the browser and sent all in one go when the connection is + * re-established again. + * + * ##### resume + * + * Invoked when the connection that had previously dropped is + * re-established. + * + * ##### close + * + * Invoked when the connection to the controller has been closed. This + * typically means the controller has shut down. Plugins should not + * send any messages that goes to or via the controller after the + * connection has been closed and before a new one is established. + * + * @param event - one of connect, drop, resume and close + */ + onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { } +} diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index 01f68715..d97fb4ff 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -7,6 +7,7 @@ import * as lib from "@clusterio/lib"; import type { PluginWebApi } from "@clusterio/controller/src/routes"; import App from "./components/App"; +import BaseWebPlugin from "./BaseWebPlugin"; import { Control, ControlConnector } from "./util/websocket"; const { ConsoleTransport, WebConsoleFormat, logger } = lib; @@ -74,7 +75,7 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[]) { continue; } try { - let WebPluginClass = lib.BaseWebPlugin; + let WebPluginClass = BaseWebPlugin; if (pluginInfo.webEntrypoint) { let webModule = (await pluginInfo.container.get(pluginInfo.webEntrypoint))(); if (!webModule.WebPlugin) { diff --git a/packages/web_ui/src/components/PluginExtra.tsx b/packages/web_ui/src/components/PluginExtra.tsx index e768870f..6713d844 100644 --- a/packages/web_ui/src/components/PluginExtra.tsx +++ b/packages/web_ui/src/components/PluginExtra.tsx @@ -3,6 +3,7 @@ import React, { useContext } from "react"; import * as lib from "@clusterio/lib"; import ControlContext from "./ControlContext"; +import BaseWebPlugin from "../BaseWebPlugin"; import { RawUserState } from "../model/user"; import { HostState } from "../model/host"; import { InstanceState } from "../model/instance"; @@ -22,7 +23,7 @@ export default function PluginExtra(props: PluginExtraProps) { let components = []; for (let [name, plugin] of control.plugins) { if (Object.prototype.hasOwnProperty.call(plugin.componentExtra, props.component)) { - type ComponentExtra = {[key: string]: React.ComponentType}; + type ComponentExtra = {[key: string]: React.ComponentType}; let ComponentExtra = (plugin.componentExtra as ComponentExtra)[props.component]; components.push(); } diff --git a/packages/web_ui/src/index.ts b/packages/web_ui/src/index.ts index fbe91afa..ff2e4fe8 100644 --- a/packages/web_ui/src/index.ts +++ b/packages/web_ui/src/index.ts @@ -1,5 +1,6 @@ export { default as bootstrap } from "./bootstrap"; export { default as notify, notifyErrorHandler } from "./util/notify"; +export { default as BaseWebPlugin } from "./BaseWebPlugin"; export * from "./model/instance"; export * from "./model/host"; @@ -18,9 +19,8 @@ export { default as LogConsole } from "./components/LogConsole"; export { default as AssignInstanceModal } from "./components/AssignInstanceModal"; export { default as InstanceList } from "./components/InstanceList"; export { default as InstanceRcon } from "./components/InstanceRcon"; -export { default as InstanceStatusTag } from "./components/InstanceStatusTag"; +export { default as InstanceStatusTag, statusColors } from "./components/InstanceStatusTag"; export { default as StartStopInstanceButton } from "./components/StartStopInstanceButton"; -export { statusColors } from "./components/InstanceStatusTag"; export { default as PageHeader } from "./components/PageHeader"; export { default as PageLayout } from "./components/PageLayout"; diff --git a/packages/web_ui/src/model/account.ts b/packages/web_ui/src/model/account.ts index cdf4b2f1..8086bf3a 100644 --- a/packages/web_ui/src/model/account.ts +++ b/packages/web_ui/src/model/account.ts @@ -1,11 +1,12 @@ import { useContext, useEffect, useState } from "react"; +import { UserAccount } from "../BaseWebPlugin"; import * as lib from "@clusterio/lib"; import { notifyErrorHandler } from "../util/notify"; import ControlContext from "../components/ControlContext"; -export function useAccount(): lib.UserAccount { +export function useAccount(): UserAccount { let control = useContext(ControlContext); let [name, setName] = useState(control.accountName || ""); let [roles, setRoles] = useState(control.accountRoles || []); diff --git a/packages/web_ui/src/pages.tsx b/packages/web_ui/src/pages.tsx index 38603e08..10d6e0d3 100644 --- a/packages/web_ui/src/pages.tsx +++ b/packages/web_ui/src/pages.tsx @@ -1,6 +1,6 @@ import React, { ReactElement } from "react"; -import type * as lib from "@clusterio/lib"; +import { PluginPage, UserAccount } from "./BaseWebPlugin"; import ControllerPage from "./components/ControllerPage"; import HostsPage from "./components/HostsPage"; @@ -16,7 +16,7 @@ import RoleViewPage from "./components/RoleViewPage"; import PluginsPage from "./components/PluginsPage"; import PluginViewPage from "./components/PluginViewPage"; -export const pages: lib.PluginPage[] = [ +export const pages: PluginPage[] = [ { path: "/controller", sidebarName: "Controller", @@ -47,7 +47,7 @@ export const pages: lib.PluginPage[] = [ { path: "/mods", sidebarName: "Mods", - permission: (account:lib.UserAccount) => account.hasAnyPermission("core.mod.list", "core.mod-pack.list"), + permission: (account: UserAccount) => account.hasAnyPermission("core.mod.list", "core.mod-pack.list"), content: , }, { diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index 40f5987e..c2fbbdf1 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -1,5 +1,6 @@ import * as lib from "@clusterio/lib"; import packageJson from "../../package.json"; +import BaseWebPlugin from "../BaseWebPlugin"; const { logFilter, logger } = lib; @@ -84,7 +85,7 @@ export class Control extends lib.Link { constructor( connector: ControlConnector, - public plugins: Map, + public plugins: Map, ) { super(connector); diff --git a/plugins/global_chat/control.ts b/plugins/global_chat/control.ts index 7213b995..3b6534ba 100644 --- a/plugins/global_chat/control.ts +++ b/plugins/global_chat/control.ts @@ -1,4 +1,5 @@ -import { BaseControlPlugin, CommandTree, Command } from "@clusterio/lib"; +import { CommandTree, Command } from "@clusterio/lib"; +import { BaseCtlPlugin } from "@clusterio/ctl"; import { ChatEvent } from "./messages"; const globalChatCommands = new CommandTree({ @@ -13,7 +14,7 @@ globalChatCommands.add(new Command({ }, })); -export class ControlPlugin extends BaseControlPlugin { +export class CtlPlugin extends BaseCtlPlugin { async addCommands(rootCommand: CommandTree) { rootCommand.add(globalChatCommands); } diff --git a/plugins/global_chat/info.ts b/plugins/global_chat/info.ts index ad1e601a..192a31ba 100644 --- a/plugins/global_chat/info.ts +++ b/plugins/global_chat/info.ts @@ -7,7 +7,7 @@ const info: lib.PluginDeclaration = { title: "Global Chat", description: "Forwards chat between instances.", instanceEntrypoint: "dist/plugin/instance", - controlEntrypoint: "dist/plugin/control", + ctlEntrypoint: "dist/plugin/control", messages: [ ChatEvent, diff --git a/plugins/global_chat/instance.ts b/plugins/global_chat/instance.ts index 6c605977..2cfb2c6e 100644 --- a/plugins/global_chat/instance.ts +++ b/plugins/global_chat/instance.ts @@ -1,4 +1,5 @@ import * as lib from "@clusterio/lib"; +import { BaseInstancePlugin } from "@clusterio/host"; import { ChatEvent } from "./messages"; /** @@ -11,7 +12,7 @@ function removeTags(content: string): string { return content.replace(/(\[gps=-?\d+,-?\d+\]|\[train=\d+\])/g, ""); } -export class InstancePlugin extends lib.BaseInstancePlugin { +export class InstancePlugin extends BaseInstancePlugin { messageQueue: string[] = []; async init() { diff --git a/plugins/inventory_sync/controller.ts b/plugins/inventory_sync/controller.ts index 6992903d..3b399315 100644 --- a/plugins/inventory_sync/controller.ts +++ b/plugins/inventory_sync/controller.ts @@ -1,5 +1,4 @@ -import type InstanceInfo from "@clusterio/controller/src/InstanceInfo"; -import type Controller from "@clusterio/controller/src/Controller"; +import { BaseControllerPlugin, type InstanceInfo } from "@clusterio/controller"; import type { IpcPlayerData } from "./instance"; import fs from "fs-extra"; @@ -36,7 +35,7 @@ async function saveDatabase( } } -export class ControllerPlugin extends lib.BaseControllerPlugin { +export class ControllerPlugin extends BaseControllerPlugin { acquiredPlayers!: Map; playerDatastore!: Map; autosaveId!: ReturnType; @@ -48,7 +47,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { saveDatabase(this.controller.config, this.playerDatastore, this.logger).catch(err => { this.logger.error(`Unexpected error autosaving player data:\n${err.stack}`); }); - }, this.controller.config.get("inventory_sync.autosave_interval") * 1000); + }, this.controller.config.get("inventory_sync.autosave_interval") as number * 1000); this.controller.handle(msg.AcquireRequest, this.handleAcquireRequest.bind(this)); this.controller.handle(msg.ReleaseRequest, this.handleReleaseRequest.bind(this)); @@ -68,7 +67,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { } if (["unknown", "stopped"].includes(instance.status)) { - let timeout = this.controller.config.get("inventory_sync.player_lock_timeout") * 1000; + let timeout = this.controller.config.get("inventory_sync.player_lock_timeout") as number * 1000; for (let acquisitonRecord of this.acquiredPlayers.values()) { if (acquisitonRecord.instanceId === instanceId && !acquisitonRecord.expires) { acquisitonRecord.expires = Date.now() + timeout; @@ -90,7 +89,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { if ( !acquisitionRecord || acquisitionRecord.instanceId === instanceId - || !this.controller.instances.has(acquisitionRecord.instanceId) + || !this.controller.instances!.has(acquisitionRecord.instanceId) || acquisitionRecord.expires && acquisitionRecord.expires < Date.now() ) { this.acquiredPlayers.set(playerName, { instanceId }); @@ -104,7 +103,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { let { instanceId, playerName } = request; if (!this.acquire(instanceId, playerName)) { let acquisitionRecord = this.acquiredPlayers.get(playerName); - let instance = this.controller.instances.get(acquisitionRecord!.instanceId); + let instance = this.controller.instances!.get(acquisitionRecord!.instanceId)!; return { status: "busy", message: instance.config.get("instance.name"), @@ -133,7 +132,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { async handleUploadRequest(request: msg.UploadRequest) { let { instanceId, playerName, playerData } = request; - let instanceName = this.controller.instances.get(instanceId).config.get("instance.name"); + let instanceName = this.controller.instances!.get(instanceId)!.config.get("instance.name"); let store = true; let acquisitionRecord = this.acquiredPlayers.get(playerName); if (!acquisitionRecord) { @@ -167,7 +166,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { async handleDownloadRequest(request: msg.DownloadRequest) { let { instanceId, playerName } = request; - let instanceName = this.controller.instances.get(instanceId).config.get("instance.name"); + let instanceName = this.controller.instances!.get(instanceId)!.config.get("instance.name"); let acquisitionRecord = this.acquiredPlayers.get(playerName); if (!acquisitionRecord) { diff --git a/plugins/inventory_sync/instance.ts b/plugins/inventory_sync/instance.ts index 23e66832..5ebd2fdd 100644 --- a/plugins/inventory_sync/instance.ts +++ b/plugins/inventory_sync/instance.ts @@ -1,4 +1,5 @@ import * as lib from "@clusterio/lib"; +import { BaseInstancePlugin } from "@clusterio/host"; import { AcquireRequest, AcquireResponse, ReleaseRequest, UploadRequest, DownloadRequest, DownloadResponse, } from "./messages"; @@ -44,7 +45,7 @@ function chunkify(chunkSize: number, string: string): string[] { return string.match(new RegExp(`.{1,${chunkSize}}`, "g")) || []; } -export class InstancePlugin extends lib.BaseInstancePlugin { +export class InstancePlugin extends BaseInstancePlugin { playersToRelease!: Set; disconnecting!: boolean; diff --git a/plugins/inventory_sync/web/index.jsx b/plugins/inventory_sync/web/index.jsx index a0af9acc..25b405c3 100644 --- a/plugins/inventory_sync/web/index.jsx +++ b/plugins/inventory_sync/web/index.jsx @@ -1,7 +1,6 @@ import React, { useContext, useEffect, useState } from "react"; -import * as lib from "@clusterio/lib"; -import { PageLayout, ControlContext } from "@clusterio/web_ui"; +import { BaseWebPlugin, PageLayout, ControlContext } from "@clusterio/web_ui"; import { DatabaseStatsRequest } from "../dist/plugin/messages"; import "./index.css"; @@ -28,7 +27,7 @@ function InventoryPage() { ; } -export class WebPlugin extends lib.BaseWebPlugin { +export class WebPlugin extends BaseWebPlugin { async init() { this.pages = [ { diff --git a/plugins/player_auth/controller.ts b/plugins/player_auth/controller.ts index 9e4257de..dbdb2c9c 100644 --- a/plugins/player_auth/controller.ts +++ b/plugins/player_auth/controller.ts @@ -4,6 +4,7 @@ import util from "util"; import jwt from "jsonwebtoken"; import * as lib from "@clusterio/lib"; +import { BaseControllerPlugin } from "@clusterio/controller"; const { basicType } = lib; import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages"; @@ -27,7 +28,7 @@ async function generateCode(length: number): Promise { type PlayerCode = { playerCode: string, verifyCode: string | null, expires: number }; -export class ControllerPlugin extends lib.BaseControllerPlugin { +export class ControllerPlugin extends BaseControllerPlugin { players!: Map; async init() { @@ -46,7 +47,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { this.controller.app.get("/api/player_auth/servers", (req: Request, res: Response) => { let servers = []; - for (let instance of this.controller.instances.values()) { + for (let instance of this.controller.instances!.values()) { if (instance.status === "running" && instance.config.get("player_auth.load_plugin")) { servers.push(instance.config.get("factorio.settings")["name"] || "unnamed server"); } @@ -88,7 +89,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { for (let entry of this.players.values()) { if (entry.playerCode === playerCode && entry.expires > Date.now()) { - let verifyCode = await generateCode(this.controller.config.get("player_auth.code_length")); + let verifyCode = await generateCode(this.controller.config.get("player_auth.code_length") as number); let secret = Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"); let verifyToken = jwt.sign( { @@ -172,8 +173,8 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { } async handleFetchPlayerCodeRequest(request: FetchPlayerCodeRequest) { - let playerCode = await generateCode(this.controller.config.get("player_auth.code_length")); - let expires = Date.now() + this.controller.config.get("player_auth.code_timeout") * 1000; + let playerCode = await generateCode(this.controller.config.get("player_auth.code_length") as number); + let expires = Date.now() + (this.controller.config.get("player_auth.code_timeout") as number) * 1000; this.players.set(request.player, { playerCode, verifyCode: null, expires }); return { player_code: playerCode, controller_url: this.controller.getControllerUrl() }; } diff --git a/plugins/player_auth/instance.ts b/plugins/player_auth/instance.ts index 7ec47eb8..c6d90a4c 100644 --- a/plugins/player_auth/instance.ts +++ b/plugins/player_auth/instance.ts @@ -1,4 +1,4 @@ -import * as lib from "@clusterio/lib"; +import { BaseInstancePlugin } from "@clusterio/host"; import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages"; @@ -11,7 +11,7 @@ type IpcPlayerAuth = { verify_code: string, } -export class InstancePlugin extends lib.BaseInstancePlugin { +export class InstancePlugin extends BaseInstancePlugin { async init() { if (!this.instance.config.get("factorio.enable_save_patching")) { throw new Error("player_auth plugin requires save patching."); diff --git a/plugins/player_auth/web/index.jsx b/plugins/player_auth/web/index.jsx index 0ad8fb62..266450ef 100644 --- a/plugins/player_auth/web/index.jsx +++ b/plugins/player_auth/web/index.jsx @@ -1,7 +1,6 @@ import React, { useEffect, useState } from "react"; -import * as lib from "@clusterio/lib"; -import { notifyErrorHandler } from "@clusterio/web_ui"; +import { BaseWebPlugin, notifyErrorHandler } from "@clusterio/web_ui"; import { Button, Form, Input, Spin, Typography } from "antd"; import "./index.css"; @@ -146,7 +145,7 @@ function LoginForm(props) { } -export class WebPlugin extends lib.BaseWebPlugin { +export class WebPlugin extends BaseWebPlugin { async init() { this.logger.info("Player Auth init"); this.loginForms = [{ diff --git a/plugins/research_sync/controller.ts b/plugins/research_sync/controller.ts index 06449692..ea8174b4 100644 --- a/plugins/research_sync/controller.ts +++ b/plugins/research_sync/controller.ts @@ -1,5 +1,6 @@ import fs from "fs-extra"; import path from "path"; +import { BaseControllerPlugin } from "@clusterio/controller"; import { Static } from "@sinclair/typebox"; import * as lib from "@clusterio/lib"; @@ -48,7 +49,7 @@ async function saveTechnologies( await lib.safeOutputFile(filePath, JSON.stringify([...technologies.entries()], null, "\t")); } -export class ControllerPlugin extends lib.BaseControllerPlugin { +export class ControllerPlugin extends BaseControllerPlugin { technologies!: Map; progressRateLimiter!: lib.RateLimiter; progressBroadcastId!: ReturnType | null; diff --git a/plugins/research_sync/instance.ts b/plugins/research_sync/instance.ts index 86bec441..77feb72d 100644 --- a/plugins/research_sync/instance.ts +++ b/plugins/research_sync/instance.ts @@ -1,4 +1,5 @@ import * as lib from "@clusterio/lib"; +import { BaseInstancePlugin } from "@clusterio/host"; import { ContributionEvent, ProgressEvent, @@ -18,7 +19,7 @@ type IpcFinished = { level: number, }; -export class InstancePlugin extends lib.BaseInstancePlugin { +export class InstancePlugin extends BaseInstancePlugin { syncStarted!: boolean; unexpectedError(err: Error) { diff --git a/plugins/statistics_exporter/instance.ts b/plugins/statistics_exporter/instance.ts index f67f3f6e..3a06a216 100644 --- a/plugins/statistics_exporter/instance.ts +++ b/plugins/statistics_exporter/instance.ts @@ -1,4 +1,6 @@ import * as lib from "@clusterio/lib"; +import { BaseInstancePlugin } from "@clusterio/host"; + const { Gauge } = lib; type IpcStats = { @@ -59,7 +61,7 @@ function setForceFlowStatistic( } -export class InstancePlugin extends lib.BaseInstancePlugin { +export class InstancePlugin extends BaseInstancePlugin { async init() { if (!this.instance.config.get("factorio.enable_save_patching")) { throw new Error("statistics_exporter plugin requires save patching."); @@ -107,7 +109,7 @@ export class InstancePlugin extends lib.BaseInstancePlugin { // take a long time for the command to go through if the command // stream is overloaded. Should the timeout be exceeded the // previous values for the metrics will end up being sent to controller. - let timeout = this.instance.config.get("statistics_exporter.command_timeout") * 1000; + let timeout = this.instance.config.get("statistics_exporter.command_timeout") as number * 1000; await lib.timeout(this.gatherMetrics(), timeout, undefined); } } diff --git a/plugins/subspace_storage/controller.ts b/plugins/subspace_storage/controller.ts index 24befee3..65c219cd 100644 --- a/plugins/subspace_storage/controller.ts +++ b/plugins/subspace_storage/controller.ts @@ -1,4 +1,4 @@ -import type ControlConnection from "@clusterio/controller/src/ControlConnection"; +import { BaseControllerPlugin, type ControlConnection } from "@clusterio/controller"; import fs from "fs-extra"; import path from "path"; @@ -70,7 +70,7 @@ async function saveDatabase( } } -export class ControllerPlugin extends lib.BaseControllerPlugin { +export class ControllerPlugin extends BaseControllerPlugin { items!: lib.ItemDatabase; itemUpdateRateLimiter!: lib.RateLimiter; itemsLastUpdate!: Map; @@ -96,7 +96,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { saveDatabase(this.controller.config, this.items, this.logger).catch(err => { this.logger.error(`Unexpected error autosaving items:\n${err.stack}`); }); - }, this.controller.config.get("subspace_storage.autosave_interval") * 1000); + }, this.controller.config.get("subspace_storage.autosave_interval") as number * 1000); this.neuralDole = new dole.NeuralDole({ items: this.items }); this.doleMagicId = setInterval(() => { @@ -180,7 +180,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { } } else { - let instance = this.controller.instances.get(instanceId); + let instance = this.controller.instances!.get(instanceId); let instanceName = instance ? instance.config.get("instance.name") : "unkonwn"; // use fancy neural net to calculate a "fair" dole division rate. @@ -200,7 +200,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { let count = dole.doleDivider({ object: { name: item.name, count: item.count, instanceId, instanceName }, items: this.items, - logItemTransfers: this.controller.config.get("subspace_storage.log_item_transfers"), + logItemTransfers: this.controller.config.get("subspace_storage.log_item_transfers") as boolean, logger: this.logger, }); if (count > 0) { @@ -230,7 +230,7 @@ export class ControllerPlugin extends lib.BaseControllerPlugin { } async handleSetStorageSubscriptionRequest(request: SetStorageSubscriptionRequest, src: lib.Address) { - let link = this.controller.wsServer.controlConnections.get(src.id); + let link = this.controller.wsServer.controlConnections.get(src.id)!; if (request.storage) { this.subscribedControlLinks.add(link); } else { diff --git a/plugins/subspace_storage/instance.ts b/plugins/subspace_storage/instance.ts index dfc8cbd6..9bc594a8 100644 --- a/plugins/subspace_storage/instance.ts +++ b/plugins/subspace_storage/instance.ts @@ -1,4 +1,5 @@ import * as lib from "@clusterio/lib"; +import { BaseInstancePlugin } from "@clusterio/host"; import { PlaceEvent, @@ -10,7 +11,7 @@ import { type IpcItems = [string, number][]; -export class InstancePlugin extends lib.BaseInstancePlugin { +export class InstancePlugin extends BaseInstancePlugin { pendingTasks!: Set; pingId?: ReturnType; diff --git a/plugins/subspace_storage/web/index.jsx b/plugins/subspace_storage/web/index.jsx index d5b211ec..04cd6b76 100644 --- a/plugins/subspace_storage/web/index.jsx +++ b/plugins/subspace_storage/web/index.jsx @@ -2,7 +2,10 @@ import React, { useContext, useEffect, useState } from "react"; import { Input, Table, Typography } from "antd"; import * as lib from "@clusterio/lib"; -import { notifyErrorHandler, useItemMetadata, useLocale, PageLayout, ControlContext } from "@clusterio/web_ui"; +import { + BaseWebPlugin, PageLayout, ControlContext, + notifyErrorHandler, useItemMetadata, useLocale, +} from "@clusterio/web_ui"; import { GetStorageRequest, SetStorageSubscriptionRequest } from "../dist/plugin/messages"; import "./index.css"; @@ -114,7 +117,7 @@ function StoragePage() { ; } -export class WebPlugin extends lib.BaseWebPlugin { +export class WebPlugin extends BaseWebPlugin { async init() { this.pages = [ { diff --git a/test/controller/BaseControllerPlugin.js b/test/controller/BaseControllerPlugin.js new file mode 100644 index 00000000..b4dda949 --- /dev/null +++ b/test/controller/BaseControllerPlugin.js @@ -0,0 +1,22 @@ +"use strict"; +const mock = require("../mock"); +const controller = require("@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(); + }); + 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({}); + }); + }); +}); diff --git a/test/host/BaseHostPlugin.js b/test/host/BaseHostPlugin.js new file mode 100644 index 00000000..a2e15739 --- /dev/null +++ b/test/host/BaseHostPlugin.js @@ -0,0 +1,22 @@ +"use strict"; +const assert = require("assert").strict; +const mock = require("../mock"); + +const host = require("@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(); + }); + it("should define defaults for hooks", async function() { + await hostPlugin.onHostConfigFieldChanged(); + await hostPlugin.onMetrics(); + await hostPlugin.onShutdown(); + hostPlugin.onControllerConnectionEvent("connect"); + await hostPlugin.onPrepareControllerDisconnect(); + }); + }); +}); diff --git a/test/host/BaseInstancePlugin.js b/test/host/BaseInstancePlugin.js new file mode 100644 index 00000000..20e077a2 --- /dev/null +++ b/test/host/BaseInstancePlugin.js @@ -0,0 +1,54 @@ +"use strict"; +const assert = require("assert").strict; +const mock = require("../mock"); + +const host = require("@clusterio/host"); + +describe("host/src/BaseInstancePlugin", function() { + describe("class BaseInstancePlugin", function() { + let instancePlugin; + it("should be constructible", async function() { + instancePlugin = new host.BaseInstancePlugin({}, new mock.MockInstance(), {}); + await instancePlugin.init(); + }); + 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(); + }); + describe("sendRcon", function() { + it("should send commands out of order", async function() { + instancePlugin.instance.server.rconCommandResults.set("a", { time: 100, response: "a" }); + instancePlugin.instance.server.rconCommandResults.set("b", { time: 50, response: "b" }); + + let a = instancePlugin.sendRcon("a"); + let b = instancePlugin.sendRcon("b"); + let result = await Promise.race([a, b]); + assert.equal(result, "b"); + }); + it("should propagate errors", async function() { + instancePlugin.instance.server.rconCommandResults.set("a", new Error("ref")); + await assert.rejects(instancePlugin.sendRcon("a"), new Error("ref")); + }); + }); + describe("sendOrderedRcon", function() { + it("should send commands in order", async function() { + instancePlugin.instance.server.rconCommandResults.set("a", { time: 100, response: "a" }); + instancePlugin.instance.server.rconCommandResults.set("b", { time: 50, response: "b" }); + + let a = instancePlugin.sendOrderedRcon("a"); + let b = instancePlugin.sendOrderedRcon("b"); + let result = await Promise.race([a, b]); + assert.equal(result, "a"); + }); + it("should propagate errors", async function() { + instancePlugin.instance.server.rconCommandResults.set("a", new Error("ref")); + await assert.rejects(instancePlugin.sendOrderedRcon("a"), new Error("ref")); + }); + }); + }); +}); diff --git a/test/host/Instance.js b/test/host/Instance.js index b53d015c..b009d84b 100644 --- a/test/host/Instance.js +++ b/test/host/Instance.js @@ -65,7 +65,8 @@ describe("class Instance", function() { new lib.MessageEvent( 1, src, addr("controller"), "InstancePlayerUpdateEvent", new lib.InstancePlayerUpdateEvent( - "join", "player", undefined, + "join", + "player", new PlayerStats({ join_count: 1, last_join_at: stats.lastJoinAt.getTime(), @@ -110,14 +111,16 @@ describe("class Instance", function() { new lib.MessageEvent( 2, src, addr("controller"), "InstancePlayerUpdateEvent", new lib.InstancePlayerUpdateEvent( - "leave", "player", "quit", + "leave", + "player", new PlayerStats({ join_count: 1, online_time_ms: stats.onlineTimeMs, last_join_at: stats.lastJoinAt.getTime(), last_leave_at: stats.lastLeaveAt.getTime(), last_leave_reason: "quit", - }) + }), + "quit", ) ), ); @@ -160,7 +163,8 @@ describe("class Instance", function() { new lib.MessageEvent( 2, src, addr("controller"), "InstancePlayerUpdateEvent", new lib.InstancePlayerUpdateEvent( - "join", "foo", undefined, + "join", + "foo", new PlayerStats({ join_count: 1, last_join_at: stats.lastJoinAt.getTime(), @@ -181,14 +185,16 @@ describe("class Instance", function() { new lib.MessageEvent( 2, src, addr("controller"), "InstancePlayerUpdateEvent", new lib.InstancePlayerUpdateEvent( - "leave", "player", "quit", + "leave", + "player", new PlayerStats({ join_count: 1, online_time_ms: stats.onlineTimeMs, last_join_at: stats.lastJoinAt.getTime(), last_leave_at: stats.lastLeaveAt.getTime(), last_leave_reason: "quit", - }) + }), + "quit", ) ) ); diff --git a/test/lib/plugin.js b/test/lib/plugin.js index a23b80d0..30587b30 100644 --- a/test/lib/plugin.js +++ b/test/lib/plugin.js @@ -6,70 +6,6 @@ const lib = require("@clusterio/lib"); describe("lib/plugin", function() { - describe("class BaseInstancePlugin", function() { - let instancePlugin; - it("should be constructible", async function() { - instancePlugin = new lib.BaseInstancePlugin({}, new mock.MockInstance(), {}); - await instancePlugin.init(); - }); - 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(); - }); - describe("sendRcon", function() { - it("should send commands out of order", async function() { - instancePlugin.instance.server.rconCommandResults.set("a", { time: 100, response: "a" }); - instancePlugin.instance.server.rconCommandResults.set("b", { time: 50, response: "b" }); - - let a = instancePlugin.sendRcon("a"); - let b = instancePlugin.sendRcon("b"); - let result = await Promise.race([a, b]); - assert.equal(result, "b"); - }); - it("should propagate errors", async function() { - instancePlugin.instance.server.rconCommandResults.set("a", new Error("ref")); - await assert.rejects(instancePlugin.sendRcon("a"), new Error("ref")); - }); - }); - describe("sendOrderedRcon", function() { - it("should send commands in order", async function() { - instancePlugin.instance.server.rconCommandResults.set("a", { time: 100, response: "a" }); - instancePlugin.instance.server.rconCommandResults.set("b", { time: 50, response: "b" }); - - let a = instancePlugin.sendOrderedRcon("a"); - let b = instancePlugin.sendOrderedRcon("b"); - let result = await Promise.race([a, b]); - assert.equal(result, "a"); - }); - it("should propagate errors", async function() { - instancePlugin.instance.server.rconCommandResults.set("a", new Error("ref")); - await assert.rejects(instancePlugin.sendOrderedRcon("a"), new Error("ref")); - }); - }); - }); - - describe("class BaseControllerPlugin", function() { - let controllerPlugin; - it("should be constructible", async function() { - controllerPlugin = new lib.BaseControllerPlugin({}, {}, {}, new mock.MockLogger()); - await controllerPlugin.init(); - }); - 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({}); - }); - }); - describe("invokeHook()", function() { let betaTestCalled = false; let plugins = new Map([ diff --git a/test/lib/plugin_loader.js b/test/lib/plugin_loader.js index 88e06bea..8d1639c6 100644 --- a/test/lib/plugin_loader.js +++ b/test/lib/plugin_loader.js @@ -4,6 +4,7 @@ const fs = require("fs-extra"); const path = require("path"); const lib = require("@clusterio/lib"); +const { BaseControllerPlugin } = require("@clusterio/controller"); const { escapeRegExp } = lib; @@ -64,7 +65,7 @@ describe("lib/plugin_loader", function() { ); }); }); - describe("loadControllerPluginClass()", 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"); @@ -81,11 +82,12 @@ describe("lib/plugin_loader", function() { it("should throw if class is missing from entrypoint", async function() { const requirePath = path.resolve(missingClass); await assert.rejects( - lib.loadControllerPluginClass({ - name: "test", - controllerEntrypoint: "controller", - requirePath, - }), + lib.loadPluginClass( + "test", + path.posix.join(requirePath, "controller"), + "ControllerPlugin", + BaseControllerPlugin, + ), { message: `PluginError: Expected ${path.posix.join(requirePath, "controller")} ` + @@ -96,11 +98,12 @@ describe("lib/plugin_loader", function() { it("should throw if class is not a subclass of BaseControllerPlugin", async function() { const requirePath = path.resolve(wrongParentClass); await assert.rejects( - lib.loadControllerPluginClass({ - name: "test", - controllerEntrypoint: "controller", - requirePath, - }), + lib.loadPluginClass( + "test", + path.posix.join(requirePath, "controller"), + "ControllerPlugin", + BaseControllerPlugin, + ), { message: "PluginError: Expected ControllerPlugin exported from " +