mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Replace current configuration mess with a trimmed down version of the original vision of #200. Configs are collection of config groups that have fields that can be set. These groups allow organizing the fields as well as opertunity for plugins to add their own groups. By default plugins have an auto generated group with the enabled field.
593 lines
14 KiB
JavaScript
593 lines
14 KiB
JavaScript
/**
|
|
* @file Configuration classes
|
|
* @author Hornwitser
|
|
*/
|
|
"use strict";
|
|
|
|
|
|
/**
|
|
* Invalid Value exception
|
|
*
|
|
* Exception class for when invalid values are attempted to be set on a
|
|
* field.
|
|
*/
|
|
class InvalidValue extends Error { };
|
|
|
|
/**
|
|
* Invalid Field exception
|
|
*
|
|
* Exception class for when a group or field that does not exist is
|
|
* attempted to be accessed.
|
|
*/
|
|
class InvalidField extends Error { };
|
|
|
|
/**
|
|
* Split the given string on the first instance of separator
|
|
*
|
|
* Splits @p string on the first instance of @p 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.
|
|
*
|
|
* @returns {Array} string split on separator.
|
|
*/
|
|
function splitOn(separator, string) {
|
|
let index = string.indexOf(separator);
|
|
if (index === -1) {
|
|
return [string, ''];
|
|
}
|
|
return [string.slice(0, index), string.slice(index + separator.length)];
|
|
}
|
|
|
|
/**
|
|
* Return a string describing the type of the value passed
|
|
*
|
|
* Works the same as typeof, excpet that null and array types get their
|
|
* own string.
|
|
*
|
|
* @param value - value to return the type of.
|
|
* @returns {string} basic type of the value passed.
|
|
*/
|
|
function basicType(value) {
|
|
if (value === null) { return "null"; }
|
|
if (value instanceof Array) { return "array"; }
|
|
return typeof value;
|
|
}
|
|
|
|
/**
|
|
* Collection of config groups
|
|
*
|
|
* Represents a collection of ConfigGroup instances that can be
|
|
* managed as one.
|
|
*/
|
|
class Config {
|
|
constructor() {
|
|
if (!this.constructor._finalized) {
|
|
throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`)
|
|
}
|
|
|
|
this._groups = new Map();
|
|
this._unknownGroups = new Map();
|
|
}
|
|
|
|
async init() {
|
|
for (let [name, GroupClass] of this.constructor._groups) {
|
|
if (!this._groups.has(name)) {
|
|
let group = new GroupClass();
|
|
await group.init();
|
|
this._groups.set(name, group);
|
|
}
|
|
}
|
|
this._initialized = true;
|
|
}
|
|
|
|
_validate(serializedConfig) {
|
|
if (basicType(serializedConfig) !== "object") {
|
|
throw new Error(`Expected object, not ${basicType(serializedConfig)} for config`);
|
|
}
|
|
|
|
if (basicType(serializedConfig.groups) !== "array") {
|
|
throw new Error(`Expected groups to be an array, not ${basicType(serializedConfig.groups)}`);
|
|
}
|
|
}
|
|
|
|
async load(serializedConfig) {
|
|
this._validate(serializedConfig);
|
|
for (let serializedGroup of serializedConfig.groups) {
|
|
let GroupClass = this.constructor._groups.get(serializedGroup.name);
|
|
if (!GroupClass) {
|
|
this._unknownGroups.set(serializedGroup.name, serializedGroup);
|
|
continue;
|
|
}
|
|
|
|
let group = new GroupClass();
|
|
await group.load(serializedGroup);
|
|
this._groups.set(GroupClass.groupName, group);
|
|
}
|
|
|
|
await this.init();
|
|
}
|
|
|
|
/**
|
|
* Serialize the config to a plain JavaScript object
|
|
*/
|
|
serialize() {
|
|
let groups = [...this._unknownGroups.values()];
|
|
for (let group of this._groups.values()) {
|
|
groups.push(group.serialize());
|
|
}
|
|
|
|
return { groups };
|
|
}
|
|
|
|
/**
|
|
* Update the configuration based on a serialized config
|
|
*
|
|
* Updates all groups in the config with groups in the serialized
|
|
* config passed.
|
|
*/
|
|
update(serializedConfig) {
|
|
this._validate(serializedConfig);
|
|
for (let serializedGroup of serializedConfig.groups) {
|
|
let group = this._groups.get(serializedGroup.name);
|
|
if (group) {
|
|
group.update(serializedGroup);
|
|
} else {
|
|
this._unknownGroups.set(serializedGroup.name, serializedGroup);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the value for a config field
|
|
*/
|
|
get(name) {
|
|
let [group, field] = splitOn('.', name);
|
|
return this.group(group).get(field);
|
|
}
|
|
|
|
/**
|
|
* Set the value for a config field
|
|
*/
|
|
set(name, value) {
|
|
let [group, field] = splitOn('.', name);
|
|
return this.group(group).set(field, value);
|
|
}
|
|
|
|
/**
|
|
* Get the config group instance with the given name
|
|
*/
|
|
group(name) {
|
|
if (!this._initialized) {
|
|
throw new Error(`${this.constructor.name} instance is uninitialized`)
|
|
}
|
|
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 {Map<string, GroupClass>} 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.
|
|
*/
|
|
static registerGroup(group) {
|
|
this._initSubclass();
|
|
|
|
if (this._finalized) {
|
|
throw new Error("Cannot register group on finalized 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);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Collection of related config entries
|
|
*/
|
|
class ConfigGroup {
|
|
/**
|
|
* Creates a new config group.
|
|
*
|
|
* After the creation of the new config group you have to call
|
|
* either .init() or .load() on it in order to fully initialize it.
|
|
*/
|
|
constructor(serializedGroup) {
|
|
if (!this.constructor._finalized) {
|
|
throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`)
|
|
}
|
|
|
|
this._fields = new Map();
|
|
this._unknownFields = new Map();
|
|
}
|
|
|
|
/**
|
|
* Initialize new config group
|
|
*
|
|
* Computes and assigns the initial values for all fields of the
|
|
* config group.
|
|
*/
|
|
async init() {
|
|
for (let [name, def] of this.constructor._definitions) {
|
|
if (this._fields.has(name)) {
|
|
continue;
|
|
}
|
|
|
|
let value = null;
|
|
if (typeof def.initial_value === "function") {
|
|
value = await def.initial_value()
|
|
|
|
} else if (def.initial_value !== undefined) {
|
|
value = def.initial_value;
|
|
}
|
|
|
|
this._fields.set(name, value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async load(serializedGroup) {
|
|
this.update(serializedGroup);
|
|
await this.init();
|
|
}
|
|
|
|
/**
|
|
* Get value for field
|
|
*/
|
|
get(name) {
|
|
if (!this.constructor._definitions.has(name)) {
|
|
throw new InvalidField(`No field named '${name}'`);
|
|
}
|
|
|
|
return this._fields.get(name);
|
|
}
|
|
|
|
/**
|
|
* Set value of field
|
|
*
|
|
* The field must be defined for the config group and the value is
|
|
* type checked against the field definition.
|
|
*
|
|
* @throws {InvalidValue} if value is not allowed for the field.
|
|
*/
|
|
set(name, value) {
|
|
let def = this.constructor._definitions.get(name);
|
|
if (!def) {
|
|
throw new Error(`No field named '${name}'`);
|
|
}
|
|
|
|
// Empty strings are treates as null
|
|
if (value === "") {
|
|
value = null;
|
|
}
|
|
|
|
if (def.enum && !def.enum.includes(value)) {
|
|
throw new InvalidValue(`Expected one of [${def.enum.join(', ')}], not ${value}`);
|
|
}
|
|
|
|
if (basicType(value) === "string") {
|
|
if (def.type === "boolean") {
|
|
if (value === "true") { value = true; }
|
|
else if (value === "false") { value = false; }
|
|
|
|
} else if (def.type === "number") {
|
|
let numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
|
|
if (numberRegExp.test(value.trim())) {
|
|
value = Number.parseFloat(value);
|
|
}
|
|
|
|
} else if (def.type === "object" ) {
|
|
try {
|
|
value = JSON.parse(value);
|
|
} catch (err) {
|
|
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (value === null) {
|
|
if (!def.optional) {
|
|
throw new InvalidValue(`Field ${name} cannot be null`)
|
|
}
|
|
|
|
} else {
|
|
if (basicType(value) !== def.type) {
|
|
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
|
}
|
|
}
|
|
|
|
this._fields.set(name, value);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
update(serializedGroup) {
|
|
if (basicType(serializedGroup) !== "object") {
|
|
throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`);
|
|
}
|
|
|
|
if (serializedGroup.name !== this.constructor.groupName) {
|
|
throw new Error(`Expected group name ${this.constructor.groupName}, 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 (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;
|
|
}
|
|
|
|
this._fields.set(name, value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize the config to a plain JavaScript object
|
|
*/
|
|
serialize() {
|
|
let fields = {};
|
|
for (let [name, value] of this._fields) {
|
|
fields[name] = value;
|
|
}
|
|
|
|
for (let [name, value] of this._unknownFields) {
|
|
fields[name] = value;
|
|
}
|
|
|
|
return {
|
|
name: this.constructor.groupName,
|
|
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.
|
|
*
|
|
* @returns {Map<string, Object>} field meta data
|
|
*/
|
|
static get definitions() {
|
|
this._initSubclass();
|
|
return this._definitions;
|
|
}
|
|
|
|
/**
|
|
* Define a new configuration field
|
|
*
|
|
* The following properties are supported:
|
|
* - type:
|
|
* string declaring the type of the config value, supports
|
|
* boolean, string, number and object.
|
|
* - name:
|
|
* Name of the configuration field. Should follow the
|
|
* lower_case_underscore style.
|
|
* - title:
|
|
* Text used to identify the config field in user interfaces.
|
|
* - description:
|
|
* Text used to describe the config field in user interfaces.
|
|
* - enum (optional):
|
|
* Array of all values that are acceptible. Useful for making
|
|
* enumerated values.
|
|
* - optional (optional):
|
|
* True if this field can be set to null. Defaults to false.
|
|
* - initial_value (optional):
|
|
* Value this config field should take in a newly initialized
|
|
* config. This can also be an async function returning the
|
|
* value to use.
|
|
*/
|
|
static define(item) {
|
|
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
|
|
let validTypes = ["boolean", "string", "number", "object"];
|
|
Object.keys(item).forEach(key => {
|
|
let value = item[key];
|
|
let valueType = basicType(value);
|
|
switch (key) {
|
|
case "type":
|
|
if (!validTypes.includes(value)) {
|
|
throw new Error(`${value} is not a valid type`);
|
|
}
|
|
break;
|
|
|
|
case "enum":
|
|
if (valueType !== "array") {
|
|
throw new Error("enum must be an array");
|
|
}
|
|
break;
|
|
|
|
case "name":
|
|
if (valueType !== "string") {
|
|
throw new Error("name must be a string");
|
|
}
|
|
break;
|
|
|
|
case "title":
|
|
if (valueType !== "string") {
|
|
throw new Error("title must be a string");
|
|
}
|
|
break;
|
|
|
|
case "description":
|
|
if (valueType !== "string") {
|
|
throw new Error("description must be a string");
|
|
}
|
|
break;
|
|
|
|
case "optional":
|
|
if (valueType !== "boolean") {
|
|
throw new Error("optional must be a boolean");
|
|
}
|
|
break;
|
|
|
|
case "initial_value":
|
|
if (valueType !== item.type && valueType !== "function") {
|
|
throw new Error("initial_value must match the type or be a function");
|
|
}
|
|
break;
|
|
|
|
default:
|
|
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 = Object.assign({
|
|
optional: false,
|
|
fullName: `${this.groupName}.${item.name}`,
|
|
}, item)
|
|
|
|
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.
|
|
*/
|
|
class PluginConfigGroup extends ConfigGroup {
|
|
static _initSubclass() {
|
|
if (!this._initialized) {
|
|
super._initSubclass();
|
|
this.define({
|
|
name: "enabled",
|
|
description: "Enable plugin",
|
|
type: "boolean",
|
|
initial_value: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
module.exports = {
|
|
InvalidField,
|
|
InvalidValue,
|
|
|
|
Config,
|
|
ConfigGroup,
|
|
PluginConfigGroup,
|
|
}
|