From e74065a493e64101978f8bf9acc0eb3acabd972f Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 9 Jan 2024 00:23:01 +0100 Subject: [PATCH] Remove config groups Refactor away the config group concept and simplyfy the way config fields are defined. Fields are defined directly as properties of the static field fieldDefinitions on the class itself with a separate interface to declare the types of fields. This makes it possible to easily make accessing the config fields type checked in a way that lets plugins extend the interfaces with their own config options by adding additional fields to the interface via declation merging. --- docs/config-system.md | 38 +- docs/writing-plugins.md | 43 +- packages/controller/controller.ts | 3 +- .../controller/src/BaseControllerPlugin.ts | 12 +- packages/controller/src/ControlConnection.ts | 8 +- packages/controller/src/Controller.ts | 16 +- packages/controller/src/routes.ts | 4 +- packages/ctl/ctl.ts | 11 +- packages/ctl/src/commands.ts | 58 +- packages/host/host.ts | 3 +- packages/host/src/BaseHostPlugin.ts | 7 +- packages/host/src/BaseInstancePlugin.ts | 7 +- packages/host/src/Host.ts | 6 +- packages/host/src/Instance.ts | 20 +- packages/lib/src/config/classes.ts | 878 ++++------------ packages/lib/src/config/definitions.ts | 930 ++++++++--------- packages/lib/src/helpers.ts | 19 + packages/lib/src/plugin.ts | 10 +- packages/lib/src/shared_commands.ts | 9 +- packages/web_ui/src/bootstrap.tsx | 3 +- .../web_ui/src/components/BaseConfigTree.tsx | 73 +- plugins/inventory_sync/controller.ts | 2 +- plugins/inventory_sync/info.ts | 53 +- plugins/inventory_sync/instance.ts | 2 +- plugins/player_auth/controller.ts | 6 +- plugins/player_auth/info.ts | 42 +- plugins/statistics_exporter/info.ts | 26 +- plugins/statistics_exporter/instance.ts | 2 +- plugins/subspace_storage/controller.ts | 2 +- plugins/subspace_storage/info.ts | 65 +- test/controller/InstanceInfo.js | 3 - test/host.js | 4 - test/host/Instance.js | 4 - test/integration/clusterio.js | 20 +- test/lib/config/classes.js | 960 ++++++------------ 35 files changed, 1259 insertions(+), 2090 deletions(-) diff --git a/docs/config-system.md b/docs/config-system.md index 3cfdec8b..a1f4018a 100644 --- a/docs/config-system.md +++ b/docs/config-system.md @@ -2,36 +2,34 @@ This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you. -Clusterio uses a group based configuration system to manage settings for the controller, instances, hosts, and the clusterioctl utility. -Plugins get their own config group for the controller and instances which they can add their own fields to. -The fields are defined early in the startup, and the groups and their fields for disabled plugins will still be created. +Clusterio uses a field based configuration system to manage settings for the controller, instances, hosts, and the clusterioctl utility. +Plugins can add their own config fields for the controller and instances configs. +The fields are defined early in the startup, and fields for disabled plugins will still be created. -The built-in config groups and their fields are defined in [packages/lib/config/definitions.js](/packages/lib/config/definitions.js) while plugins can add their own config groups to the `ControllerConfigGroup` and `InstanceConfigGroup` properties of the `info.js` export using subclasses of `PluginConfigGroup`. -The plugin group has the enabled field pre-defined on it, and it's used to enable/disable plugins. +The built-in config fields are defined in [packages/lib/config/definitions.js](/packages/lib/config/definitions.js) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `info.js` export. +A `.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup. ## Defing Fields -Fields are defined using the `define` class method on group classes after the groupName have been set and before the class has been finalized. -The `define` method takes an object as argument with the following properties: +Fields are defined as POJS object properties where the name of the property correspond to the name of the configuration field. +The property name is the primary way the field is accessed both in code and on the command line, as such it should follow the lower\_case\_underscore style and consist of a group name and a field name separated by a dot. +For plugins the group name is the name of the plugin, e.g., the `level` field of the `foo_frobber` plugin would be named `foo_frobber.lavel`. **access**: The locations this config field can be read and modified from. This is an array of strings which can contain the values `"controller"`, `"host"` and `"control"`. If a party is missing in this array then that party will not receive the value of this field when the config is shared, and will be unable to modify the field. - Takes the value of `defaultAccess` set on the config group class if not passed. - Note that instance config groups should have both `"controller"` and `"host"` for all of its fields or unexpected behaviour may occur. + If not set any party can read and modify this config entry. + Note that instance config fields should have both `"controller"` and `"host"` or unexpected behaviour may occur. **type**: The type of config value this field will support. The supported values are boolean, string, number, and object, and if an attept is made to set the field to a value that is not of the right type it will be reject unless it's a string that can be converted to the right type. -**name**: - Name of the configuration field, this is the primary way the field is accessed, both in code and on the command line. - As such it should follow the lower\_case\_underscore style and not contain any spaces or dots. - **title** (optional): Text used to identify the config field in user interfaces. + This should not contain the grouping used in `name`. **description** (optional): Text used to describe the config field in user interfaces. @@ -44,7 +42,7 @@ The `define` method takes an object as argument with the following properties: True if this field can be set to null. Defaults to false. -**initial_value** (optional if optional is true): +**initialValue** (optional if optional is true): Value this config field should take in a newly initialized config. This can also be an async function returning the value to use. @@ -53,24 +51,24 @@ If the value for a field stored in on disk somehow ends up being invalid the fie ## Accessing Fields -On the Config instance there's two ways to access fields: through the set and get methods using dot notation. +On the Config instance there's two ways to access fields: through the set and get methods. E.g.: let value = configInstance.get("group.field"); -Or by getting the group first and then calling set or get on that. +Or by through the raw fields object (which is best avoided as it doesn't check types or whether the accessed field exists). E.g.: - let value = configInstance.group("group").get("field") + let value = configInstance.fields["group.field"] -The set method takes an extra argument, which is the value to set the field to. -If the value is not valid this will throw an InvalidValue exception, if the group or field doesn't exist it will throw an InvalidField exception. +The set method also takes an extra argument, which is the value to set the field to. +If the value is not valid this will throw an InvalidValue exception, if the field doesn't exist it will throw an InvalidField exception. ## Config Lifecycle Values for all config fields are created when the config is initialized, as well as for any fields with missing values when a config is loaded. -Once a field has a value it is never automatically removed, even if the field or group it was part of no longer exists. +Once a field has a value it is never automatically removed, even if the field it was part of no longer exists. This ensures that plugin configuration is not lost should the server be started up without the plugins that defined the configuration installed. For instance configs, they are kept on the controller and serialized and sent over to the relevant hosts on startup. diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 6888df99..5aab0f3a 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -82,8 +82,8 @@ The following properties are recognized: This is an optional paramater. A plugin may have code only for instances but it must still be loaded on the controller in order for it to be possible to load it on an instance. -**InstanceConfigGroup**: - Subclass of `PluginConfigGroup` for defining the per instance configuration fields for this plugin. +**instanceConfigFields**: + Object mapping instance configuration fields to `FieldDefinitions`for this plugin. See [Plugin Configuration](#plugin-configuration) **controllerEntrypoint**: @@ -92,7 +92,7 @@ The following properties are recognized: A plugin can be made that only runs on the controller. **ControllerConfigGroup**: - Subclass of `PluginConfigGroup` for defining the controller configuration fields for this plugin. + Object mapping controller configuration fields to `FieldDefinitions`for this plugin. See [Plugin Configuration](#plugin-configuration) **ctlEntrypoint**: @@ -202,32 +202,35 @@ For plugin hooks expections thrown are automatically catched and logged, but for ## Plugin Configuration Clusterio provides a configuration system that handles storing, distributing, editing and validating config fields for you. -You can take advantage of it by subclassing `PluginConfigGroup`, setting `defaultAccess` to where config entries can be accessed from, setting the `groupName` to your plugin name, defining fields on it, finalizing it, and passing it as either `ControllerConfigGroup` or `InstanceConfigGroup` in the `info.js` export. +You can take advantage of it by declaring config fields under either `controllerConfigFields` or `instanceConfigFields` in the `info.js` export. For example in info.js: -```js -const lib = require("@clusterio/lib"); +```ts +import type * as lib from "@clusterio/lib"; -class ControllerConfigGroup extends lib.PluginConfigGroup { } -ControllerConfigGroup.defaultAccess = ["controller", "host", "control"]; -ControllerConfigGroup.groupName = "foo_frobber"; -ControllerConfigGroup.define({ - name: "level", - description: "Level of frobnication done", - type: "number", - initial_value: 2, -}); -ControllerConfigGroup.finalize(); +declare module "@clusterio/lib" { + // Extend the interface defining available fields so that instance.config.get(...) + // will have our own options. + export interface ControllerConfigFields { + "foo_frobber.level": number, // If optional is true then this should have | null in addition. + } +} -module.exports = { +export default { ... - ControllerConfigGroup: ControllerConfigGroup, -}; + controllerConfigFields: { + "foo_frobber.level": { + description: "Level of frobnication done", + type: "number", + initialValue: 2, + }, + } +} satisfies lib.PluginDeclaration; ``` Code inside the `ControllerPlugin` class will then be able to access the level config field through the `Config` object at `this.controller.config`, for example in the ControllerPluginClass: -```js +```ts async init() { let level = this.controller.config.get("foo_frobber.level"); this.logger.info(`I got a frobnication level of ${level}`); diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index c84f791c..08f3a71f 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -316,8 +316,7 @@ async function initialize(): Promise { logger.info("Loading Plugin info"); const pluginInfos = await lib.loadPluginInfos(pluginList); lib.registerPluginMessages(pluginInfos); - lib.registerPluginConfigGroups(pluginInfos); - lib.finalizeConfigs(); + lib.addPluginConfigFields(pluginInfos); const controllerConfigPath = args.config; logger.info(`Loading config from ${controllerConfigPath}`); diff --git a/packages/controller/src/BaseControllerPlugin.ts b/packages/controller/src/BaseControllerPlugin.ts index 9aec578b..281810c9 100644 --- a/packages/controller/src/BaseControllerPlugin.ts +++ b/packages/controller/src/BaseControllerPlugin.ts @@ -1,5 +1,5 @@ import type { - CollectorResult, ConfigGroup, Event, InstanceStatus, Logger, + CollectorResult, Event, InstanceStatus, Logger, ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo, } from "@clusterio/lib"; import type Controller from "./Controller"; @@ -99,12 +99,11 @@ export default class BaseControllerPlugin { * 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 curr - The current value of the field. * @param prev - The previous value of the field. */ - async onControllerConfigFieldChanged(group: ConfigGroup, field: string, prev: unknown) { } + async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { } /** * Called when the value of an instance config field changed. @@ -114,12 +113,11 @@ export default class BaseControllerPlugin { * * @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 curr - The current value of the field. * @param prev - The previous value of the field. */ - async onInstanceConfigFieldChanged(instance: InstanceInfo, group: ConfigGroup, field: string, prev: unknown) { } + async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { } /** * Called before collecting Prometheus metrics diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 990934ec..6cf9deca 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -174,12 +174,12 @@ export default class ControlConnection extends BaseConnection { } async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) { - this._controller.config.set(request.field, request.value, "control"); + this._controller.config.set(request.field as keyof lib.ControllerConfigFields, request.value, "control"); } async handleControllerConfigSetPropRequest(request: lib.ControllerConfigSetPropRequest) { let { field, prop, value } = request; - this._controller.config.setProp(field, prop, value, "control"); + this._controller.config.setProp(field as keyof lib.ControllerConfigFields, prop, value, "control"); } async handleHostRevokeTokensRequest(request: lib.HostRevokeTokensRequest) { @@ -261,14 +261,14 @@ export default class ControlConnection extends BaseConnection { throw new lib.RequestError("Setting instance.id is not supported"); } - instance.config.set(request.field, request.value, "control"); + instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control"); await this._controller.instanceConfigUpdated(instance); } async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) { let instance = this._controller.getRequestInstance(request.instanceId); let { field, prop, value } = request; - instance.config.setProp(field, prop, value, "control"); + instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control"); await this._controller.instanceConfigUpdated(instance); } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 6db5d9df..307208d0 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -216,11 +216,11 @@ export default class Controller { this._startDevServer(args); } - this.config.on("fieldChanged", (group, field, prev) => { - if (group.name === "controller" && field === "autosave_interval") { + this.config.on("fieldChanged", (field, curr, prev) => { + if (field === "controller.autosave_interval") { this.onAutosaveIntervalChanged(); } - lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", group, field, prev); + lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", field, curr, prev); }); for (let instance of this.instances.values()) { this.addInstanceHooks(instance); @@ -799,7 +799,7 @@ export default class Controller { this.clearSavesOfInstance(instanceId); // Assign to target - instance.config.set("instance.assigned_host", hostId); + instance.config.set("instance.assigned_host", hostId ?? null); // "fieldChanged" event handler will set this.instancesDirty if (hostId !== undefined && newHostConnection) { await newHostConnection.send( @@ -859,14 +859,14 @@ export default class Controller { } addInstanceHooks(instance: InstanceInfo) { - instance.config.on("fieldChanged", (group: lib.ConfigGroup, field: string, prev: any) => { + instance.config.on("fieldChanged", (field: string, curr: any, prev: any) => { instance.updatedAt = Date.now(); - if (group.name === "instance" && field === "name") { + if (field === "instance.name") { this.instanceDetailsUpdated([instance]); } this.instancesDirty = true; - lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", instance, group, field, prev); + lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", instance, field, curr, prev); }); } @@ -988,7 +988,7 @@ export default class Controller { logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`); } - if (!this.config.group(pluginInfo.name).get("load_plugin")) { + if (!this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields)) { continue; } diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index 5fceb72a..b56d54f6 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -109,7 +109,9 @@ function getPlugins(req: Request, res: Response) { for (let pluginInfo of req.app.locals.controller.pluginInfos) { let name = pluginInfo.name; let loaded = req.app.locals.controller.plugins.has(name); - let enabled = loaded && req.app.locals.controller.config.group(pluginInfo.name).get("load_plugin") as boolean; + let enabled = loaded && req.app.locals.controller.config.get( + `${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields, + ) as unknown as boolean; let web: { main:any, error:string|undefined } = { main: undefined, error: undefined}; let devPlugins = req.app.locals.devPlugins; if (devPlugins && devPlugins.has(name)) { diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index 8134964c..46863461 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -114,8 +114,7 @@ export class Control extends lib.Link { async function loadPlugins(pluginList: Map) { let pluginInfos = await lib.loadPluginInfos(pluginList); lib.registerPluginMessages(pluginInfos); - lib.registerPluginConfigGroups(pluginInfos); - lib.finalizeConfigs(); + lib.addPluginConfigFields(pluginInfos); let ctlPlugins = new Map(); for (let pluginInfo of pluginInfos) { @@ -261,16 +260,16 @@ async function startControl() { } let tlsCa: string | undefined; - let tlsCaPath = controlConfig.get("control.tls_ca") as string | null; + let tlsCaPath = controlConfig.get("control.tls_ca"); if (tlsCaPath) { tlsCa = await fs.readFile(tlsCaPath, "utf8"); } let controlConnector = new ControlConnector( - controlConfig.get("control.controller_url") as string, - controlConfig.get("control.max_reconnect_delay") as number, + controlConfig.get("control.controller_url")!, + controlConfig.get("control.max_reconnect_delay"), tlsCa, - controlConfig.get("control.controller_token") as string, + controlConfig.get("control.controller_token")!, ); let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins); try { diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 9096680f..215c67b3 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -57,28 +57,26 @@ async function configToKeyVal(data: string) { } function serializedConfigToString( - serializedConfig: lib.SerializedConfig, - configGroup: typeof lib.Config, + serializedConfig: lib.ConfigSchema, + configGroup: typeof lib.ControllerConfig | typeof lib.InstanceConfig, disallowedList: Record, ) { let allConfigElements = ""; - for (let group of serializedConfig.groups) { - for (let [name, value] of Object.entries(group.fields)) { - if (`${group.name}.${name}` in disallowedList) { - continue; - } - let desc = ""; - try { - desc += configGroup.groups.get(group.name)!._definitions.get(name)!.description; - } catch (err) { - desc += "No description found"; - } - // split onto two lines for readability and es-lint - if (String(value) === "null") { - value = ""; - } - allConfigElements += `${group.name}.${name} = ${value}\n\n`; + for (let [name, value] of Object.entries(serializedConfig)) { + if (name in disallowedList) { + continue; } + let desc = ""; + try { + desc += (configGroup.fieldDefinitions as any)[name]!.description; + } catch (err) { + desc += "No description found"; + } + // split onto two lines for readability and es-lint + if (String(value) === "null") { + value = ""; + } + allConfigElements += `${name} = ${value}\n\n`; } return allConfigElements; } @@ -93,10 +91,8 @@ controllerConfigCommands.add(new lib.Command({ handler: async function(args: object, control: Control) { let config = await control.send(new lib.ControllerConfigGetRequest()); - for (let group of config.groups) { - for (let [name, value] of Object.entries(group.fields)) { - print(`${group.name}.${name} ${JSON.stringify(value)}`); - } + for (let [name, value] of Object.entries(config)) { + print(`${name} ${JSON.stringify(value)}`); } }, })); @@ -115,7 +111,7 @@ controllerConfigCommands.add(new lib.Command({ } else if (args.value === undefined) { args.value = ""; } - await control.send(new lib.ControllerConfigSetFieldRequest(args.field as string, args.value as string)); + await control.send(new lib.ControllerConfigSetFieldRequest(args.field, args.value)); }, })); @@ -214,7 +210,7 @@ const controllerPluginCommands = new lib.CommandTree({ controllerPluginCommands.add(new lib.Command({ definition: ["list", "List plugins on controller"], handler: async function(args: object, control: Control) { - let url = new URL(control.config.get("control.controller_url") as string); + let url = new URL(control.config.get("control.controller_url")!); url.pathname += "api/plugins"; let response = await phin<[]>({ url, @@ -335,10 +331,8 @@ instanceConfigCommands.add(new lib.Command({ let instanceId = await lib.resolveInstance(control, args.instance); let config = await control.send(new lib.InstanceConfigGetRequest(instanceId)); - for (let group of config.groups) { - for (let [name, value] of Object.entries(group.fields)) { - print(`${group.name}.${name} ${JSON.stringify(value)}`); - } + for (let [name, value] of Object.entries(config)) { + print(`${name} ${JSON.stringify(value)}`); } }, })); @@ -612,7 +606,7 @@ instanceSaveCommands.add(new lib.Command({ let content = await fs.readFile(args.filepath); let instanceId = await lib.resolveInstance(control, args.instance); - let url = new URL(control.config.get("control.controller_url") as string); + let url = new URL(control.config.get("control.controller_url")!); url.pathname += "api/upload-save"; url.searchParams.append("instance_id", String(instanceId)); url.searchParams.append("filename", filename); @@ -694,7 +688,7 @@ instanceSaveCommands.add(new lib.Command({ let instanceId = await lib.resolveInstance(control, args.instance); let streamId = await control.send(new lib.InstanceDownloadSaveRequest(instanceId, args.save)); - let url = new URL(control.config.get("control.controller_url") as string); + let url = new URL(control.config.get("control.controller_url")!); url.pathname += `api/stream/${streamId}`; let response = await phin({ url, method: "GET", @@ -1280,7 +1274,7 @@ modCommands.add(new lib.Command({ // phin doesn't support streaming requests :( let content = await fs.readFile(args.file); - let url = new URL(control.config.get("control.controller_url") as string); + let url = new URL(control.config.get("control.controller_url")!); url.pathname += "api/upload-mod"; url.searchParams.append("filename", filename); @@ -1324,7 +1318,7 @@ modCommands.add(new lib.Command({ handler: async function(args: { name: string, modVersion: string }, control: Control) { let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion)); - let url = new URL(control.config.get("control.controller_url") as string); + let url = new URL(control.config.get("control.controller_url")!); url.pathname += `api/stream/${streamId}`; let response = await phin({ url, method: "GET", diff --git a/packages/host/host.ts b/packages/host/host.ts index 867c1234..56a8c346 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -142,8 +142,7 @@ async function startHost() { logger.info("Loading Plugin info"); let pluginInfos = await lib.loadPluginInfos(pluginList); lib.registerPluginMessages(pluginInfos); - lib.registerPluginConfigGroups(pluginInfos); - lib.finalizeConfigs(); + lib.addPluginConfigFields(pluginInfos); logger.info(`Loading config from ${args.config}`); let hostConfig; diff --git a/packages/host/src/BaseHostPlugin.ts b/packages/host/src/BaseHostPlugin.ts index d161fcab..87664cff 100644 --- a/packages/host/src/BaseHostPlugin.ts +++ b/packages/host/src/BaseHostPlugin.ts @@ -1,5 +1,5 @@ import type { - ConfigGroup, CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo, + CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo, } from "@clusterio/lib"; import type Host from "./Host"; @@ -46,12 +46,11 @@ export default class BaseHostPlugin { * Invoked after the value of the config field given by `field` has * changed on this host. * - * @param group - - * The group who's field got changed. * @param field - Name of the field that changed. + * @param curr - The current value of the field. * @param prev - The previous value of the field. */ - async onHostConfigFieldChanged(group: ConfigGroup, field: string, prev: unknown) { } + async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) { } /** * Called before collecting Prometheus metrics diff --git a/packages/host/src/BaseInstancePlugin.ts b/packages/host/src/BaseInstancePlugin.ts index dd5f929d..2792f404 100644 --- a/packages/host/src/BaseInstancePlugin.ts +++ b/packages/host/src/BaseInstancePlugin.ts @@ -1,5 +1,5 @@ import type { - ConfigGroup, CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo, + CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo, } from "@clusterio/lib"; import type Instance from "./Instance"; import type Host from "./Host"; @@ -70,12 +70,11 @@ export default class BaseInstancePlugin { * 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 curr - The current value of the field. * @param prev - The previous value of the field. */ - async onInstanceConfigFieldChanged(group: ConfigGroup, field: string, prev: unknown) { } + async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { } /** * Called before collecting Prometheus metrics diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 19a0c48f..eaaab7bc 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -287,8 +287,8 @@ export default class Host extends lib.Link { this.configPath = hostConfigPath; this.config = hostConfig; - this.config.on("fieldChanged", (group, field, prev) => { - lib.invokeHook(this.plugins, "onHostConfigFieldChanged", group, field, prev); + this.config.on("fieldChanged", (name, curr, prev) => { + lib.invokeHook(this.plugins, "onHostConfigFieldChanged", name, curr, prev); }); this.connector.on("hello", data => { @@ -362,7 +362,7 @@ export default class Host extends lib.Link { async loadPlugins() { for (let pluginInfo of this.pluginInfos) { - if (!this.config.group(pluginInfo.name).get("load_plugin")) { + if (!this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)) { continue; } diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 22c43cf7..09acb34c 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -121,7 +121,7 @@ export default class Instance extends lib.Link { _host: Host; _dir: string; - _configFieldChanged: (group: lib.ConfigGroup, field: string, prev: unknown) => void; + _configFieldChanged: (field: string, curr: unknown, prev: unknown) => void; _status: "stopped" | "starting" | "running" | "stopping" | "creating_save" | "exporting_data" = "stopped"; _loadedSave: string | null = null; _playerCheckInterval: ReturnType | undefined; @@ -150,16 +150,16 @@ export default class Instance extends lib.Link { instance_name: this.name, }); - this._configFieldChanged = (group: lib.ConfigGroup, field: string, prev: unknown) => { - let hook = () => lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", group, field, prev); + this._configFieldChanged = (field: string, curr: unknown, prev: unknown) => { + let hook = () => lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", field, curr, prev); - if (group.name === "factorio" && field === "settings") { - this.updateFactorioSettings(group.get(field) as any, prev as any).finally(hook); - } else if (group.name === "factorio" && field === "enable_whitelist") { - this.updateFactorioWhitelist(group.get(field) as any).finally(hook); + if (field === "factorio.settings") { + this.updateFactorioSettings(curr as any, prev as any).finally(hook); + } else if (field === "factorio.enable_whitelist") { + this.updateFactorioWhitelist(curr as any).finally(hook); } else { - if (group.name === "factorio" && field === "max_concurrent_commands") { - this.server.maxConcurrentCommands = group.get(field) as number; + if (field === "factorio.max_concurrent_commands") { + this.server.maxConcurrentCommands = curr as number; } hook(); } @@ -546,7 +546,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); if ( !pluginInfo.instanceEntrypoint || !this._host.serverPlugins.has(pluginInfo.name) - || !this.config.group(pluginInfo.name).get("load_plugin") + || !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.InstanceConfigFields) ) { continue; } diff --git a/packages/lib/src/config/classes.ts b/packages/lib/src/config/classes.ts index b513cd89..698d7b44 100644 --- a/packages/lib/src/config/classes.ts +++ b/packages/lib/src/config/classes.ts @@ -1,13 +1,12 @@ // Configuration classes import { Type, Static } from "@sinclair/typebox"; -import events from "events"; +import TypedEventEmitter from "../TypedEventEmitter"; import isDeepStrictEqual from "../is_deep_strict_equal"; import { basicType } from "../helpers"; import * as libSchema from "../schema"; import { StringEnum } from "../data"; - const ConfigLocation = StringEnum(["controller", "host", "instance", "control"]); export type ConfigLocation = Static; @@ -41,62 +40,123 @@ export class InvalidValue extends Error { }; */ export class InvalidField extends Error { }; -/** - * Split the given string on the first instance of separator - * - * Splits `string` on the first instance of `separator` and returns an - * array consisting of the string up to the separator and the string - * after the separator. Returns an array with the string and an empty - * string if the separator is not present. - * - * @param separator - Separator to split string by. - * @param string - String to split - * @returns string split on separator. - * @internal - */ -function splitOn(separator: string, string: string) { - let index = string.indexOf(separator); - if (index === -1) { - return [string, ""]; - } - return [string.slice(0, index), string.slice(index + separator.length)]; -} - -const ConfigGroupSchema = Type.Object({ +const OldConfigGroupSchema = Type.Object({ "name": Type.String(), "fields": Type.Record(Type.String(), Type.Unknown()), }); -type SerializedGroup = Static; +type OldConfigGroupSchema = Static; -const ConfigSchema = Type.Object({ - "groups": Type.Array(ConfigGroupSchema), +const OldConfigSchema = Type.Object({ + "groups": Type.Array(OldConfigGroupSchema), }); -export type SerializedConfig = Static +export type OldConfigSchema = Static +export const ConfigSchema = Type.Record(Type.String(), Type.Union([ + Type.Null(), + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Object({}), +])); +export type ConfigSchema = Static; + +type FieldType = "boolean" | "string" | "number" | "object"; +type FieldValue = null | boolean | string | number | object; +export interface FieldDefinition { + /** + * string declaring the type of the config value, supports boolean, + * string, number and object. + */ + type: FieldType; + /** + * Text used to identify the config field in user interfaces. + */ + title?: string; + /** + * Text used to describe the config field in user interfaces. + */ + description?: string; + /** + * True if this field can be set to null. Defaults to false. + */ + optional?: boolean; + /** + * Value this config field should take in a newly initialized config. + * This can also be an async function returning the value to use. + */ + initialValue?: unknown | (() => unknown); + /** + * Array of all values that are acceptible. Useful for making + * enumerated values. + */ + enum?: unknown[]; + /** + * True if a restart of the entity this config is attached to is + * required for changes to take effect. Informative only, defaults to + * false. + */ + restartRequired?: boolean; + /** + * Properties to invert the value of restartRequired for if present. + */ + restartRequiredProps?: string[]; + /** + * Array of strings declaring where this config entry can be read and + * modified from. + */ + access?: ConfigLocation[]; +} + +export type ConfigDefs = { + [Prop in keyof Fields]: FieldDefinition; +} + +function defaultValue(def: FieldDefinition) { + if (def.initialValue === undefined) { + return null; + } + if (typeof def.initialValue === "function") { + return def.initialValue(); + } + return def.initialValue; +} + +export type ConfigEvents = { + fieldChanged: (name: string, curr: FieldValue, prev: FieldValue) => void; +} /** - * Collection of config groups + * Collection of config entries * - * Represents a collection of ConfigGroup instances that can be - * managed as one. + * Type checks and stores a collection of related config entries. Attempt + * to set invalid values for fields will result in an error thrown while + * loading a config with invalid values will result in those being replaced + * by the innitial value for those fields. * * This is an EventEmitter with the following events: * - fieldChanged - - * invoked after a field has been updated in one of the config groups - * belonging to this config. Takes three parameters: group, field, - * prev. + * invoked after a field has been updated. Takes three parameters: name, + * curr, prev. * * @extends events.EventEmitter */ -export class Config extends events.EventEmitter { - declare static _finalized: boolean; - declare static _initialized: boolean; - declare static _groups: Map; +export class Config< + Fields extends { [Field in keyof Fields]: FieldValue }, +> extends TypedEventEmitter< + keyof ConfigEvents, + ConfigEvents +> { + /** + * Mapping of config name to field meta data + * Note that mutating this object will lead to unexpected behaviour. + */ + declare static fieldDefinitions: ConfigDefs; declare ["constructor"]: typeof Config; - _groups = new Map(); - _unknownGroups = new Map>(); + + fields: Fields; + _unknownFields: Record = {}; /** Set to true when a field in the config is changed. */ dirty = false; @@ -106,38 +166,57 @@ export class Config extends events.EventEmitter { * * @param location - * Location to evaluate access for this instance from - * @param groups - - * Serialized representation of groups to load. + * @param fields - + * Serialized representation of fields to load. */ constructor( public location: ConfigLocation, - groups: Static["groups"] = [], + fields?: Static, + public defaultAccess: ConfigLocation[] = ["controller", "host", "control"], ) { if (typeof location !== "string") { throw new Error("location must be a string"); } super(); - if (!this.constructor._finalized) { - throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`); + this.fields = Object.fromEntries( + Object.entries( + this.constructor.fieldDefinitions + ).filter( + ([name, def]) => this._checkAccess(name, def, location, false) + ).map( + ([name, def]) => [name, defaultValue(def)] + ) + ) as Fields; + + if (fields) { + this.update(fields, false, location); } + } - for (let serializedGroup of groups) { - let GroupClass = this.constructor._groups.get(serializedGroup.name); - if (!GroupClass) { - this._unknownGroups.set(serializedGroup.name, serializedGroup); - continue; - } - - let group = GroupClass.fromJSON(serializedGroup, this); - this._groups.set(group.name, group); - } - - for (let [name, GroupClass] of this.constructor._groups) { - if (!this._groups.has(name)) { - this._groups.set(name, new GroupClass(this)); + /** + * Check access for a field + * + * @param name - Name of the field to check. + * @param def - definition for for the field to check. + * @param remote - location of remote access to check for. + * @param error - throw on failure to pass check. + * @throws {InvalidAccess} + * if access is not granted and error is true. + * @returns true if access is granted + * @private + */ + _checkAccess(name: string, def: FieldDefinition, remote: ConfigLocation, error: boolean) { + for (let loc of [remote, this.location]) { + if (!(def.access ?? this.defaultAccess).includes(loc)) { + if (error) { + throw new InvalidAccess(`Field '${name}' is not accessible from ${loc}`); + } else { + return false; + } } } + return true; } static jsonSchema = ConfigSchema; @@ -155,11 +234,21 @@ export class Config extends events.EventEmitter { json: Static, location: ConfigLocation, ) { + // migrate: Pre alpha 14 config format + if (typeof json === "object" && json !== null && json.groups instanceof Array) { + json = Object.fromEntries( + (json.groups as unknown as OldConfigGroupSchema[]).flatMap( + group => Object.entries(group.fields).map( + ([name, value]) => [`${group.name}.${name}`, value as FieldValue] + ), + ), + ); + } const valid = Config.validate(json); if (!valid) { throw new Error("Invalid config"); } - return new this(location, json.groups); + return new this(location, json); } toJSON() { @@ -170,292 +259,82 @@ export class Config extends events.EventEmitter { * Serialize the config to a plain JavaScript object * * @param location - Location used for access control. - * @returns JSON serializable representation of the config. + * @returns JSON serializable representation of the group. */ toRemote(location: ConfigLocation): Static { - let groups: Static[] = [...this._unknownGroups.values()]; - for (let group of this._groups.values()) { - groups.push(group.toRemote(location)); + let fields: Record = {}; + for (let [name, value] of Object.entries(this.fields)) { + let def = this.constructor.fieldDefinitions[name]; + if (!this._checkAccess(name, def, location, false)) { + continue; + } + + fields[name] = value as FieldValue; } - return { groups }; + for (let [name, value] of Object.entries(this._unknownFields)) { + fields[name] = value; + } + + return fields; } /** - * Update the configuration based on a serialized config + * Update a config from a serialized config * - * Updates all groups in the config with groups in the serialized - * config passed. + * Overwrites the values of all the fields in this config that has a + * valid value in the passed serialized config with that value. * - * @param serializedConfig - Output from .toRemote(). + * @param json - + * Result from a previous call to .toRemote(). * @param notify - Invoke fieldChanged events if true. * @param location - Location used for access control. */ update( - serializedConfig: Static, + json: Static, notify: boolean, location = this.location ) { - const valid = Config.validate(serializedConfig); + const valid = Config.validate(json); if (!valid) { throw new Error("Invalid config"); } - for (let serializedGroup of serializedConfig.groups) { - let group = this._groups.get(serializedGroup.name); - if (group) { - group.update(serializedGroup, notify, location); - } else { - this._unknownGroups.set(serializedGroup.name, serializedGroup); - } - } - } - /** - * Check if field can be accessed from the given location - * - * @param name - Name of the field to check. - * @param location - Location used for access control. - * @returns true if field is accessible - */ - canAccess(name: string, location = this.location) { - let [group, field] = splitOn(".", name); - return this.group(group).canAccess(field, location); - } - - /** - * Get the value for a config field - * - * @param name - Name of field to get. - * @param location - Location used for access control. - * @returns the value for the field. - */ - get(name: string, location = this.location) { - let [group, field] = splitOn(".", name); - return this.group(group).get(field, location); - } - - /** - * Set the value for a config field - * - * @param name - Name of field to set. - * @param value - the value to set on field - * @param location - Location used for access control. - */ - set(name: string, value: unknown, location = this.location) { - let [group, field] = splitOn(".", name); - this.group(group).set(field, value, location); - } - - /** - * Set property of an object value for a config field - * - * @param name - Name of field to set property on. - * @param prop - Name of property to set on field. - * @param value - the value to set the property to. - * @param location - Location used for access control. - */ - setProp(name: string, prop: string, value: unknown, location = this.location) { - let [group, field] = splitOn(".", name); - this.group(group).setProp(field, prop, value, location); - } - - /** - * Get the config group instance with the given name - * - * @param name - Name of group to get. - * @returns config group. - */ - group(name: string) { - let group = this._groups.get(name); - if (!group) { - throw new InvalidField(`No config group named '${name}'`); - } - return group; - } - - static _initSubclass() { - // These properties are initialized here in order to ensure that - // they end up on the sub-class. - if (!this._initialized) { - this._initialized = true; - this._finalized = false; - this._groups = new Map(); - } - } - - /** - * Mapping of group name to group class - * - * Returns the internal mapping of group names to group classes that - * mave been registered with this config. Note that mutating this - * object will lead to unexpected behaviour. - * - * @returns config groups - */ - static get groups() { - this._initSubclass(); - return this._groups; - } - - /** - * Locks this Config class from adding more groups. - */ - static finalize() { - this._initSubclass(); - this._finalized = true; - } - - /** - * Register a ConfigGroup with this Config - * - * Adds the ConfigGroup to this config as a group that will get - * initialized, loaded and serialized with this config class. - * - * @param group - class to register. - */ - static registerGroup(group: typeof ConfigGroup) { - this._initSubclass(); - - if (this._finalized) { - throw new Error("Cannot register group on finalized config"); + if (basicType(json) !== "object") { + throw new Error(`Expected object, not ${basicType(json)} for Config`); } - if (!group._finalized) { - throw new Error("Group must be finalized before it can be registered"); - } - - if (this._groups.has(group.groupName)) { - throw new Error(`${group.groupName} has already been registered`); - } - - this._groups.set(group.groupName, group); - } -} - -type FieldType = "boolean" | "string" | "number" | "object"; -export interface FieldDefinition { - type: FieldType; - name: string; - fullName: string; - title?: string; - description?: string; - optional: boolean; - initial_value?: unknown | (() => unknown); - enum?: unknown[]; - restartRequired: boolean; - restartRequiredProps?: string[]; - access: ConfigLocation[]; -} - -/** - * Collection of related config entries - * - * Type checks and stores a collection of related config entries. Attempt - * to set invalid values for fields will result in an error thrown while - * loading a config with invalid values will result in those being replaced - * by the innitial value for those fields. - */ -export class ConfigGroup { - declare static _finalized: boolean; - declare static _initialized: boolean; - declare static _definitions: Map; - declare static groupName: string; - declare static defaultAccess: ConfigLocation[]; - declare ["constructor"]: typeof ConfigGroup; - - _fields = new Map(); - _unknownFields = new Map(); - - - /** - * Creates a new config group. - * - * @param config - - * Parent config for this group instance. - * @param serializedGroup - - * Serialized representation of group to load. - */ - constructor( - public config: Config, - serializedGroup?: Static, - ) { - if (!this.constructor._finalized) { - throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`); - } - - if (serializedGroup) { - this.update(serializedGroup, false, config.location); - } - - for (let [name, def] of this.constructor._definitions) { - if (this._fields.has(name)) { - continue; - } - if (!def.access.includes(this.config.location)) { + for (let [name, value] of Object.entries(json) as [keyof Fields & string, FieldValue][]) { + let def = this.constructor.fieldDefinitions[name]; + if (!def) { + this._unknownFields[name as string] = value; continue; } - let value = null; - if (typeof def.initial_value === "function") { - value = def.initial_value(); - - } else if (def.initial_value !== undefined) { - value = def.initial_value; + if (!this._checkAccess(name, def, location, false)) { + continue; } - this._fields.set(name, value); - } - } - - /** - * Check access for a field - * - * @param def - definition for for the field to check. - * @param remote - location of remote access to check for. - * @param error - throw on failure to pass check. - * @throws {InvalidAccess} - * if access is not granted and error is true. - * @returns true if access is granted - * @private - */ - _checkAccess(def: FieldDefinition, remote: ConfigLocation, error: boolean) { - for (let loc of [remote, this.config.location]) { - if (!def.access.includes(loc)) { - if (error) { - throw new InvalidAccess(`Field '${def.name}' is not accessible from ${loc}`); - } else { - return false; + if (def.optional) { + if (value !== null && basicType(value) !== def.type) { + continue; } + + } else if (basicType(value) !== def.type) { + continue; + } + + if (def.enum && !def.enum.includes(value)) { + continue; + } + + let prev = this.fields[name]; + this.fields[name] = value as any; + if (notify && !isDeepStrictEqual(value, prev)) { + this.dirty = true; + this.emit("fieldChanged", name, value, prev); } } - return true; - } - - static jsonSchema = ConfigGroupSchema; - - /** - * Load from serialized group - * - * Loads all the values from the passed serialized version of the - * group and then initializes any possible missing fields from it to - * their default values. - * - * @param json - - * Result from a previous call to .toJSON(). - * @param config - Parent config for this group instance. - * @returns Instance of this group. - */ - static fromJSON(json: Static, config: Config) { - return new this(config, json); - } - - /** - * Name of the group - * - * Shortcut for `group.constructor.groupName` - */ - get name() { - return this.constructor.groupName; } /** @@ -465,12 +344,12 @@ export class ConfigGroup { * @param location - Location used for access control. * @returns true if field is accessible */ - canAccess(name: string, location = this.config.location) { - let def = this.constructor._definitions.get(name); + canAccess(name: string, location = this.location) { + let def = this.constructor.fieldDefinitions[name]; if (!def) { throw new InvalidField(`No field named '${name}'`); } - return this._checkAccess(def, location, false); + return this._checkAccess(name, def, location, false); } /** @@ -480,14 +359,14 @@ export class ConfigGroup { * @param location - Location used for access control. * @returns Value stored for the field. */ - get(name: string, location = this.config.location) { - let def = this.constructor._definitions.get(name); + get(name: Field, location = this.location) { + let def = this.constructor.fieldDefinitions[name]; if (!def) { throw new InvalidField(`No field named '${name}'`); } - this._checkAccess(def, location, true); + this._checkAccess(name, def, location, true); - return this._fields.get(name); + return this.fields[name]; } /** @@ -497,17 +376,18 @@ export class ConfigGroup { * type checked against the field definition. * * @param name - Name of field to set. - * @param value - Value to set for field. + * @param newValue - Value to set for field. * @param location - Location used for access control. * @throws {InvalidField} if field is not defined. * @throws {InvalidValue} if value is not allowed for the field. */ - set(name: string, value: unknown, location = this.config.location) { - let def = this.constructor._definitions.get(name); + set(name: Field, newValue: Fields[Field], location = this.location) { + let value: FieldValue = newValue; + let def = this.constructor.fieldDefinitions[name]; if (!def) { throw new InvalidField(`No field named '${name}'`); } - this._checkAccess(def, location, true); + this._checkAccess(name, def, location, true); // Empty strings are treates as null if (value === "") { @@ -550,11 +430,11 @@ export class ConfigGroup { throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`); } - let prev = this._fields.get(name); - this._fields.set(name, value); - if (this.config && !isDeepStrictEqual(value, prev)) { - this.config.dirty = true; - this.config.emit("fieldChanged", this, name, prev); + let prev = this.fields[name]; + this.fields[name] = value as Fields[Field]; + if (!isDeepStrictEqual(value, prev)) { + this.dirty = true; + this.emit("fieldChanged", name, value, prev); } } @@ -571,18 +451,23 @@ export class ConfigGroup { * @throws {InvalidField} if field is not defined. * @throws {InvalidValue} if field is not an object. */ - setProp(name: string, prop: string, value: unknown, location = this.config.location) { - let def = this.constructor._definitions.get(name); + setProp( + name: Field, + prop: string, + value: unknown, + location = this.location, + ) { + let def = this.constructor.fieldDefinitions[name]; if (!def) { throw new InvalidField(`No field named '${name}'`); } - this._checkAccess(def, location, true); + this._checkAccess(name, def, location, true); if (def.type !== "object") { throw new InvalidValue(`Cannot set property on non-object field '${name}'`); } - let prev = this._fields.get(name) as Record; + let prev = this.fields[name] as Record; let updated = {...prev || {}}; if (value !== undefined) { @@ -590,323 +475,10 @@ export class ConfigGroup { } else { delete updated[prop]; } - this._fields.set(name, updated); - if (this.config && !isDeepStrictEqual(updated, prev)) { - this.config.dirty = true; - this.config.emit("fieldChanged", this, name, prev); - } - } - - /** - * Update a config group from a serialized group - * - * Overwrites the values of all the fields in this config group that - * has a valid value in the passed serialized group with that value. - * - * @param serializedGroup - - * Result from a previous call to .toRemote(). - * @param notify - Invoke fieldChanged events if true. - * @param location - Location used for access control. - */ - update( - serializedGroup: Static, - notify: boolean, - location = this.config.location - ) { - if (basicType(serializedGroup) !== "object") { - throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`); - } - - if (serializedGroup.name !== this.name) { - throw new Error(`Expected group name ${this.name}, not ${serializedGroup.name}`); - } - - if (basicType(serializedGroup.fields) !== "object") { - throw new Error(`Expected fields to be an object, not ${basicType(serializedGroup.fields)}`); - } - - for (let [name, value] of Object.entries(serializedGroup.fields)) { - let def = this.constructor._definitions.get(name); - if (!def) { - this._unknownFields.set(name, value); - continue; - } - - if (!this._checkAccess(def, location, false)) { - continue; - } - - if (def.optional) { - if (value !== null && basicType(value) !== def.type) { - continue; - } - - } else if (basicType(value) !== def.type) { - continue; - } - - if (def.enum && !def.enum.includes(value)) { - continue; - } - - let prev = this._fields.get(name); - this._fields.set(name, value); - if (notify && this.config && !isDeepStrictEqual(value, prev)) { - this.config.dirty = true; - this.config.emit("fieldChanged", this, name, prev); - } - } - } - - toJSON() { - return this.toRemote(this.config.location); - } - - /** - * Serialize the config to a plain JavaScript object - * - * @param location - Location used for access control. - * @returns JSON serializable representation of the group. - */ - toRemote(location: ConfigLocation): Static { - let fields: Record = {}; - for (let [name, value] of this._fields) { - let def = this.constructor._definitions.get(name)!; - if (!this._checkAccess(def, location, false)) { - continue; - } - - fields[name] = value; - } - - for (let [name, value] of this._unknownFields) { - fields[name] = value; - } - - return { - name: this.name, - fields, - }; - } - - static _initSubclass() { - // These properties are initialized here in order to ensure that - // they end up on the sub-class. - if (!this._initialized) { - this._initialized = true; - this._definitions = new Map(); - } - } - - /** - * Mapping of config name to field meta data - * - * Returns the internal meta data for the fields of this class. - * Note that mutating this object will lead to unexpected behaviour. - */ - static get definitions() { - this._initSubclass(); - return this._definitions; - } - - /** - * Define a new configuration field - * - * @param item - Field definition. - * @param item.access - - * Array of strings declaring where this config entry can be read - * and modified from. - * @param item.type - - * string declaring the type of the config value, supports - * boolean, string, number and object. - * @param item.name - - * Name of the configuration field. Should follow the - * lower_case_underscore style. - * @param item.title - - * Text used to identify the config field in user interfaces. - * @param item.description - - * Text used to describe the config field in user interfaces. - * @param item.enum - - * Array of all values that are acceptible. Useful for making - * enumerated values. - * @param item.restartRequired - - * True if a restart of the entity this config is attached to is - * required for changes to take effect. Informative only, defaults - * to false. - * @param item.restartRequiredProps - - * Properties to invert the value of restartRequired for if present. - * @param item.optional - - * True if this field can be set to null. Defaults to false. - * @param item.initial_value - - * Value this config field should take in a newly initialized - * config. This can also be an async function returning the - * value to use. - */ - // eslint-disable-next-line complexity - static define(item: { - access?: ConfigLocation[], - type: FieldType, - name: string, - title?: string, - description?: string, - enum?: unknown[], - restartRequired?: boolean, - restartRequiredProps?: string[], - optional?: boolean, - initial_value?: unknown | (() => unknown), - }) { - if (this._finalized) { - throw new Error(`Cannot define field for ConfigGroup class ${this.name} after it has been finalized`); - } - - if (basicType(this.groupName) !== "string") { - throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`); - } - - this._initSubclass(); - - let itemKeys = Object.keys(item); - - // Check for required properties - ["name", "type"].forEach(prop => { - if (!itemKeys.includes(prop)) { - throw new Error(`${prop} is required when defining an field`); - } - }); - - // Validate properties - { - const { - type, access, enum: enum_, name, title, description, restartRequired, - restartRequiredProps, optional, initial_value, ...rest - } = item; - - let validTypes = ["boolean", "string", "number", "object"]; - if (type !== undefined) { - if (!validTypes.includes(type)) { - throw new Error(`${type} is not a valid type`); - } - } - - if (access !== undefined) { - if (basicType(access) !== "array") { - throw new Error("access must be an array"); - } - } - - if (enum_ !== undefined) { - if (basicType(enum_) !== "array") { - throw new Error("enum must be an array"); - } - } - - if (name !== undefined) { - if (typeof name !== "string") { - throw new Error("name must be a string"); - } - } - - if (title !== undefined) { - if (typeof title !== "string") { - throw new Error("title must be a string"); - } - } - - if (description !== undefined) { - if (typeof description !== "string") { - throw new Error("description must be a string"); - } - } - - if (restartRequired !== undefined) { - if (typeof restartRequired !== "boolean") { - throw new Error("restartRequired must be a boolean"); - } - } - - if (restartRequiredProps !== undefined) { - if (basicType(restartRequiredProps) !== "array") { - throw new Error("restartRequiredProps must be a array"); - } - } - - if (optional !== undefined) { - if (typeof optional !== "boolean") { - throw new Error("optional must be a boolean"); - } - } - - if (initial_value !== undefined) { - if (basicType(initial_value) !== item.type && basicType(initial_value) !== "function") { - throw new Error("initial_value must match the type or be a function"); - } - } - - for (let key of Object.keys(rest)) { - throw new Error(`Unknown property ${key}`); - } - } - - if (this._definitions.has(item.name)) { - throw new Error(`Config field ${item.name} has already been defined`); - } - - let computed: FieldDefinition = { - access: this.defaultAccess, - optional: false, - fullName: `${this.groupName}.${item.name}`, - restartRequired: false, - ...item, - }; - - if (item.restartRequiredProps && item.type !== "object") { - throw new Error("restartRequiredProps is only allowed if type is object"); - } - - if (!computed.access) { - throw new Error(`access not set and no defaultAccess defined for ${this.name}`); - } - - if (!computed.optional && computed.initial_value === undefined) { - throw new Error(`Non-optional field ${item.name} needs an initial_value`); - } - - this._definitions.set(item.name, computed); - } - - /** - * Locks this ConfigGroup class from adding more definitons. - */ - static finalize() { - if (basicType(this.groupName) !== "string") { - throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`); - } - - this._initSubclass(); - this._finalized = true; - } -} - -/** - * Config group for plugins - * - * Grouping that includes per plugin fields clusterio uses to manage - * plugins. This is currently only the enabled field. It is otherwise - * identical to the ConfigGroup class. - * - * @extends ConfigGroup - */ -export class PluginConfigGroup extends ConfigGroup { - static _initSubclass() { - if (!this._initialized) { - super._initSubclass(); - this.define({ - name: "load_plugin", - title: "Load Plugin", - restartRequired: true, - type: "boolean", - initial_value: true, - }); + this.fields[name] = updated as Fields[Field]; + if (!isDeepStrictEqual(updated, prev)) { + this.dirty = true; + this.emit("fieldChanged", name, updated, prev); } } } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index ad388a58..eb5dbb51 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -3,569 +3,469 @@ import * as classes from "./classes"; import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin"; -/** - * Controller config group for {@link ControllerConfig} - * @extends classes.ConfigGroup - */ -export class ControllerGroup extends classes.ConfigGroup { } -ControllerGroup.defaultAccess = ["controller", "host", "control"]; -ControllerGroup.groupName = "controller"; -ControllerGroup.define({ - name: "name", - title: "Name", - description: "Name of the cluster.", - type: "string", - initial_value: "Your Cluster", -}); -ControllerGroup.define({ - name: "mods_directory", - title: "Mods Directory", - description: "Path to directory where mods shared with the cluster are stored.", - restartRequired: true, - type: "string", - initial_value: "mods", -}); -ControllerGroup.define({ - name: "database_directory", - title: "Database directory", - description: "Directory where item and configuration data is stored.", - type: "string", - initial_value: "database", -}); -ControllerGroup.define({ - name: "autosave_interval", - title: "Autosave Interval", - description: "Interval in seconds to autosave data in memory to disk.", - type: "number", - initial_value: 60, -}); -ControllerGroup.define({ - name: "http_port", - title: "HTTP Port", - description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.", - restartRequired: true, - type: "number", - optional: true, - initial_value: 8080, -}); -ControllerGroup.define({ - name: "https_port", - title: "HTTPS Port", - description: "Port to listen for HTTPS connection on, set to null to not listen for HTTPS connections.", - restartRequired: true, - type: "number", - optional: true, -}); -ControllerGroup.define({ - name: "bind_address", - title: "Bind Address", - description: "IP address to bind the HTTP and HTTPS ports on.", - restartRequired: true, - type: "string", - optional: true, -}); -ControllerGroup.define({ - name: "external_address", - title: "External Address", - description: "Public facing address the controller is hosted on.", - type: "string", - optional: true, -}); -ControllerGroup.define({ - name: "tls_certificate", - title: "TLS Certificate", - description: "Path to the certificate to use for HTTPS.", - restartRequired: true, - type: "string", - optional: true, -}); -ControllerGroup.define({ - name: "tls_private_key", - title: "TLS Private Key", - description: "Path to the private key to use for HTTPS.", - restartRequired: true, - type: "string", - optional: true, -}); -ControllerGroup.define({ - access: ["controller"], - name: "auth_secret", - title: "Controller Authentication Secret", - description: - "Secret used to generate and verify authentication tokens. " + - "Should be a long string of random letters and numbers. " + - "Do not share this.", - restartRequired: true, - type: "string", - optional: true, -}); -ControllerGroup.define({ - name: "heartbeat_interval", - title: "Heartbeat Interval", - description: "Interval heartbeats are sent out on WebSocket connections.", - type: "number", - initial_value: 15, -}); -ControllerGroup.define({ - name: "session_timeout", - title: "Session Timeout", - description: "Time in seconds before giving up resuming a dropped WebSocket session.", - type: "number", - initial_value: 60, -}); -ControllerGroup.define({ - name: "metrics_timeout", - title: "Metrics Timeout", - description: "Timeout in seconds for metrics gathering from hosts.", - type: "number", - initial_value: 8, -}); -ControllerGroup.define({ - name: "proxy_stream_timeout", - title: "Proxy Stream Timeout", - description: "Timeout in seconds for proxy streams to start flowing.", - type: "number", - initial_value: 15, -}); -ControllerGroup.define({ - name: "default_mod_pack_id", - title: "Default Mod Pack", - description: "Mod pack used by default for instances.", - type: "number", - optional: true, -}); -ControllerGroup.define({ - name: "default_role_id", - title: "Default role", - description: "ID of role assigned by default to new users.", - type: "number", - optional: true, - initial_value: 1, -}); -ControllerGroup.finalize(); +export interface ControllerConfigFields { + "controller.name": string; + "controller.mods_directory": string; + "controller.database_directory": string; + "controller.http_port": number | null; + "controller.https_port": number | null; + "controller.bind_address": string | null; + "controller.external_address": string | null; + "controller.tls_certificate": string | null; + "controller.tls_private_key": string | null; + "controller.auth_secret": string; + "controller.heartbeat_interval": number; + "controller.session_timeout": number; + "controller.metrics_timeout": number; + "controller.proxy_stream_timeout": number; + "controller.default_mod_pack_id": number | null; + "controller.default_role_id": number | null; + "controller.autosave_interval": number; +} /** * Controller Config class * @extends classes.Config */ -export class ControllerConfig extends classes.Config { - declare static fromJSON: (json: classes.SerializedConfig, location: classes.ConfigLocation) => ControllerConfig; - get(name: "controller.name"): string; - get(name: "controller.mods_directory"): string; - get(name: "controller.database_directory"): string; - get(name: "controller.http_port"): number | null; - get(name: "controller.https_port"): number | null; - get(name: "controller.bind_address"): string | null; - get(name: "controller.external_address"): string | null; - get(name: "controller.tls_certificate"): string | null; - get(name: "controller.tls_private_key"): string | null; - get(name: "controller.auth_secret"): string; - get(name: "controller.heartbeat_interval"): number; - get(name: "controller.session_timeout"): number; - get(name: "controller.metrics_timeout"): number; - get(name: "controller.proxy_stream_timeout"): number; - get(name: "controller.default_mod_pack_id"): number | null; - get(name: "controller.default_role_id"): number | null; - get(name: "controller.autosave_interval"): number; - get(name: string): unknown; - get(name: string) { - return super.get(name); - } +export class ControllerConfig extends classes.Config { + declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => ControllerConfig; + static fieldDefinitions: classes.ConfigDefs = { + "controller.name": { + title: "Name", + description: "Name of the cluster.", + type: "string", + initialValue: "Your Cluster", + }, + "controller.mods_directory": { + title: "Mods Directory", + description: "Path to directory where mods shared with the cluster are stored.", + restartRequired: true, + type: "string", + initialValue: "mods", + }, + "controller.database_directory": { + title: "Database directory", + description: "Directory where item and configuration data is stored.", + type: "string", + initialValue: "database", + }, + "controller.autosave_interval": { + title: "Autosave Interval", + description: "Interval in seconds to autosave data in memory to disk.", + type: "number", + initialValue: 60, + }, + "controller.http_port": { + title: "HTTP Port", + description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.", + restartRequired: true, + type: "number", + optional: true, + initialValue: 8080, + }, + "controller.https_port": { + title: "HTTPS Port", + description: "Port to listen for HTTPS connection on, set to null to not listen for HTTPS connections.", + restartRequired: true, + type: "number", + optional: true, + }, + "controller.bind_address": { + title: "Bind Address", + description: "IP address to bind the HTTP and HTTPS ports on.", + restartRequired: true, + type: "string", + optional: true, + }, + "controller.external_address": { + title: "External Address", + description: "Public facing address the controller is hosted on.", + type: "string", + optional: true, + }, + "controller.tls_certificate": { + title: "TLS Certificate", + description: "Path to the certificate to use for HTTPS.", + restartRequired: true, + type: "string", + optional: true, + }, + "controller.tls_private_key": { + title: "TLS Private Key", + description: "Path to the private key to use for HTTPS.", + restartRequired: true, + type: "string", + optional: true, + }, + "controller.auth_secret": { + access: ["controller"], + title: "Controller Authentication Secret", + description: + "Secret used to generate and verify authentication tokens. " + + "Should be a long string of random letters and numbers. " + + "Do not share this.", + restartRequired: true, + type: "string", + optional: true, + }, + "controller.heartbeat_interval": { + title: "Heartbeat Interval", + description: "Interval heartbeats are sent out on WebSocket connections.", + type: "number", + initialValue: 15, + }, + "controller.session_timeout": { + title: "Session Timeout", + description: "Time in seconds before giving up resuming a dropped WebSocket session.", + type: "number", + initialValue: 60, + }, + "controller.metrics_timeout": { + title: "Metrics Timeout", + description: "Timeout in seconds for metrics gathering from hosts.", + type: "number", + initialValue: 8, + }, + "controller.proxy_stream_timeout": { + title: "Proxy Stream Timeout", + description: "Timeout in seconds for proxy streams to start flowing.", + type: "number", + initialValue: 15, + }, + "controller.default_mod_pack_id": { + title: "Default Mod Pack", + description: "Mod pack used by default for instances.", + type: "number", + optional: true, + }, + "controller.default_role_id": { + title: "Default role", + description: "ID of role assigned by default to new users.", + type: "number", + optional: true, + initialValue: 1, + }, + }; } -ControllerConfig.registerGroup(ControllerGroup); - -/** - * Host config group for {@link HostConfig} - * @extends classes.ConfigGroup - */ -export class HostGroup extends classes.ConfigGroup {} -HostGroup.defaultAccess = ["controller", "host", "control"]; -HostGroup.groupName = "host"; -HostGroup.define({ - name: "name", - description: "Name of the host", - type: "string", - initial_value: "New Host", -}); -HostGroup.define({ - name: "id", - description: "ID of the host", - type: "number", - initial_value: () => Math.random() * 2**31 | 0, -}); -HostGroup.define({ - name: "factorio_directory", - description: "Path to directory to look for factorio installs", - type: "string", - initial_value: "factorio", -}); -HostGroup.define({ - name: "mods_directory", - title: "Mods Directory", - description: "Path to directory where mods for instances are cached.", - restartRequired: true, - type: "string", - initial_value: "mods", -}); -HostGroup.define({ - name: "instances_directory", - description: "Path to directory to store instances in.", - restartRequired: true, - type: "string", - initial_value: "instances", -}); -HostGroup.define({ - name: "controller_url", - description: "URL to connect to the controller at", - restartRequired: true, - type: "string", - initial_value: "http://localhost:8080/", -}); -HostGroup.define({ - name: "controller_token", - description: "Token to authenticate to controller with.", - restartRequired: true, - type: "string", - initial_value: "enter token here", -}); -HostGroup.define({ - name: "tls_ca", - description: "Path to Certificate Authority to validate TLS connection to controller against.", - restartRequired: true, - type: "string", - optional: true, -}); -HostGroup.define({ - name: "public_address", - description: "Public facing address players should connect to in order to join instances on this host", - type: "string", - initial_value: "localhost", -}); -HostGroup.define({ - name: "max_reconnect_delay", - title: "Max Reconnect Delay", - description: "Maximum delay to wait before attempting to reconnect WebSocket", - type: "number", - initial_value: 60, -}); -HostGroup.finalize(); +export interface HostConfigFields { + "host.name": string; + "host.id": number; + "host.factorio_directory": string; + "host.mods_directory": string; + "host.instances_directory": string; + "host.controller_url": string; + "host.controller_token": string; + "host.tls_ca": string | null; + "host.public_address": string; + "host.max_reconnect_delay": number; +} /** * Host Config class * @extends classes.Config */ -export class HostConfig extends classes.Config { - declare static fromJSON: (json: classes.SerializedConfig, location: classes.ConfigLocation) => HostConfig; - get(name: "host.name"): string; - get(name: "host.id"): number; - get(name: "host.factorio_directory"): string; - get(name: "host.mods_directory"): string; - get(name: "host.instances_directory"): string; - get(name: "host.controller_url"): string; - get(name: "host.controller_token"): string; - get(name: "host.tls_ca"): string | null; - get(name: "host.public_address"): string; - get(name: "host.max_reconnect_delay"): number; - get(name: string): unknown; - get(name: string) { - return super.get(name); - } +export class HostConfig extends classes.Config { + declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => HostConfig; + static fieldDefinitions: classes.ConfigDefs = { + "host.name": { + description: "Name of the host", + type: "string", + initialValue: "New Host", + }, + "host.id": { + description: "ID of the host", + type: "number", + initialValue: () => Math.random() * 2**31 | 0, + }, + "host.factorio_directory": { + description: "Path to directory to look for factorio installs", + type: "string", + initialValue: "factorio", + }, + "host.mods_directory": { + title: "Mods Directory", + description: "Path to directory where mods for instances are cached.", + restartRequired: true, + type: "string", + initialValue: "mods", + }, + "host.instances_directory": { + description: "Path to directory to store instances in.", + restartRequired: true, + type: "string", + initialValue: "instances", + }, + "host.controller_url": { + description: "URL to connect to the controller at", + restartRequired: true, + type: "string", + initialValue: "http://localhost:8080/", + }, + "host.controller_token": { + access: ["host"], + description: "Token to authenticate to controller with.", + restartRequired: true, + type: "string", + initialValue: "enter token here", + }, + "host.tls_ca": { + description: "Path to Certificate Authority to validate TLS connection to controller against.", + restartRequired: true, + type: "string", + optional: true, + }, + "host.public_address": { + description: "Public facing address players should connect to in order to join instances on this host", + type: "string", + initialValue: "localhost", + }, + "host.max_reconnect_delay": { + title: "Max Reconnect Delay", + description: "Maximum delay to wait before attempting to reconnect WebSocket", + type: "number", + initialValue: 60, + }, + }; } -HostConfig.registerGroup(HostGroup); +export interface InstanceConfigFields { + "instance.name": string; + "instance.id": number; + "instance.assigned_host": number | null; + "instance.auto_start": boolean; -/** - * Instance config group for {@link classes.InstanceConfig} - * @extends classes.ConfigGroup - */ -export class InstanceGroup extends classes.ConfigGroup { } -InstanceGroup.defaultAccess = ["controller", "host", "control"]; -InstanceGroup.groupName = "instance"; -InstanceGroup.define({ - name: "name", - type: "string", - initial_value: "New Instance", -}); -InstanceGroup.define({ - name: "id", - description: "ID of the instance", - type: "number", - initial_value: () => Math.random() * 2**31 | 0, -}); -InstanceGroup.define({ - name: "assigned_host", - type: "number", - optional: true, -}); -InstanceGroup.define({ - name: "auto_start", - description: "Automatically start this instance when the host hosting it is started up", - type: "boolean", - initial_value: false, -}); -InstanceGroup.finalize(); + "factorio.version": string; + "factorio.game_port": number | null; + "factorio.rcon_port": number | null; + "factorio.rcon_password": string | null; + "factorio.player_online_autosave_slots": number; + "factorio.mod_pack": number | null; + "factorio.enable_save_patching": boolean; + "factorio.enable_whitelist": boolean; + "factorio.enable_authserver_bans": boolean; + "factorio.settings": Record; + "factorio.verbose_logging": boolean; + "factorio.strip_paths": boolean; + "factorio.sync_adminlist": boolean; + "factorio.sync_whitelist": boolean; + "factorio.sync_banlist": boolean; + "factorio.max_concurrent_commands": number; +} -/** - * Factorio config group for {@link classes.InstanceConfig} - * @extends classes.ConfigGroup - */ -export class FactorioGroup extends classes.ConfigGroup { } -FactorioGroup.defaultAccess = ["controller", "host", "control"]; -FactorioGroup.groupName = "factorio"; -FactorioGroup.define({ - name: "version", - description: "Version of the game to run, use latest to run the latest installed version", - restartRequired: true, - type: "string", - initial_value: "latest", -}); -FactorioGroup.define({ - name: "game_port", - description: "UDP port to run game on, uses a random port if null", - restartRequired: true, - type: "number", - optional: true, -}); -FactorioGroup.define({ - name: "rcon_port", - description: "TCP port to run RCON on, uses a random port if null", - restartRequired: true, - type: "number", - optional: true, -}); -FactorioGroup.define({ - name: "rcon_password", - description: "Password for RCON, randomly generated if null", - restartRequired: true, - type: "string", - optional: true, -}); -FactorioGroup.define({ - name: "player_online_autosave_slots", - description: - "Rename autosaves where players have been online since the previous autosave into a separate autosave " + - "pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.", - type: "number", - initial_value: 5, -}); -FactorioGroup.define({ - name: "mod_pack", - description: "Mod pack to use on this server", - restartRequired: true, - type: "number", - optional: true, -}); -FactorioGroup.define({ - name: "enable_save_patching", - description: "Patch saves with Lua code. Required for Clusterio integrations, lua modules, and most plugins.", - restartRequired: true, - type: "boolean", - initial_value: true, -}); -FactorioGroup.define({ - name: "enable_whitelist", - description: "Turn on whitelist for joining the server.", - type: "boolean", - initial_value: false, -}); -FactorioGroup.define({ - name: "enable_authserver_bans", - description: "Turn on Factorio.com based multiplayer bans.", - restartRequired: true, - type: "boolean", - initial_value: false, -}); -FactorioGroup.define({ - name: "settings", - description: "Settings overridden in server-settings.json", - restartRequired: true, - restartRequiredProps: [ - "afk_autokick_interval", "allow_commands", "autosave_interval", "autosave_only_on_server", "description", - "ignore_player_limit_for_returning_players", "max_players", "max_upload_slots", - "max_upload_in_kilobytes_per_second", "name", "only_admins_can_pause_the_game", "game_password", - "require_user_verification", "tags", "visibility", - ], - type: "object", - initial_value: {}, // See create instance handler in controller. -}); -FactorioGroup.define({ - name: "verbose_logging", - description: "Enable verbose logging on the Factorio server", - restartRequired: true, - type: "boolean", - initial_value: false, -}); -FactorioGroup.define({ - name: "strip_paths", - description: "Strip down instance paths in the log", - restartRequired: true, - type: "boolean", - initial_value: true, -}); -FactorioGroup.define({ - name: "sync_adminlist", - description: "Synchronize adminlist with controller", - type: "boolean", - initial_value: true, -}); -FactorioGroup.define({ - name: "sync_whitelist", - description: "Synchronize whitelist with controller", - type: "boolean", - initial_value: true, -}); -FactorioGroup.define({ - name: "sync_banlist", - description: "Synchronize banlist with controller", - type: "boolean", - initial_value: true, -}); -FactorioGroup.define({ - name: "max_concurrent_commands", - description: "Maximum number of RCON commands trasmitted in parallel", - restartRequired: true, - type: "number", - initial_value: 5, -}); -FactorioGroup.finalize(); /** * Instance config class * @extends classes.Config */ -export class InstanceConfig extends classes.Config { - declare static fromJSON: (json: classes.SerializedConfig, location: classes.ConfigLocation) => InstanceConfig; - - get(name: "instance.name"): string; - get(name: "instance.id"): number; - get(name: "instance.assigned_host"): number | null; - get(name: "instance.auto_start"): boolean; - - get(name: "factorio.version"): string; - get(name: "factorio.game_port"): number | null; - get(name: "factorio.rcon_port"): number | null; - get(name: "factorio.rcon_password"): string | null; - get(name: "factorio.player_online_autosave_slots"): number; - get(name: "factorio.mod_pack"): number | null; - get(name: "factorio.enable_save_patching"): boolean; - get(name: "factorio.enable_whitelist"): boolean; - get(name: "factorio.enable_authserver_bans"): boolean; - get(name: "factorio.settings"): Record; - get(name: "factorio.verbose_logging"): boolean; - get(name: "factorio.strip_paths"): boolean; - get(name: "factorio.sync_adminlist"): boolean; - get(name: "factorio.sync_whitelist"): boolean; - get(name: "factorio.sync_banlist"): boolean; - get(name: "factorio.max_concurrent_commands"): number; - get(name: string): unknown; - get(name: string) { - return super.get(name); - } +export class InstanceConfig extends classes.Config { + declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => InstanceConfig; + static fieldDefinitions: classes.ConfigDefs = { + "instance.name": { + type: "string", + initialValue: "New Instance", + }, + "instance.id": { + description: "ID of the instance", + type: "number", + initialValue: () => Math.random() * 2**31 | 0, + }, + "instance.assigned_host": { + type: "number", + optional: true, + }, + "instance.auto_start": { + description: "Automatically start this instance when the host hosting it is started up", + type: "boolean", + initialValue: false, + }, + "factorio.version": { + description: "Version of the game to run, use latest to run the latest installed version", + restartRequired: true, + type: "string", + initialValue: "latest", + }, + "factorio.game_port": { + description: "UDP port to run game on, uses a random port if null", + restartRequired: true, + type: "number", + optional: true, + }, + "factorio.rcon_port": { + description: "TCP port to run RCON on, uses a random port if null", + restartRequired: true, + type: "number", + optional: true, + }, + "factorio.rcon_password": { + description: "Password for RCON, randomly generated if null", + restartRequired: true, + type: "string", + optional: true, + }, + "factorio.player_online_autosave_slots": { + description: + "Rename autosaves where players have been online since the previous autosave into a separate " + + "autosave pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.", + type: "number", + initialValue: 5, + }, + "factorio.mod_pack": { + description: "Mod pack to use on this server", + restartRequired: true, + type: "number", + optional: true, + }, + "factorio.enable_save_patching": { + description: + "Patch saves with Lua code. Required for Clusterio integrations, lua modules, and most plugins.", + restartRequired: true, + type: "boolean", + initialValue: true, + }, + "factorio.enable_whitelist": { + description: "Turn on whitelist for joining the server.", + type: "boolean", + initialValue: false, + }, + "factorio.enable_authserver_bans": { + description: "Turn on Factorio.com based multiplayer bans.", + restartRequired: true, + type: "boolean", + initialValue: false, + }, + "factorio.settings": { + description: "Settings overridden in server-settings.json", + restartRequired: true, + restartRequiredProps: [ + "afk_autokick_interval", "allow_commands", "autosave_interval", "autosave_only_on_server", + "description", "ignore_player_limit_for_returning_players", "max_players", "max_upload_slots", + "max_upload_in_kilobytes_per_second", "name", "only_admins_can_pause_the_game", "game_password", + "require_user_verification", "tags", "visibility", + ], + type: "object", + initialValue: {}, // See create instance handler in controller. + }, + "factorio.verbose_logging": { + description: "Enable verbose logging on the Factorio server", + restartRequired: true, + type: "boolean", + initialValue: false, + }, + "factorio.strip_paths": { + description: "Strip down instance paths in the log", + restartRequired: true, + type: "boolean", + initialValue: true, + }, + "factorio.sync_adminlist": { + description: "Synchronize adminlist with controller", + type: "boolean", + initialValue: true, + }, + "factorio.sync_whitelist": { + description: "Synchronize whitelist with controller", + type: "boolean", + initialValue: true, + }, + "factorio.sync_banlist": { + description: "Synchronize banlist with controller", + type: "boolean", + initialValue: true, + }, + "factorio.max_concurrent_commands": { + description: "Maximum number of RCON commands trasmitted in parallel", + restartRequired: true, + type: "number", + initialValue: 5, + }, + }; } -InstanceConfig.registerGroup(InstanceGroup); -InstanceConfig.registerGroup(FactorioGroup); - -/** - * Control config group for {@link classes.ControlConfig} - * @extends classes.ConfigGroup - */ -export class ControlGroup extends classes.ConfigGroup {} -ControlGroup.defaultAccess = ["control"]; -ControlGroup.groupName = "control"; -ControlGroup.define({ - name: "controller_url", - description: "URL to connect to the controller at", - type: "string", - optional: true, -}); -ControlGroup.define({ - name: "controller_token", - description: "Token to authenticate to controller with.", - type: "string", - optional: true, -}); -ControlGroup.define({ - name: "tls_ca", - description: "Path to Certificate Authority to validate TLS connection to controller against.", - type: "string", - optional: true, -}); -ControlGroup.define({ - name: "max_reconnect_delay", - title: "Max Reconnect Delay", - description: "Maximum delay to wait before attempting to reconnect WebSocket", - type: "number", - initial_value: 60, -}); -ControlGroup.finalize(); +export interface ControlConfigFields { + "control.controller_url": string | null; + "control.controller_token": string | null; + "control.tls_ca": string | null; + "control.max_reconnect_delay": number; +} /** * Control config class * @extends classes.Config */ -export class ControlConfig extends classes.Config { } -ControlConfig.registerGroup(ControlGroup); +export class ControlConfig extends classes.Config { + declare static fromJSON: (json: classes.ConfigSchema, location: classes.ConfigLocation) => ControlConfig; + static fieldDefinitions: classes.ConfigDefs = { + "control.controller_url": { + description: "URL to connect to the controller at", + type: "string", + optional: true, + }, + "control.controller_token": { + access: ["control"], + description: "Token to authenticate to controller with.", + type: "string", + optional: true, + }, + "control.tls_ca": { + description: "Path to Certificate Authority to validate TLS connection to controller against.", + type: "string", + optional: true, + }, + "control.max_reconnect_delay": { + title: "Max Reconnect Delay", + description: "Maximum delay to wait before attempting to reconnect WebSocket", + type: "number", + initialValue: 60, + }, + }; +} -function validateGroup( - pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo, - groupName: "ControllerConfigGroup" | "HostConfigGroup" | "InstanceConfigGroup", +function validateFields( + pluginName: string, + fields: Record, ) { - if (!(pluginInfo[groupName]?.prototype instanceof classes.PluginConfigGroup)) { - throw new Error( - `Expected ${groupName} for ${pluginInfo.name} to be a subclass of PluginConfigGroup` - ); - } - - if (pluginInfo[groupName]?.groupName !== pluginInfo.name) { - throw new Error( - `Expected ${groupName} for ${pluginInfo.name} to be named after the plugin` - ); + for (const [name, field] of Object.entries(fields)) { + if (!name.startsWith(`${pluginName}.`)) { + throw new Error( + `Expected name of config field '${name}' for ${pluginName} to start with '${pluginName}.'` + ); + } } } /** - * Registers the config groups for the provided plugin infos + * Add config fields defined by the provided plugin infos * * @param {Array} pluginInfos - Array of plugin info objects. */ -export function registerPluginConfigGroups(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) { +export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) { function pluginConfig( pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo, - group: "ControllerConfigGroup" | "HostConfigGroup" | "InstanceConfigGroup", - Config: typeof classes.Config, + kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields", + Config: typeof ControllerConfig | typeof HostConfig | typeof InstanceConfig, ) { - if (pluginInfo[group]) { - validateGroup(pluginInfo, group); - Config.registerGroup(pluginInfo[group]!); + (Config.fieldDefinitions as any)[`${pluginInfo.name}.load_plugin`] = { + title: "Load Plugin", + restartRequired: true, + type: "boolean", + initialValue: true, + }; - } else { - class ConfigGroup extends classes.PluginConfigGroup { } - ConfigGroup.defaultAccess = ["controller", "host", "control"]; - ConfigGroup.groupName = pluginInfo.name; - ConfigGroup.finalize(); - Config.registerGroup(ConfigGroup); + const fields = pluginInfo[kind]; + if (fields) { + validateFields(pluginInfo.name, fields); + Object.assign(Config.fieldDefinitions, fields); } } for (let pluginInfo of pluginInfos) { - pluginConfig(pluginInfo, "ControllerConfigGroup", ControllerConfig); + pluginConfig(pluginInfo, "controllerConfigFields", ControllerConfig); if (pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint) { - pluginConfig(pluginInfo, "HostConfigGroup", HostConfig); + pluginConfig(pluginInfo, "hostConfigFields", HostConfig); } if (pluginInfo.instanceEntrypoint) { - pluginConfig(pluginInfo, "InstanceConfigGroup", InstanceConfig); + pluginConfig(pluginInfo, "instanceConfigFields", InstanceConfig); } } } - -/** - * Lock configs from adding more groups and make them usable - */ -export function finalizeConfigs() { - ControllerConfig.finalize(); - HostConfig.finalize(); - InstanceConfig.finalize(); - ControlConfig.finalize(); -} diff --git a/packages/lib/src/helpers.ts b/packages/lib/src/helpers.ts index c9589d92..b322cf7c 100644 --- a/packages/lib/src/helpers.ts +++ b/packages/lib/src/helpers.ts @@ -177,6 +177,25 @@ export async function readStream(stream: NodeJS.ReadableStream & { isTTY?: boole return Buffer.concat(chunks); } +/** + * Split the given string on the first instance of separator + * + * Splits `string` on the first instance of `separator` and returns an + * array consisting of the string up to the separator and the string + * after the separator. Returns an array with the string and an empty + * string if the separator is not present. + * + * @param separator - Separator to split string by. + * @param string - String to split + * @returns string split on separator. + */ +export function splitOn(separator: string, string: string) { + let index = string.indexOf(separator); + if (index === -1) { + return [string, ""]; + } + return [string.slice(0, index), string.slice(index + separator.length)]; +} /** * Escapes text for inclusion in a RegExp diff --git a/packages/lib/src/plugin.ts b/packages/lib/src/plugin.ts index 6cf323af..a08cac9a 100644 --- a/packages/lib/src/plugin.ts +++ b/packages/lib/src/plugin.ts @@ -4,7 +4,7 @@ */ import * as libHelpers from "./helpers"; import type { Logger } from "./logging"; -import type { PluginConfigGroup } from "./config"; +import type { FieldDefinition } from "./config"; import type { PlayerStats } from "./data"; // TODO Add proper typing for plugins @@ -15,17 +15,17 @@ export type PluginDeclaration = { description?: string; controllerEntrypoint?: string; - ControllerConfigGroup?: typeof PluginConfigGroup; + controllerConfigFields?: Record; hostEntrypoint?: string; - HostConfigGroup?: typeof PluginConfigGroup; + hostConfigFields?: Record; instanceEntrypoint?: string; - InstanceConfigGroup?: typeof PluginConfigGroup; + instanceConfigFields?: Record; ctlEntrypoint?: string; webEntrypoint?: string; - ControlConfigGroup?: typeof PluginConfigGroup; + controlConfigFields?: Record; messages?: any[]; routes?: string[]; diff --git a/packages/lib/src/shared_commands.ts b/packages/lib/src/shared_commands.ts index fdc5a251..6b9ea138 100644 --- a/packages/lib/src/shared_commands.ts +++ b/packages/lib/src/shared_commands.ts @@ -128,17 +128,14 @@ export function configCommand(yargs: any) { */ export async function handleConfigCommand( args: Record, - instance: libConfig.Config, + instance: libConfig.Config, configPath: string ) { let command = (args._ as string[])[1]; if (command === "list") { - for (let GroupClass of (instance.constructor as typeof libConfig.Config).groups.values()) { - for (let def of GroupClass.definitions.values()) { - let value = instance.get(def.fullName); - print(`${def.fullName} ${JSON.stringify(value)}`); - } + for (const name of Object.keys(instance.constructor.fieldDefinitions)) { + print(`${name} ${JSON.stringify(instance.get(name))}`); } } else if (command === "show") { diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index 6db05d58..bc3aef2e 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -104,8 +104,7 @@ export default async function bootstrap() { })); let pluginInfos = await loadPluginInfos(); lib.registerPluginMessages(pluginInfos); - lib.registerPluginConfigGroups(pluginInfos); - lib.finalizeConfigs(); + lib.addPluginConfigFields(pluginInfos); let wsUrl = new URL(webRoot, document.location.href); let controlConnector = new ControlConnector(wsUrl.href, 120, undefined); diff --git a/packages/web_ui/src/components/BaseConfigTree.tsx b/packages/web_ui/src/components/BaseConfigTree.tsx index a3dcd638..81feb511 100644 --- a/packages/web_ui/src/components/BaseConfigTree.tsx +++ b/packages/web_ui/src/components/BaseConfigTree.tsx @@ -1,4 +1,4 @@ -import type * as lib from "@clusterio/lib"; +import * as lib from "@clusterio/lib"; import React, { useEffect, useState } from "react"; import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tooltip, Tree, Typography } from "antd"; @@ -6,41 +6,35 @@ import ReloadOutlined from "@ant-design/icons/ReloadOutlined"; const { Title } = Typography; -function getInitialValues(config: lib.Config, props: BaseConfigTreeProps) { +function getInitialValues(config: lib.Config, props: BaseConfigTreeProps) { let initialValues: any = {}; - for (let [name, GroupClass] of props.ConfigClass.groups) { - let group = config.group(name); - for (let def of GroupClass.definitions.values()) { - if (!group.canAccess(def.name)) { - continue; - } + for (let [name, def] of Object.entries(config.constructor.fieldDefinitions)) { + if (!config.canAccess(name)) { + continue; + } - let value = group.get(def.name) as any; - let fieldName = `${group.name}.${def.name}`; - - if (def.type === "object") { - for (let prop of Object.keys(value)) { - let propPath = `${group.name}.${def.name}.${prop}`; - initialValues[propPath] = JSON.stringify(value[prop]); - } - } else { - initialValues[fieldName] = value; + let value = config.get(name) as any; + if (def.type === "object") { + for (let prop of Object.keys(value)) { + initialValues[`${name}.${prop}`] = JSON.stringify(value[prop]); } + } else { + initialValues[name] = value; } } return initialValues; } type BaseConfigTreeProps = { - ConfigClass: typeof lib.Config; - retrieveConfig: () => Promise; + ConfigClass: typeof lib.ControllerConfig | typeof lib.InstanceConfig; + retrieveConfig: () => Promise; setField: (field:string, value:any) => Promise; setProp: (field:string, prop:string, value:any) => Promise; id?: number; onApply?: () => void; }; export default function BaseConfigTree(props: BaseConfigTreeProps) { - let [config, setConfig] = useState(null); + let [config, setConfig] = useState|null>(null); let [form] = Form.useForm(); let [changedFields, setChangedFields] = useState>(new Set()); let [errorFields, setErrorFields] = useState(new Map()); @@ -116,21 +110,30 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { let treeData = []; let propsMap = new Map(); - for (let [name, GroupClass] of props.ConfigClass.groups) { - let group = config.group(name); + const groups = new Map(); + for (let [fieldName, def] of Object.entries(props.ConfigClass.fieldDefinitions)) { + const [groupName, field] = lib.splitOn(".", fieldName); + let group = groups.get(groupName); + if (!group) { + group = {}; + groups.set(groupName, group); + } + group[field] = def; + } + for (let [groupName, groupDefs] of groups) { let treeNode: TreeNode = { - key: group.name, - title: group.name, + key: groupName, + title: groupName, children: [], }; - for (let def of GroupClass.definitions.values()) { - if (!group.canAccess(def.name)) { + for (let [field, def] of Object.entries(groupDefs)) { + if (!config.canAccess(`${groupName}.${field}`)) { continue; } - let value = group.get(def.name) as any; - let fieldName = `${group.name}.${def.name}`; + let fieldName = `${groupName}.${field}`; + let value = config.get(fieldName) as any; let childNode: ChildNode = { key: fieldName, children: [], @@ -140,13 +143,13 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { let restartRequiredProps = new Set(def.restartRequiredProps || []); childNode.title = - {def.title || def.name} + {def.title || field} {!def.restartRequiredProps && def.restartRequired && restartTip} } tooltip={def.description} />; for (let prop of Object.keys(value)) { - let propPath = `${group.name}.${def.name}.${prop}`; + let propPath = `${groupName}.${field}.${prop}`; let restart = Boolean( def.restartRequiredProps && Number(def.restartRequired) ^ Number(restartRequiredProps.has(prop)) ); @@ -178,7 +181,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { propsMap.set(propPath, [fieldName, prop]); } - let newPropPath = `${group.name}.${def.name}:add`; + let newPropPath = `${groupName}.${field}:add`; childNode.children.push({ key: newPropPath, title: @@ -201,11 +204,11 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { let propValue = form.getFieldValue(`${newPropPath}.value`); if (!Object.prototype.hasOwnProperty.call(value, propName)) { let newConfig = props.ConfigClass.fromJSON(config!.toJSON(), "control"); - newConfig.setProp(fieldName, propName, null); + (newConfig as lib.Config).setProp(fieldName, propName, null); setConfig(newConfig); } - let propPath = `${group.name}.${def.name}.${propName}`; + let propPath = `${groupName}.${field}.${propName}`; form.setFieldsValue({ [`${newPropPath}.name`]: "", [`${newPropPath}.value`]: "", @@ -224,7 +227,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { childNode.title = - {def.title || def.name} + {def.title || field} {def.restartRequired && restartTip} } validateStatus={errorFields.has(fieldName) ? "error" : undefined} diff --git a/plugins/inventory_sync/controller.ts b/plugins/inventory_sync/controller.ts index 0f82011e..33f0f192 100644 --- a/plugins/inventory_sync/controller.ts +++ b/plugins/inventory_sync/controller.ts @@ -62,7 +62,7 @@ export class ControllerPlugin extends BaseControllerPlugin { } if (["unknown", "stopped"].includes(instance.status)) { - let timeout = this.controller.config.get("inventory_sync.player_lock_timeout") as number * 1000; + let timeout = this.controller.config.get("inventory_sync.player_lock_timeout") * 1000; for (let acquisitonRecord of this.acquiredPlayers.values()) { if (acquisitonRecord.instanceId === instanceId && !acquisitonRecord.expires) { acquisitonRecord.expires = Date.now() + timeout; diff --git a/plugins/inventory_sync/info.ts b/plugins/inventory_sync/info.ts index 62f693bd..38a42735 100644 --- a/plugins/inventory_sync/info.ts +++ b/plugins/inventory_sync/info.ts @@ -1,30 +1,14 @@ import * as lib from "@clusterio/lib"; import * as messages from "./messages"; -class ControllerConfigGroup extends lib.PluginConfigGroup { } -ControllerConfigGroup.defaultAccess = ["controller", "host", "control"]; -ControllerConfigGroup.groupName = "inventory_sync"; -ControllerConfigGroup.define({ - name: "player_lock_timeout", - title: "Player Lock Timeout", - description: - "Time in seconds before the lock on a player inventory expires after an instance stops or is disconnected", - type: "number", - initial_value: 60, -}); -ControllerConfigGroup.finalize(); - -class InstanceConfigGroup extends lib.PluginConfigGroup { } -InstanceConfigGroup.defaultAccess = ["controller", "host", "control"]; -InstanceConfigGroup.groupName = "inventory_sync"; -InstanceConfigGroup.define({ - name: "rcon_chunk_size", - title: "Rcon inventory chunk size", - description: "Divide inventories into multiple chunks before sending with rcon to prevent blocking the pipe", - type: "number", - initial_value: 1000, -}); -InstanceConfigGroup.finalize(); +declare module "@clusterio/lib" { + export interface InstanceConfigFields { + "inventory_sync.rcon_chunk_size": number; + } + export interface ControllerConfigFields { + "inventory_sync.player_lock_timeout": number; + } +} lib.definePermission({ name: "inventory_sync.inventory.view", @@ -39,10 +23,27 @@ export default { description: "Synchronizes players inventories between instances", instanceEntrypoint: "dist/plugin/instance", - InstanceConfigGroup, + instanceConfigFields: { + "inventory_sync.rcon_chunk_size": { + title: "Rcon inventory chunk size", + description: + "Divide inventories into chunks of this size before sending with rcon to prevent blocking the pipe", + type: "number", + initialValue: 1000, + }, + }, controllerEntrypoint: "dist/plugin/controller", - ControllerConfigGroup, + controllerConfigFields: { + "inventory_sync.player_lock_timeout": { + title: "Player Lock Timeout", + description: + "Time in seconds before the lock on a player inventory expires after an instance stops " + + "or is disconnected", + type: "number", + initialValue: 60, + }, + }, messages: [ messages.AcquireRequest, diff --git a/plugins/inventory_sync/instance.ts b/plugins/inventory_sync/instance.ts index 95b7a3fe..c831e8fd 100644 --- a/plugins/inventory_sync/instance.ts +++ b/plugins/inventory_sync/instance.ts @@ -177,7 +177,7 @@ export class InstancePlugin extends BaseInstancePlugin { return; } - const chunkSize = this.instance.config.get("inventory_sync.rcon_chunk_size") as number; + const chunkSize = this.instance.config.get("inventory_sync.rcon_chunk_size"); const chunks = chunkify(chunkSize, JSON.stringify(response.playerData)); this.logger.verbose(`Sending inventory for ${playerName} in ${chunks.length} chunks`); for (let i = 0; i < chunks.length; i++) { diff --git a/plugins/player_auth/controller.ts b/plugins/player_auth/controller.ts index 81ac1283..bc0a494b 100644 --- a/plugins/player_auth/controller.ts +++ b/plugins/player_auth/controller.ts @@ -89,7 +89,7 @@ export class ControllerPlugin extends 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") as number); + let verifyCode = await generateCode(this.controller.config.get("player_auth.code_length")); let secret = Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"); let verifyToken = jwt.sign( { @@ -173,8 +173,8 @@ export class ControllerPlugin extends BaseControllerPlugin { } async handleFetchPlayerCodeRequest(request: FetchPlayerCodeRequest) { - 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; + 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; this.players.set(request.player, { playerCode, verifyCode: null, expires }); return { player_code: playerCode, controller_url: this.controller.getControllerUrl() }; } diff --git a/plugins/player_auth/info.ts b/plugins/player_auth/info.ts index 8289657a..ac1386bf 100644 --- a/plugins/player_auth/info.ts +++ b/plugins/player_auth/info.ts @@ -1,24 +1,15 @@ import * as lib from "@clusterio/lib"; import * as messages from "./messages"; -class ControllerConfigGroup extends lib.PluginConfigGroup {} -ControllerConfigGroup.defaultAccess = ["controller", "host", "control"]; -ControllerConfigGroup.groupName = "player_auth"; -ControllerConfigGroup.define({ - name: "code_length", - title: "Code Length", - description: "Length in characters of the generated codes.", - type: "number", - initial_value: 6, -}); -ControllerConfigGroup.define({ - name: "code_timeout", - title: "Code Timeout", - description: "Time in seconds for the generated codes to stay valid.", - type: "number", - initial_value: 120, -}); -ControllerConfigGroup.finalize(); +declare module "@clusterio/lib" { + export interface InstanceConfigFields { + "player_auth.load_plugin": boolean; + } + export interface ControllerConfigFields { + "player_auth.code_length": number; + "player_auth.code_timeout": number; + } +} export default { name: "player_auth", @@ -27,7 +18,20 @@ export default { controllerEntrypoint: "dist/plugin/controller", instanceEntrypoint: "dist/plugin/instance", webEntrypoint: "./web", - ControllerConfigGroup, + controllerConfigFields: { + "player_auth.code_length": { + title: "Code Length", + description: "Length in characters of the generated codes.", + type: "number", + initialValue: 6, + }, + "player_auth.code_timeout": { + title: "Code Timeout", + description: "Time in seconds for the generated codes to stay valid.", + type: "number", + initialValue: 120, + }, + }, messages: [ messages.FetchPlayerCodeRequest, diff --git a/plugins/statistics_exporter/info.ts b/plugins/statistics_exporter/info.ts index 138e3024..d8276358 100644 --- a/plugins/statistics_exporter/info.ts +++ b/plugins/statistics_exporter/info.ts @@ -1,16 +1,10 @@ import * as lib from "@clusterio/lib"; -class InstanceConfigGroup extends lib.PluginConfigGroup {} -InstanceConfigGroup.defaultAccess = ["controller", "host", "control"]; -InstanceConfigGroup.groupName = "statistics_exporter"; -InstanceConfigGroup.define({ - name: "command_timeout", - title: "Command Timeout", - description: "Timeout in seconds of the metrics command before returning results from the previous invocation.", - type: "number", - initial_value: 1, -}); -InstanceConfigGroup.finalize(); +declare module "@clusterio/lib" { + export interface InstanceConfigFields { + "statistics_exporter.command_timeout": number; + } +} export default { name: "statistics_exporter", @@ -19,5 +13,13 @@ export default { "Provides in-game item/fluid production, builds, kills, and pollution "+ "statistics to the cluster's Prometheus endpoint.", instanceEntrypoint: "dist/plugin/instance", - InstanceConfigGroup, + instanceConfigFields: { + "statistics_exporter.command_timeout": { + title: "Command Timeout", + description: + "Timeout in seconds of the metrics command before returning results from the previous invocation.", + type: "number", + initialValue: 1, + }, + }, } satisfies lib.PluginDeclaration; diff --git a/plugins/statistics_exporter/instance.ts b/plugins/statistics_exporter/instance.ts index 3a06a216..edf53ae2 100644 --- a/plugins/statistics_exporter/instance.ts +++ b/plugins/statistics_exporter/instance.ts @@ -109,7 +109,7 @@ export class InstancePlugin extends 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") as number * 1000; + let timeout = this.instance.config.get("statistics_exporter.command_timeout") * 1000; await lib.timeout(this.gatherMetrics(), timeout, undefined); } } diff --git a/plugins/subspace_storage/controller.ts b/plugins/subspace_storage/controller.ts index a6135b2f..dfafce44 100644 --- a/plugins/subspace_storage/controller.ts +++ b/plugins/subspace_storage/controller.ts @@ -196,7 +196,7 @@ export class ControllerPlugin extends 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") as boolean, + logItemTransfers: this.controller.config.get("subspace_storage.log_item_transfers"), logger: this.logger, }); if (count > 0) { diff --git a/plugins/subspace_storage/info.ts b/plugins/subspace_storage/info.ts index 04c47cd9..a7d6ad54 100644 --- a/plugins/subspace_storage/info.ts +++ b/plugins/subspace_storage/info.ts @@ -1,37 +1,15 @@ import * as lib from "@clusterio/lib"; import * as messages from "./messages"; -class ControllerConfigGroup extends lib.PluginConfigGroup {} -ControllerConfigGroup.defaultAccess = ["controller", "host", "control"]; -ControllerConfigGroup.groupName = "subspace_storage"; -ControllerConfigGroup.define({ - name: "division_method", - title: "Division Method", - description: "Method for dividing resource requests between instances.", - type: "string", - enum: ["simple", "dole", "neural_dole"], - initial_value: "simple", -}); -ControllerConfigGroup.define({ - name: "log_item_transfers", - title: "Log Item Transfers", - description: "Spam controller console with item transfers done.", - type: "boolean", - initial_value: false, -}); -ControllerConfigGroup.finalize(); - -class InstanceConfigGroup extends lib.PluginConfigGroup {} -InstanceConfigGroup.defaultAccess = ["controller", "host", "control"]; -InstanceConfigGroup.groupName = "subspace_storage"; -InstanceConfigGroup.define({ - name: "log_item_transfers", - title: "Log Item Transfers", - description: "Spam host console with item transfers done.", - type: "boolean", - initial_value: false, -}); -InstanceConfigGroup.finalize(); +declare module "@clusterio/lib" { + export interface ControllerConfigFields { + "subspace_storage.division_method": "simple" | "dole" | "neural_dole"; + "subspace_storage.log_item_transfers": boolean; + } + export interface InstanceConfigFields { + "subspace_storage.log_item_transfers": boolean; + } +} lib.definePermission({ name: "subspace_storage.storage.view", @@ -45,10 +23,31 @@ export default { title: "Subspace Storage", description: "Provides shared storage across instances for the Subspace Storage mod", instanceEntrypoint: "dist/plugin/instance", - InstanceConfigGroup, + instanceConfigFields: { + "subspace_storage.log_item_transfers": { + title: "Log Item Transfers", + description: "Spam host console with item transfers done.", + type: "boolean", + initialValue: false, + }, + }, controllerEntrypoint: "dist/plugin/controller", - ControllerConfigGroup, + controllerConfigFields: { + "subspace_storage.division_method": { + title: "Division Method", + description: "Method for dividing resource requests between instances.", + type: "string", + enum: ["simple", "dole", "neural_dole"], + initialValue: "simple", + }, + "subspace_storage.log_item_transfers": { + title: "Log Item Transfers", + description: "Spam controller console with item transfers done.", + type: "boolean", + initialValue: false, + }, + }, messages: [ messages.PlaceEvent, diff --git a/test/controller/InstanceInfo.js b/test/controller/InstanceInfo.js index 8eb9c297..67939189 100644 --- a/test/controller/InstanceInfo.js +++ b/test/controller/InstanceInfo.js @@ -17,9 +17,6 @@ describe("controller/InstanceInfo", function() { assert.deepEqual(InstanceInfo.fromJSON(json, "controller"), info); } - if (!lib.InstanceConfig._finalized) { - lib.InstanceConfig.finalize(); - } const config = new lib.InstanceConfig("controller").toJSON(); check(InstanceInfo.fromJSON({ config, status: "stopped" }, "controller")); diff --git a/test/host.js b/test/host.js index 32327782..3aa8162e 100644 --- a/test/host.js +++ b/test/host.js @@ -7,10 +7,6 @@ const Host = require("@clusterio/host/dist/src/Host").default; const { _discoverInstances } = require("@clusterio/host/dist/src/Host"); describe("Host testing", function() { - before(function() { - lib.InstanceConfig.finalize(); - }); - describe("discoverInstances()", function() { it("should discover test instance", async function() { let instancePath = path.join("test", "file", "instances"); diff --git a/test/host/Instance.js b/test/host/Instance.js index a49e4413..9af3921d 100644 --- a/test/host/Instance.js +++ b/test/host/Instance.js @@ -11,10 +11,6 @@ const addr = lib.Address.fromShorthand; describe("class Instance", function() { - before(function() { - lib.InstanceConfig.finalize(); - }); - let src; let dst = addr({ hostId: 1 }); let instance; diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 6a7af906..3c653921 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -205,30 +205,14 @@ describe("Integration of Clusterio", function() { }); it("should not leak auth_secret", async function() { let result = await getControl().send(new lib.ControllerConfigGetRequest()); - let done = false; - for (let group of result.groups) { - if (group.name === "controller") { - assert.equal(Object.prototype.hasOwnProperty.call(group.fields, "auth_secret"), false); - done = true; - break; - } - } - assert(done, "controller group not found"); + assert.equal(Object.prototype.hasOwnProperty.call(result, "controller.auth_secret"), false); }); }); describe("controller config set", function() { it("sets given config option", async function() { await execCtl('controller config set controller.name "Test Cluster"'); let result = await getControl().send(new lib.ControllerConfigGetRequest()); - let done = false; - for (let group of result.groups) { - if (group.name === "controller") { - assert.equal(group.fields.name, "Test Cluster"); - done = true; - break; - } - } - assert(done, "controller group not found"); + assert.equal(result["controller.name"], "Test Cluster"); }); it("should not allow setting auth_secret", async function() { await assert.rejects(execCtl("controller config set controller.auth_secret root")); diff --git a/test/lib/config/classes.js b/test/lib/config/classes.js index 888ac6dc..03e69c4d 100644 --- a/test/lib/config/classes.js +++ b/test/lib/config/classes.js @@ -5,109 +5,12 @@ const events = require("events"); describe("lib/config/classes", function() { - describe("ConfigGroup", function() { - let mockConfig = new events.EventEmitter(); - mockConfig.location = "local"; - class TestGroup extends lib.ConfigGroup { } - TestGroup.defaultAccess = ["local", "remote"]; - TestGroup.groupName = "test_group"; - describe(".define()", function() { - it("should throw if groupName is not set", function() { - class NoGroupName extends lib.ConfigGroup { } - assert.throws( - () => NoGroupName.define({ name: "a", type: "string", optional: true }), - new Error("Expected ConfigGroup class NoGroupName to have the groupName property set to a string") - ); - }); - it("should throw on unknown properties", function() { - assert.throws( - () => TestGroup.define({ name: "a", type: "string", optional: true, invalid: true }), - new Error("Unknown property invalid") - ); - }); - it("should throw on invalid properties", function() { - for (let [def, error] of [ - [ - { access: "local", name: "a", type: "string", optional: true }, - "access must be an array", - ], - [ - { name: "a", type: "invalid", optional: true }, - "invalid is not a valid type", - ], - [ - { name: [], type: "string", optional: true }, - "name must be a string", - ], - [ - { name: "a", type: "string", enum: true, optional: true }, - "enum must be an array", - ], - [ - { name: "a", title: 2, type: "string", optional: true }, - "title must be a string", - ], - [ - { name: "a", description: 2, type: "string", optional: true }, - "description must be a string", - ], - [ - { name: "a", type: "string", optional: "yes" }, - "optional must be a boolean", - ], - [ - { name: "a", type: "string", initial_value: 1 }, - "initial_value must match the type or be a function", - ], - [ - { name: "a", type: "string" }, - "Non-optional field a needs an initial_value", - ], - ]) { - assert.throws(() => TestGroup.define(def), new Error(error)); - } - }); - it("should throw on defaultAccess not set", function() { - class NoAccessGroup extends lib.ConfigGroup { } - NoAccessGroup.groupName = "no_access"; - assert.throws( - () => NoAccessGroup.define({ name: "a", type: "string", optional: true }), - new Error("access not set and no defaultAccess defined for NoAccessGroup") - ); - }); - it("should throw on missing properties", function() { - assert.throws( - () => TestGroup.define({ type: "string", optional: true }), - new Error("name is required when defining an field") - ); - assert.throws( - () => TestGroup.define({ name: "a", optional: true }), - new Error("type is required when defining an field") - ); - }); - it("should allow defining entries", function() { - TestGroup.define({ name: "test", type: "string", optional: true }); - TestGroup.define({ name: "func", type: "number", initial_value: () => 42 }); - TestGroup.define({ name: "bool", type: "boolean", initial_value: false, optional: true }); - TestGroup.define({ name: "json", type: "object", initial_value: {}, optional: true }); - TestGroup.define({ name: "priv", access: ["local"], type: "string", optional: true }); - - assert(TestGroup._definitions.has("test"), "field test was not defined"); - assert(TestGroup._definitions.has("func"), "field func was not defined"); - assert(TestGroup._definitions.has("bool"), "field bool was not defined"); - assert(TestGroup._definitions.has("json"), "field json was not defined"); - assert(TestGroup._definitions.has("priv"), "field priv was not defined"); - }); - - it("should throw on already defined entries", function() { - assert.throws( - () => TestGroup.define({ name: "test", type: "string", optional: true }), - new Error("Config field test has already been defined") - ); - }); - - it("should set the entries on the class", function() { - let field = { + describe("Config", function() { + class TestConfig extends lib.Config { + static fieldDefinitions = { + "alpha.foo": { type: "string", optional: true }, + "beta.bar": { type: "object", initialValue: {} }, + "test.enum": { access: ["local", "remote"], type: "string", name: "enum", @@ -116,451 +19,22 @@ describe("lib/config/classes", function() { restartRequired: true, enum: ["a", "b", "c"], optional: true, - initial_value: "b", - }; - TestGroup.define(field); - // Generated properties - field.fullName = "test_group.enum"; + initialValue: "b", + }, + "test.test": { type: "string", optional: true }, + "test.func": { type: "number", initialValue: () => 42 }, + "test.bool": { type: "boolean", initialValue: false, optional: true }, + "test.json": { type: "object", initialValue: {}, optional: true }, + "test.priv": { access: ["local"], type: "string", optional: true }, + }; - assert.deepEqual(TestGroup._definitions.get("enum"), field); - }); - }); - - describe("get definitions", function() { - it("should give the field definitions", function() { - assert.equal(TestGroup.definitions, TestGroup._definitions); - }); - }); - - describe(".finalize()", function() { - it("should throw if groupName is not set", function() { - class NoGroupName extends lib.ConfigGroup { } - assert.throws( - () => NoGroupName.finalize(), - new Error("Expected ConfigGroup class NoGroupName to have the groupName property set to a string") - ); - }); - it("should throw when creating an instance before finalize", function() { - assert.throws( - () => new TestGroup(mockConfig), - new Error("Cannot instantiate incomplete ConfigGroup class TestGroup") - ); - }); - - it("should not throw", function() { - TestGroup.finalize(); - }); - - it("should throw when calling define after finalize", function() { - assert.throws( - () => TestGroup.define({}), - new Error("Cannot define field for ConfigGroup class TestGroup after it has been finalized") - ); - }); - }); + constructor(location, fields) { + super(location, fields, ["local", "remote"]); + } + } describe("constructor", function() { - it("should create an instance", function() { - let testInstance = new TestGroup(mockConfig); - }); - it("should initialize all fields with defaults", function() { - let testInstance = new TestGroup(mockConfig); - assert.equal(testInstance.get("enum"), "b"); - assert.equal(testInstance.get("test"), null); - assert.equal(testInstance.get("func"), 42); - assert.equal(testInstance.get("priv"), null); - }); - it("should not initalize inaccessible fields", function() { - let testInstance = new TestGroup({ location: "remote" }); - assert.equal(testInstance._fields.get("enum"), "b"); - assert.equal(testInstance._fields.get("priv"), undefined); - }); - }); - - describe("get name", function() { - it("should give the name of the group", function() { - let testInstance = new TestGroup(mockConfig); - assert.equal(testInstance.name, "test_group"); - }); - }); - - describe(".toJSON()", function() { - it("should serialize a basic group", function() { - let testInstance = new TestGroup(mockConfig); - assert.deepEqual(testInstance.toJSON(), { - name: "test_group", - fields: { - enum: "b", - test: null, - func: 42, - bool: false, - json: {}, - priv: null, - }, - }); - }); - }); - - describe(".toRemote()", function() { - it("should leave out inaccessible fields", function() { - let testInstance = new TestGroup(mockConfig); - assert.deepEqual(testInstance.toRemote("remote"), { - name: "test_group", - fields: { - enum: "b", - test: null, - func: 42, - bool: false, - json: {}, - }, - }); - }); - }); - - describe(".fromJSON()", function() { - it("should load a basic group", function() { - let testInstance = TestGroup.fromJSON({ name: "test_group", fields: { - enum: "c", - test: "blah", - func: 22, - bool: null, - json: { valid: true }, - priv: "bar", - }}, mockConfig); - assert.equal(testInstance.get("enum"), "c"); - assert.equal(testInstance.get("test"), "blah"); - assert.equal(testInstance.get("func"), 22); - assert.equal(testInstance.get("bool"), null); - assert.equal(testInstance.get("priv"), "bar"); - assert.deepEqual(testInstance.get("json"), { valid: true }); - }); - it("should load defaults for missing serialized fields", function() { - let testInstance = TestGroup.fromJSON({ name: "test_group", fields: { enum: "a" } }, mockConfig); - assert.equal(testInstance.get("enum"), "a"); - assert.equal(testInstance.get("test"), null); - assert.equal(testInstance.get("func"), 42); - }); - it("should ignore inaccessible fields", function() { - let remoteConfig = new events.EventEmitter(); - remoteConfig.location = "remote"; - let testInstance = TestGroup.fromJSON({ name: "test_group", fields: { priv: "bad" } }, remoteConfig); - assert.equal(testInstance._fields.get("priv"), undefined); - }); - - it("should preserve unknown fields when serialized back", function() { - let testFields = { - enum: "a", - test: "blah", - func: 50, - bool: false, - json: {}, - priv: null, - alpha: null, - beta: "decay", - gamma: 99, - }; - - let testInstance = TestGroup.fromJSON({ name: "test_group", fields: testFields }, mockConfig); - assert.deepEqual(testInstance.toJSON().fields, testFields); - }); - }); - - describe(".update()", function() { - it("should skip updating invalid values", function() { - let testInstance = new TestGroup(mockConfig); - testInstance.update({ name: "test_group", fields: { enum: "wrong", test: 3, func: null }}, false); - assert.equal(testInstance.get("enum"), "b"); - assert.equal(testInstance.get("test"), null); - assert.equal(testInstance.get("func"), 42); - }); - it("should skip updating inaccessible fields", function() { - let testInstance = new TestGroup(mockConfig); - testInstance.update( - { name: "test_group", fields: { test: "a", priv: "bad" }}, false, "remote" - ); - assert.equal(testInstance.get("test"), "a"); - assert.equal(testInstance.get("priv"), null); - }); - - it("should throw on invalid input", function() { - let testInstance = new TestGroup(mockConfig); - assert.throws( - () => testInstance.update(), - new Error("Expected object, not undefined for ConfigGroup") - ); - assert.throws( - () => testInstance.update({}, false), - new Error("Expected group name test_group, not undefined") - ); - assert.throws( - () => testInstance.update({ name: "test_group" }, false), - new Error("Expected fields to be an object, not undefined") - ); - assert.throws( - () => testInstance.update({ name: "test_group", fields: [] }, false), - new Error("Expected fields to be an object, not array") - ); - assert.throws( - () => testInstance.update({ name: "test_group", fields: null }, false), - new Error("Expected fields to be an object, not null") - ); - }); - }); - - describe(".canAccess()", function() { - let testInstance; - before(function() { - testInstance = new TestGroup(mockConfig, { - name: "test_group", - fields: { enum: "a", priv: "blah", func: 27 }, - }); - }); - it("should return true for fields that are accessible", function() { - for (let field of ["enum", "test", "func", "bool", "json"]) { - assert.equal(testInstance.canAccess(field), true); - assert.equal(testInstance.canAccess(field, "local"), true); - assert.equal(testInstance.canAccess(field, "remote"), true); - } - assert.equal(testInstance.canAccess("priv"), true); - assert.equal(testInstance.canAccess("priv", "local"), true); - assert.equal(testInstance.canAccess("priv", "remote"), false); - }); - }); - - describe(".get()", function() { - let testInstance; - before(function() { - testInstance = new TestGroup(mockConfig, { - name: "test_group", - fields: { enum: "a", priv: "blah", func: 27 }, - }); - }); - it("should retreive value of field", function() { - assert.equal(testInstance.get("enum"), "a"); - assert.equal(testInstance.get("priv"), "blah"); - assert.equal(testInstance.get("func"), 27); - }); - it("should throw if field is inaccessible", function() { - assert.throws( - () => testInstance.get("priv", "remote"), - new lib.InvalidAccess("Field 'priv' is not accessible from remote") - ); - }); - }); - - describe(".set()", function() { - let testInstance; - before(function() { - testInstance = TestGroup.fromJSON( - { name: "test_group", fields: { enum: "a", test: "blah", func: 27 }}, - mockConfig, - ); - }); - - it("should throw if field does not exist", function() { - assert.throws( - () => testInstance.set("bar", 1), - new lib.InvalidField("No field named 'bar'") - ); - }); - - it("should throw if field is inaccesible", function() { - assert.throws( - () => testInstance.set("priv", "bad", "remote"), - new lib.InvalidAccess("Field 'priv' is not accessible from remote") - ); - }); - - it("should throw if field is not in enum", function() { - assert.throws( - () => testInstance.set("enum", "bar"), - new Error("Expected one of [a, b, c], not bar") - ); - }); - - it("should work if field is in enum", function() { - testInstance.set("enum", "c"); - assert.equal(testInstance.get("enum"), "c"); - }); - - it("should throw if field is not optional", function() { - assert.throws( - () => testInstance.set("func", null), - new Error("Field func cannot be null") - ); - }); - - it("should work if field is optional", function() { - testInstance.set("test", "spam"); - assert.equal(testInstance.get("test"), "spam"); - }); - - it("should throw if field is of wrong type", function() { - assert.throws( - () => testInstance.set("test", 1), - new Error("Expected type of test to be string, not number") - ); - }); - - it("should treat empty string as null", function() { - testInstance.set("test", ""); - assert.equal(testInstance.get("test"), null); - }); - - it("should auto convert string to boolean if possible", function() { - testInstance.set("bool", "true"); - assert.equal(testInstance.get("bool"), true); - testInstance.set("bool", "false"); - assert.equal(testInstance.get("bool"), false); - - assert.throws( - () => testInstance.set("bool", "blah"), - new lib.InvalidValue("Expected type of bool to be boolean, not string") - ); - }); - - it("should auto convert string to number if possible", function() { - for (let s of [ - "1", - "+23", - ".23e5", - "-.1e-2", - "100.0001", - "Infinity", - ]) { - testInstance.set("func", s); - assert.equal(testInstance.get("func"), Number.parseFloat(s)); - } - - assert.throws( - () => testInstance.set("func", "blah"), - new lib.InvalidValue("Expected type of func to be number, not string") - ); - }); - - it("should auto convert string to object", function() { - testInstance.set("json", '{"json": true}'); - assert.deepEqual(testInstance.get("json"), { json: true }); - - let errMsg; - try { - JSON.parse("blah"); - } catch (err) { - errMsg = err.message; - } - assert.throws( - () => testInstance.set("json", "blah"), - new lib.InvalidValue(`Error parsing value for json: ${errMsg}`) - ); - }); - }); - - describe(".setProp()", function() { - let testInstance; - before(function() { - testInstance = TestGroup.fromJSON( - { name: "test_group", fields: { enum: "a", test: "blah", func: 27 }}, - mockConfig, - ); - }); - - it("should throw if field does not exist", function() { - assert.throws( - () => testInstance.setProp("bar", 1), - new lib.InvalidField("No field named 'bar'") - ); - }); - - it("should throw if field is inaccesible", function() { - assert.throws( - () => testInstance.setProp("priv", "prop", "bad", "remote"), - new lib.InvalidAccess("Field 'priv' is not accessible from remote") - ); - }); - - it("should throw if field is not an object", function() { - assert.throws( - () => testInstance.setProp("enum", "prop", "a"), - new lib.InvalidField("Cannot set property on non-object field 'enum'") - ); - }); - - it("should work if field is an object", function() { - testInstance.set("json", { prev: 32, test: false }); - testInstance.setProp("json", "test", true); - assert.deepEqual(testInstance.get("json"), { prev: 32, test: true }); - }); - - it("should handle field being null", function() { - testInstance.set("json", null); - testInstance.setProp("json", "test", true); - assert.deepEqual(testInstance.get("json"), { test: true }); - }); - - it("should unset field if passed undefined", function() { - testInstance.set("json", { test: true, extra: "yes" }); - testInstance.setProp("json", "extra", undefined); - assert.deepEqual(testInstance.get("json"), { test: true }); - }); - }); - }); - - describe("Config", function() { - class AlphaGroup extends lib.ConfigGroup { } - AlphaGroup.defaultAccess = ["local", "remote"]; - AlphaGroup.groupName = "alpha"; - class BetaGroup extends lib.ConfigGroup { } - BetaGroup.defaultAccess = ["local", "remote"]; - BetaGroup.groupName = "beta"; - class TestConfig extends lib.Config { } - - before(function() { - AlphaGroup.define({ name: "foo", type: "string", optional: true }); - AlphaGroup.finalize(); - TestConfig.registerGroup(AlphaGroup); - BetaGroup.define({ name: "bar", type: "object", initial_value: {} }); - BetaGroup.finalize(); - TestConfig.registerGroup(BetaGroup); - }); - - describe(".registerGroup()", function() { - it("should throw if the class is finalized", function() { - class FinalizedConfig extends lib.Config { } - FinalizedConfig.finalize(); - class TestGroup extends lib.ConfigGroup { } - TestGroup.groupName = "test"; - TestGroup.finalize(); - - assert.throws( - () => FinalizedConfig.registerGroup(TestGroup), - new Error("Cannot register group on finalized config") - ); - }); - it("should throw if the group is not finalized", function() { - class UnfinishedGroup extends lib.ConfigGroup { } - - assert.throws( - () => TestConfig.registerGroup(UnfinishedGroup), - new Error("Group must be finalized before it can be registered") - ); - }); - it("should throw if the group is already registered", function() { - assert.throws( - () => TestConfig.registerGroup(AlphaGroup), - new Error("alpha has already been registered") - ); - }); - }); - - describe("constructor", function() { - it("should throw if config is not finalized", function() { - assert.throws( - () => new TestConfig("local"), - new Error("Cannot instantiate incomplete Config class TestConfig") - ); - }); - it("should construct a finalized class", function() { - TestConfig.finalize(); + it("should construct an instance", function() { let config = new TestConfig("local"); assert(config); }); @@ -574,6 +48,15 @@ describe("lib/config/classes", function() { let testInstance = new TestConfig("local"); assert.equal(testInstance.get("alpha.foo"), null); assert.deepEqual(testInstance.get("beta.bar"), {}); + assert.equal(testInstance.get("test.enum"), "b"); + assert.equal(testInstance.get("test.test"), null); + assert.equal(testInstance.get("test.func"), 42); + assert.equal(testInstance.get("test.priv"), null); + }); + it("should not initalize inaccessible fields", function() { + let testInstance = new TestConfig("remote"); + assert.equal(testInstance.fields["test.enum"], "b"); + assert.equal(testInstance.fields["test.priv"], undefined); }); }); @@ -581,16 +64,29 @@ describe("lib/config/classes", function() { it("should serialize a basic config", function() { let testInstance = new TestConfig("local"); assert.deepEqual(testInstance.toJSON(), { - groups: [ - { - name: "alpha", - fields: { foo: null }, - }, - { - name: "beta", - fields: { bar: {} }, - }, - ], + "alpha.foo": null, + "beta.bar": {}, + "test.enum": "b", + "test.test": null, + "test.func": 42, + "test.bool": false, + "test.json": {}, + "test.priv": null, + }); + }); + }); + + describe(".toRemote()", function() { + it("should leave out inaccessible fields", function() { + let testInstance = new TestConfig("local"); + assert.deepEqual(testInstance.toRemote("remote"), { + "alpha.foo": null, + "beta.bar": {}, + "test.enum": "b", + "test.test": null, + "test.func": 42, + "test.bool": false, + "test.json": {}, }); }); }); @@ -606,117 +102,332 @@ describe("lib/config/classes", function() { new Error("Invalid config") ); assert.throws( - () => TestConfig.fromJSON({ groups: null }, "local"), + () => TestConfig.fromJSON([], "local"), new Error("Invalid config") ); }); - it("should load defaults for missing serialized groups", function() { - let testInstance = TestConfig.fromJSON({ groups: [{ name: "alpha", fields: { foo: "a" }}] }, "local"); + it("should load defaults for missing fields", function() { + let testInstance = TestConfig.fromJSON({ + "alpha.foo": "a", + "test.enum": "a", + }, "local"); assert.equal(testInstance.get("alpha.foo"), "a"); assert.deepEqual(testInstance.get("beta.bar"), {}); + assert.equal(testInstance.get("test.enum"), "a"); + assert.equal(testInstance.get("test.test"), null); + assert.equal(testInstance.get("test.func"), 42); }); - it("should preserve unknown groups when serialized back", function() { - let testGroups = [ - { - name: "extra", - fields: { blah: true, spam: "foobar" }, - }, - { - name: "alpha", - fields: { foo: "true" }, - }, - { - name: "beta", - fields: { bar: { value: 20 }}, - }, - ]; - let testInstance = TestConfig.fromJSON({ groups: testGroups }, "local"); - assert.deepEqual(testInstance.toJSON().groups, testGroups); + it("should load fields", function() { + let testInstance = TestConfig.fromJSON({ + "test.enum": "c", + "test.test": "blah", + "test.func": 22, + "test.bool": null, + "test.json": { valid: true }, + "test.priv": "bar", + }, "local"); + assert.equal(testInstance.get("test.enum"), "c"); + assert.equal(testInstance.get("test.test"), "blah"); + assert.equal(testInstance.get("test.func"), 22); + assert.equal(testInstance.get("test.bool"), null); + assert.equal(testInstance.get("test.priv"), "bar"); + assert.deepEqual(testInstance.get("test.json"), { valid: true }); + }); + + it("should preserve unknown fields when serialized back", function() { + let testFields = { + "extra.blah": true, + "extra.spam": "foobar", + "alpha.foo": "true", + "beta.bar": { value: 20 }, + "test.enum": "a", + "test.test": "blah", + "test.func": 50, + "test.bool": false, + "test.json": {}, + "test.priv": null, + "test.alpha": null, + "test.beta": "decay", + "test.gamma": 99, + }; + let testInstance = TestConfig.fromJSON(testFields, "local"); + assert.deepEqual(testInstance.toJSON(), testFields); + }); + + it("should ignore inaccessible fields", function() { + let testInstance = TestConfig.fromJSON({ "test.priv": "bad" }, "remote"); + assert.equal(testInstance.fields["test.priv"], undefined); + assert.equal(testInstance.fields["test.func"], 42); }); }); describe(".update()", function() { it("should update a basic config", function() { let testInstance = new TestConfig("local"); - testInstance.update({ groups: [ - { - name: "extra", - fields: { blah: true, spam: "baz" }, - }, - { - name: "beta", - fields: { bar: { value: 30 }}, - }, - ]}, false); - + testInstance.update({ + "extra.blah": true, + "extra.spam": "baz", + "beta.bar": { value: 30 }, + }, false); assert.deepEqual(testInstance.toJSON(), { - groups: [ - { - name: "extra", - fields: { blah: true, spam: "baz" }, - }, - { - name: "alpha", - fields: { foo: null }, - }, - { - name: "beta", - fields: { bar: { value: 30 }}, - }, - ], + "extra.blah": true, + "extra.spam": "baz", + "alpha.foo": null, + "beta.bar": { value: 30 }, + "test.bool": false, + "test.enum": "b", + "test.func": 42, + "test.json": {}, + "test.priv": null, + "test.test": null, }); }); - }); - describe(".group()", function() { - it("should retrieve a given group", function() { + it("should skip updating invalid values", function() { let testInstance = new TestConfig("local"); - assert(testInstance.group("alpha") instanceof AlphaGroup); + testInstance.update({ + "test.enum": "wrong", + "test.test": 3, + "test.func": null, + }, false); + assert.equal(testInstance.get("test.enum"), "b"); + assert.equal(testInstance.get("test.test"), null); + assert.equal(testInstance.get("test.func"), 42); + }); + + it("should skip updating inaccessible fields", function() { + let testInstance = new TestConfig("local"); + testInstance.update({ + "test.test": "a", + "test.priv": "bad", + }, false, "remote"); + assert.equal(testInstance.get("test.test"), "a"); + assert.equal(testInstance.get("test.priv"), null); + }); + + it("should throw on invalid input", function() { + let testInstance = new TestConfig("local"); + assert.throws( + () => testInstance.update(), + new Error("Invalid config") + ); + assert.throws( + () => testInstance.update([], false), + new Error("Invalid config") + ); + assert.throws( + () => testInstance.update(null, false), + new Error("Invalid config") + ); }); }); describe(".canAccess()", function() { - it("should throw if group does not exist", function() { + it("should throw if field does not exist", function() { let testInstance = new TestConfig("local"); - assert.throws(() => testInstance.canAccess("invalid"), new Error("No config group named 'invalid'")); + assert.throws(() => testInstance.canAccess("invalid"), new Error("No field named 'invalid'")); }); - it("should throw if no field is specified", function() { - let testInstance = new TestConfig("local"); - assert.throws(() => testInstance.canAccess("alpha"), new Error("No field named ''")); - }); - - it("should return true for an accessible field", function() { + it("should return true for fields that are accessible", function() { let testInstance = new TestConfig("local"); assert.equal(testInstance.canAccess("alpha.foo"), true); + for (let field of ["test.enum", "test.test", "test.func", "test.bool", "test.json"]) { + assert.equal(testInstance.canAccess(field), true); + assert.equal(testInstance.canAccess(field, "local"), true); + assert.equal(testInstance.canAccess(field, "remote"), true); + } + assert.equal(testInstance.canAccess("test.priv"), true); + assert.equal(testInstance.canAccess("test.priv", "local"), true); + assert.equal(testInstance.canAccess("test.priv", "remote"), false); }); }); describe(".get()", function() { - it("should throw if group does not exist", function() { + it("should throw if field does not exist", function() { let testInstance = new TestConfig("local"); - assert.throws(() => testInstance.get("invalid"), new Error("No config group named 'invalid'")); + assert.throws(() => testInstance.get("invalid"), new Error("No field named 'invalid'")); }); - - it("should throw if no field is specified", function() { + it("should retreive value of field", function() { + let testInstance = new TestConfig("local", { + "test.enum": "a", + "test.priv": "blah", + "test.func": 27, + }); + assert.equal(testInstance.get("test.enum"), "a"); + assert.equal(testInstance.get("test.priv"), "blah"); + assert.equal(testInstance.get("test.func"), 27); + }); + it("should throw if field is inaccessible", function() { let testInstance = new TestConfig("local"); - assert.throws(() => testInstance.get("alpha"), new Error("No field named ''")); + assert.throws( + () => testInstance.get("test.priv", "remote"), + new lib.InvalidAccess("Field 'test.priv' is not accessible from remote") + ); }); }); describe(".set()", function() { + let testInstance; + beforeEach(function() { + testInstance = new TestConfig("local", { + "test.enum": "a", + "test.test": "blah", + "test.func": 27, + }); + }); + it("should set the value", function() { - let testInstance = new TestConfig("local"); testInstance.set("alpha.foo", "new value"); assert.equal(testInstance.get("alpha.foo"), "new value"); }); + + it("should throw if field does not exist", function() { + assert.throws( + () => testInstance.set("test.bar", 1), + new lib.InvalidField("No field named 'test.bar'") + ); + }); + + it("should throw if field is inaccesible", function() { + assert.throws( + () => testInstance.set("test.priv", "bad", "remote"), + new lib.InvalidAccess("Field 'test.priv' is not accessible from remote") + ); + }); + + it("should throw if field is not in enum", function() { + assert.throws( + () => testInstance.set("test.enum", "bar"), + new Error("Expected one of [a, b, c], not bar") + ); + }); + + it("should work if field is in enum", function() { + testInstance.set("test.enum", "c"); + assert.equal(testInstance.get("test.enum"), "c"); + }); + + it("should throw if field is not optional", function() { + assert.throws( + () => testInstance.set("test.func", null), + new Error("Field test.func cannot be null") + ); + }); + + it("should work if field is optional", function() { + testInstance.set("test.test", "spam"); + assert.equal(testInstance.get("test.test"), "spam"); + }); + + it("should throw if field is of wrong type", function() { + assert.throws( + () => testInstance.set("test.test", 1), + new Error("Expected type of test.test to be string, not number") + ); + }); + + it("should treat empty string as null", function() { + testInstance.set("test.test", ""); + assert.equal(testInstance.get("test.test"), null); + }); + + it("should auto convert string to boolean if possible", function() { + testInstance.set("test.bool", "true"); + assert.equal(testInstance.get("test.bool"), true); + testInstance.set("test.bool", "false"); + assert.equal(testInstance.get("test.bool"), false); + + assert.throws( + () => testInstance.set("test.bool", "blah"), + new lib.InvalidValue("Expected type of test.bool to be boolean, not string") + ); + }); + + it("should auto convert string to number if possible", function() { + for (let s of [ + "1", + "+23", + ".23e5", + "-.1e-2", + "100.0001", + "Infinity", + ]) { + testInstance.set("test.func", s); + assert.equal(testInstance.get("test.func"), Number.parseFloat(s)); + } + + assert.throws( + () => testInstance.set("test.func", "blah"), + new lib.InvalidValue("Expected type of test.func to be number, not string") + ); + }); + + it("should auto convert string to object", function() { + testInstance.set("test.json", '{"json": true}'); + assert.deepEqual(testInstance.get("test.json"), { json: true }); + + let errMsg; + try { + JSON.parse("blah"); + } catch (err) { + errMsg = err.message; + } + assert.throws( + () => testInstance.set("test.json", "blah"), + new lib.InvalidValue(`Error parsing value for test.json: ${errMsg}`) + ); + }); }); - describe("get groups", function() { - it("should give the registered group classes", function() { - assert.equal(TestConfig.groups, TestConfig._groups); + describe(".setProp()", function() { + let testInstance; + beforeEach(function() { + testInstance = new TestConfig("local", { + "test.enum": "a", + "test.test": "blah", + "test.func": 27, + }); + }); + + it("should throw if field does not exist", function() { + assert.throws( + () => testInstance.setProp("test.bar", 1), + new lib.InvalidField("No field named 'test.bar'") + ); + }); + + it("should throw if field is inaccesible", function() { + assert.throws( + () => testInstance.setProp("test.priv", "prop", "bad", "remote"), + new lib.InvalidAccess("Field 'test.priv' is not accessible from remote") + ); + }); + + it("should throw if field is not an object", function() { + assert.throws( + () => testInstance.setProp("test.enum", "prop", "a"), + new lib.InvalidField("Cannot set property on non-object field 'test.enum'") + ); + }); + + it("should work if field is an object", function() { + testInstance.set("test.json", { prev: 32, test: false }); + testInstance.setProp("test.json", "test", true); + assert.deepEqual(testInstance.get("test.json"), { prev: 32, test: true }); + }); + + it("should handle field being null", function() { + testInstance.set("test.json", null); + testInstance.setProp("test.json", "test", true); + assert.deepEqual(testInstance.get("test.json"), { test: true }); + }); + + it("should unset field if passed undefined", function() { + testInstance.set("test.json", { test: true, extra: "yes" }); + testInstance.setProp("test.json", "extra", undefined); + assert.deepEqual(testInstance.get("test.json"), { test: true }); }); }); @@ -726,8 +437,8 @@ describe("lib/config/classes", function() { beforeEach(function() { testInstance = new TestConfig("local"); called = false; - testInstance.once("fieldChanged", (group, field, prev) => { - if (group.name === "beta" && field === "bar") { + testInstance.once("fieldChanged", (field, curr, prev) => { + if (field === "beta.bar") { called = true; } }); @@ -740,8 +451,7 @@ describe("lib/config/classes", function() { it("should be called when updating a config", function() { testInstance.update({ - location: "local", - groups: [{ name: "beta", fields: { bar: { value: 1 }}}], + "beta.bar": { value: 1 }, }, true); assert(called, "fieldChanged was not called"); });