diff --git a/lib/config/classes.js b/lib/config/classes.js index 13196729..4c5c0351 100644 --- a/lib/config/classes.js +++ b/lib/config/classes.js @@ -1,6 +1,9 @@ // Configuration classes "use strict"; +const events = require("events"); +const util = require("util"); + const { basicType } = require("lib/helpers"); @@ -55,10 +58,20 @@ function splitOn(separator, string) { * Represents a collection of ConfigGroup instances that can be * managed as one. * + * 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. + * + * @extends events.EventEmitter + * * @memberof module:lib/config */ -class Config { +class Config extends events.EventEmitter { constructor() { + super(); + if (!this.constructor._finalized) { throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`) } @@ -71,7 +84,7 @@ class Config { for (let [name, GroupClass] of this.constructor._groups) { if (!this._groups.has(name)) { let group = new GroupClass(); - await group.init(); + await group.init(this); this._groups.set(name, group); } } @@ -98,7 +111,7 @@ class Config { } let group = new GroupClass(); - await group.load(serializedGroup); + await group.load(this, serializedGroup); this._groups.set(GroupClass.groupName, group); } @@ -123,12 +136,12 @@ class Config { * Updates all groups in the config with groups in the serialized * config passed. */ - update(serializedConfig) { + update(serializedConfig, notify) { this._validate(serializedConfig); for (let serializedGroup of serializedConfig.groups) { let group = this._groups.get(serializedGroup.name); if (group) { - group.update(serializedGroup); + group.update(serializedGroup, notify); } else { this._unknownGroups.set(serializedGroup.name, serializedGroup); } @@ -226,6 +239,11 @@ class Config { /** * 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. + * * @memberof module:lib/config */ class ConfigGroup { @@ -242,6 +260,7 @@ class ConfigGroup { this._fields = new Map(); this._unknownFields = new Map(); + this._config = null; } /** @@ -250,7 +269,9 @@ class ConfigGroup { * Computes and assigns the initial values for all fields of the * config group. */ - async init() { + async init(config) { + this._config = config; + for (let [name, def] of this.constructor._definitions) { if (this._fields.has(name)) { continue; @@ -275,9 +296,9 @@ class ConfigGroup { * group and then initializes any possible missing fields from it to * their default values. */ - async load(serializedGroup) { - this.update(serializedGroup); - await this.init(); + async load(config, serializedGroup) { + this.update(serializedGroup, false); + await this.init(config); } /** @@ -345,7 +366,11 @@ class ConfigGroup { } } + let prev = this._fields.get(name); this._fields.set(name, value); + if (this._config && !util.isDeepStrictEqual(value, prev)) { + this._config.emit("fieldChanged", this, name, prev); + } } /** @@ -354,7 +379,7 @@ class ConfigGroup { * 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) { + update(serializedGroup, notify) { if (basicType(serializedGroup) !== "object") { throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`); } @@ -388,7 +413,11 @@ class ConfigGroup { continue; } + let prev = this._fields.get(name); this._fields.set(name, value); + if (notify && this._config && !util.isDeepStrictEqual(value, prev)) { + this._config.emit("fieldChanged", this, name, prev); + } } } diff --git a/lib/plugin.js b/lib/plugin.js index 3e08cd0f..f2d2a080 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -42,6 +42,19 @@ class BaseInstancePlugin { */ async init() { } + /** + * Called when the value of a config field changed. + * + * Invoked after the value of the config field given by `field` has + * changed. + * + * @param {module:lib/config.ConfigGroup} group - + * The group who's field got changed on. + * @param {string} field - Name of the field that changed. + * @param prev - The previous value of the field. + */ + async onInstanceConfigFieldChanged(group, field, prev) { } + /** * Called before collecting Prometheus metrics * diff --git a/slave.js b/slave.js index 15390cb7..8932aacb 100644 --- a/slave.js +++ b/slave.js @@ -68,6 +68,11 @@ class Instance extends link.Link{ this.plugins = new Map(); this.config = instanceConfig; + this._configFieldChanged = (group, field, prev) => { + plugin.invokeHook(this.plugins, "onInstanceConfigFieldChanged", group, field, prev); + }; + this.config.on("fieldChanged", this._configFieldChanged); + let serverOptions = { version: this.config.get("factorio.version"), gamePort: this.config.get('factorio.game_port'), @@ -112,6 +117,8 @@ class Instance extends link.Link{ this._running = false; link.messages.instanceStopped.send(this, { instance_id: this.config.get("instance.id") }); + this.config.off("fieldChanged", this._configFieldChanged); + // Clear metrics this instance is exporting for (let collector of prometheus.defaultRegistry.collectors) { if ( @@ -756,7 +763,7 @@ class Slave extends link.Link { let { instance_id, serialized_config } = message.data; let instanceInfo = this.instanceInfos.get(instance_id); if (instanceInfo) { - instanceInfo.config.update(serialized_config); + instanceInfo.config.update(serialized_config, true); console.log(`Clusterio | Updated config for ${instanceInfo.path}`); // TODO: Notify of update if instance is running diff --git a/test/lib/config/classes.js b/test/lib/config/classes.js index 6e2f0bac..7f7c862c 100644 --- a/test/lib/config/classes.js +++ b/test/lib/config/classes.js @@ -177,7 +177,7 @@ describe("lib/config/classes", function() { describe(".load()", function() { it("should load a basic group", async function() { let testInstance = new TestGroup(); - await testInstance.load({ name: "test_group", fields: { + await testInstance.load(null, { name: "test_group", fields: { enum: "c", test: "blah", func: 22, @@ -193,7 +193,7 @@ describe("lib/config/classes", function() { }); it("should load defaults for missing serialized fields", async function() { let testInstance = new TestGroup(); - await testInstance.load({ name: "test_group", fields: { enum: "a" } }); + await testInstance.load(null, { name: "test_group", fields: { enum: "a" } }); assert.equal(testInstance.get("enum"), "a"); assert.equal(testInstance.get("test"), null); @@ -213,7 +213,7 @@ describe("lib/config/classes", function() { } let testInstance = new TestGroup(); - await testInstance.load({ name: "test_group", fields: testFields }); + await testInstance.load(null, { name: "test_group", fields: testFields }); assert.deepEqual(testInstance.serialize().fields, testFields); }); @@ -223,7 +223,7 @@ describe("lib/config/classes", function() { it("should skip updating invalid values", async function() { let testInstance = new TestGroup(); await testInstance.init(); - testInstance.update({ name: "test_group", fields: { enum: "wrong", test: 3, func: null }}); + 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); @@ -237,19 +237,19 @@ describe("lib/config/classes", function() { new Error("Expected object, not undefined for ConfigGroup") ); assert.throws( - () => testInstance.update({}), + () => testInstance.update({}, false), new Error("Expected group name test_group, not undefined") ); assert.throws( - () => testInstance.update({ name: "test_group" }), + () => testInstance.update({ name: "test_group" }, false), new Error("Expected fields to be an object, not undefined") ); assert.throws( - () => testInstance.update({ name: "test_group", fields: []}), + () => 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}), + () => testInstance.update({ name: "test_group", fields: null}, false), new Error("Expected fields to be an object, not null") ); }); @@ -261,7 +261,7 @@ describe("lib/config/classes", function() { testInstance = new TestGroup(); testInstance.update({ name: "test_group", fields: { enum: "a", test: "blah", func: 27, - }}); + }}, false); }); it("should throw if field does not exist", function() { @@ -500,7 +500,7 @@ describe("lib/config/classes", function() { name: "beta", fields: { bar: 30 }, } - ]}); + ]}, false); assert.deepEqual(testInstance.serialize(), { groups: [ @@ -563,5 +563,36 @@ describe("lib/config/classes", function() { }); }); + describe("fieldChanged event", function() { + it("should be called when setting a field", async function() { + let testInstance = new TestConfig(); + await testInstance.init(); + + let called = false; + testInstance.once("fieldChanged", (group, field, prev) => { + if (group.constructor.groupName === "alpha" && field === "foo") { + called = true; + } + }); + + testInstance.set("alpha.foo", "new value"); + assert(called, "fieldChanged was not called"); + }); + + it("should be called when updating a config", async function() { + let testInstance = new TestConfig(); + await testInstance.init(); + + let called = false; + testInstance.once("fieldChanged", (group, field, prev) => { + if (group.constructor.groupName === "alpha" && field === "foo") { + called = true; + } + }); + + testInstance.update({ groups: [{ name: "alpha", fields: { foo: "new value" }}]}, true); + assert(called, "fieldChanged was not called"); + }); + }); }); });