Move base plugins to their respective packages

Move BaseControllerPlugin to controller/src/BaseControllerPlugin.ts
Move BaseInstancePlugin to host/src/BaseInstancePlugin.ts
Move BaseControlPlugin to ctl/src/BaseCtlPlugin.ts
Move BaseWebPlugin to web_ui/src/BaseWebPlugin.ts

This solves the circular package dependency problem of the base plugins
needing types that are available only in the dependencies to lib.
This commit is contained in:
Hornwitser committed 2023-11-25 18:41:43 +01:00
1 parent 63dc370ea0
commit 808eda0513
51 files changed
+1073 -1060

No files matched your search

+9 -9
View File
@@ -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,7 +136,7 @@ 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.
For the instance plugin it's exactly the same except "Controller" is replaced with "Instance", and for the clusterioctl plugin "Ctl" is used.
The available hooks that you can override are documented in the base class [in lib/plugin.js](/packages/lib/plugin.js).
It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
@@ -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,
}
```
+5 -4
View File
@@ -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();
}
+9
View File
@@ -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();
}
+1 -1
View File
@@ -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"
@@ -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<void | AsyncIterator<CollectorResult>> { }
/**
* 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<T>(event: Event<T>) {
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);
}
}
}
}
+9 -3
View File
@@ -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<string, lib.BaseControllerPlugin> = new Map();
plugins: Map<string, BaseControllerPlugin> = new Map();
/** WebSocket server */
wsServer: WsServer;
@@ -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);
+1 -1
View File
@@ -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",
},
+25 -15
View File
@@ -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<string, lib.BaseControlPlugin>;
plugins: Map<string, BaseCtlPlugin>;
/** 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<string, lib.BaseControlPlugin>
ctlPlugins: Map<string, BaseCtlPlugin>
) {
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<string, string>) {
lib.registerPluginConfigGroups(pluginInfos);
lib.finalizeConfigs();
let controlPlugins = new Map<string, lib.BaseControlPlugin>();
let ctlPlugins = new Map<string, BaseCtlPlugin>();
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();
}
+6
View File
@@ -0,0 +1,6 @@
import { bootstrap } from "./ctl";
export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin";
if (module === require.main) {
bootstrap();
}
+1 -1
View File
@@ -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"
+47
View File
@@ -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) { }
}
+3 -2
View File
@@ -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<string, lib.BaseControlPlugin>, yargs: Argv) {
export async function registerCommands(ctlPlugins: Map<string, BaseCtlPlugin>, 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<string, lib.BaseContr
rootCommands.add(logCommands);
rootCommands.add(debugCommands);
for (let controlPlugin of controlPlugins.values()) {
for (let controlPlugin of ctlPlugins.values()) {
await controlPlugin.addCommands(rootCommands);
}
+1 -1
View File
@@ -3,7 +3,7 @@
"references": [
{ "path": "../lib" },
],
"include": ["package.json", "ctl.ts", "src/**/*.ts"],
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
"compilerOptions": {
"outDir": "dist",
},
+5 -1
View File
@@ -245,7 +245,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 +287,7 @@ ${err.stack}`
process.exitCode = 1;
});
}
if (module === require.main) {
bootstrap();
}
+6
View File
@@ -0,0 +1,6 @@
import { bootstrap } from "./host";
export { default as BaseInstancePlugin } from "./src/BaseInstancePlugin";
if (module === require.main) {
bootstrap();
}
+1 -1
View File
@@ -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"
+257
View File
@@ -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<void | AsyncIterator<CollectorResult>> { }
/**
* 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<string> {
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<string>((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;
}
}
+8 -2
View File
@@ -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<string, lib.BaseInstancePlugin>;
plugins: Map<string, BaseInstancePlugin>;
config: lib.InstanceConfig;
logger: lib.Logger;
server: FactorioServer;
@@ -492,7 +493,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();
+3 -1
View File
@@ -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)) {
+1 -1
View File
@@ -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",
},
+31 -1
View File
@@ -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<typeof this.jsonSchema>) {
return new this(json.roles);
}
}
-27
View File
@@ -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<typeof this.jsonSchema>) {
return new this(json.roles);
}
}
export class UserUpdateEvent {
declare ["constructor"]: typeof UserUpdateEvent;
static type = "event" as const;
+1 -1
View File
@@ -15,6 +15,7 @@ import type { RequestClass, EventClass } from "./link";
*/
export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)[] = [
core.PingRequest,
core.AccountUpdateEvent,
controller.ControllerConfigGetRequest,
controller.ControllerConfigSetFieldRequest,
@@ -105,6 +106,5 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
user.UserSetWhitelistedRequest,
user.UserSetBannedRequest,
user.UserDeleteRequest,
user.AccountUpdateEvent,
user.UserUpdateEvent,
];
+6 -811
View File
@@ -3,22 +3,7 @@
* @module lib/plugin
*/
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";
// TODO Add proper typing for plugins
/* Used to define the export of info.ts from plugins */
@@ -33,14 +18,14 @@ export type PluginDeclaration = {
controllerEntrypoint?: string;
ControllerConfigGroup?: typeof PluginConfigGroup;
controlEntrypoint?: string;
ctlEntrypoint?: string;
webEntrypoint?: string;
ControlConfigGroup?: typeof PluginConfigGroup;
messages?: any[];
webEntrypoint?: string;
routes?: string[];
}
export type PluginNodeEnvInfo = PluginDeclaration & {
requirePath: string;
version: string;
@@ -53,19 +38,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,
@@ -76,787 +52,6 @@ interface PlayerEvent {
*/
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`.
*/
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<void | AsyncIterator<CollectorResult>> { }
/**
* 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<string> {
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<string>((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<void | AsyncIterator<CollectorResult>> { }
/**
* 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") { }
}
/**
* Invokes the given hook on all plugins
@@ -867,7 +62,7 @@ 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<string, BasePlugin>, hook: string, ...args: any[]) {
export async function invokeHook(plugins: Map<string, any>, hook: string, ...args: any[]) {
let results: unknown[] = [];
for (let [name, plugin] of plugins) {
try {
+10 -53
View File
@@ -52,65 +52,22 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
return plugins;
}
function loadPluginClass(
entrypointName: "controllerEntrypoint"|"instanceEntrypoint"|"controlEntrypoint",
export async function loadPluginClass<Class extends { new (...args: any): any }>(
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<Class> {
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<typeof libPlugin.BaseControllerPlugin> {
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<typeof libPlugin.BaseInstancePlugin> {
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<typeof libPlugin.BaseControlPlugin> {
return loadPluginClass("controlEntrypoint", "ControlPlugin", libPlugin.BaseControlPlugin, pluginInfo);
}
+201
View File
@@ -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") { }
}
+2 -1
View File
@@ -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) {
@@ -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<PluginExtraProps & {plugin:lib.BaseWebPlugin}>};
type ComponentExtra = {[key: string]: React.ComponentType<PluginExtraProps & {plugin:BaseWebPlugin}>};
let ComponentExtra = (plugin.componentExtra as ComponentExtra)[props.component];
components.push(<ComponentExtra key={name} plugin={plugin} {...props}/>);
}
+2 -2
View File
@@ -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";
+2 -1
View File
@@ -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<string>(control.accountName || "");
let [roles, setRoles] = useState<any[]>(control.accountRoles || []);
+3 -3
View File
@@ -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: <ModsPage />,
},
{
+2 -1
View File
@@ -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<string, lib.BaseWebPlugin>,
public plugins: Map<string, BaseWebPlugin>,
) {
super(connector);
+3 -2
View File
@@ -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);
}
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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() {
+8 -9
View File
@@ -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<string, { instanceId: number, expires?: number }>;
playerDatastore!: Map<string, IpcPlayerData>;
autosaveId!: ReturnType<typeof setInterval>;
@@ -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) {
+2 -1
View File
@@ -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<string>;
disconnecting!: boolean;
+2 -3
View File
@@ -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() {
</PageLayout>;
}
export class WebPlugin extends lib.BaseWebPlugin {
export class WebPlugin extends BaseWebPlugin {
async init() {
this.pages = [
{
+6 -5
View File
@@ -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<string> {
type PlayerCode = { playerCode: string, verifyCode: string | null, expires: number };
export class ControllerPlugin extends lib.BaseControllerPlugin {
export class ControllerPlugin extends BaseControllerPlugin {
players!: Map<string, PlayerCode>;
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() };
}
+2 -2
View File
@@ -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.");
+2 -3
View File
@@ -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 = [{
+2 -1
View File
@@ -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<string, Technology>;
progressRateLimiter!: lib.RateLimiter;
progressBroadcastId!: ReturnType<typeof setInterval> | null;
+2 -1
View File
@@ -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) {
+4 -2
View File
@@ -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);
}
}
+6 -6
View File
@@ -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<string, number>;
@@ -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 {
+2 -1
View File
@@ -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<any>;
pingId?: ReturnType<typeof setTimeout>;
+5 -2
View File
@@ -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() {
</PageLayout>;
}
export class WebPlugin extends lib.BaseWebPlugin {
export class WebPlugin extends BaseWebPlugin {
async init() {
this.pages = [
{
+22
View File
@@ -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({});
});
});
});
+54
View File
@@ -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"));
});
});
});
});
-64
View File
@@ -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([
+14 -11
View File
@@ -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 " +