diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index bb3ee970..c84f791c 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -156,7 +156,6 @@ async function handleBootstrapCommand( return; } let controlConfig = new lib.ControlConfig("control"); - controlConfig.init(); controlConfig.set("control.controller_url", Controller.calculateControllerUrl(controllerConfig)); controlConfig.set( @@ -164,7 +163,7 @@ async function handleBootstrapCommand( userManager.signUserToken(admin.name), ); - let content = JSON.stringify(controlConfig.serialize(), null, "\t"); + let content = JSON.stringify(controlConfig, null, "\t"); if (args.output === "-") { // eslint-disable-next-line no-console console.log(content); @@ -322,15 +321,15 @@ async function initialize(): Promise { const controllerConfigPath = args.config; logger.info(`Loading config from ${controllerConfigPath}`); - const controllerConfig = new lib.ControllerConfig("controller"); + let controllerConfig = new lib.ControllerConfig("controller"); try { - let fileData = await fs.readFile(controllerConfigPath, { encoding: "utf8" }); - controllerConfig.load(JSON.parse(fileData)); + let jsonConfig = JSON.parse(await fs.readFile(controllerConfigPath, { encoding: "utf8" })); + controllerConfig = lib.ControllerConfig.fromJSON(jsonConfig, "controller"); } catch (err: any) { if (err.code === "ENOENT") { logger.info("Config not found, initializing new config"); - controllerConfig.init(); + controllerConfig = new lib.ControllerConfig("controller"); } else { throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.message}`); @@ -343,7 +342,7 @@ async function initialize(): Promise { let bytes = await asyncRandomBytes(256); controllerConfig.set("controller.auth_secret", bytes.toString("base64")); await lib.safeOutputFile( - controllerConfigPath, JSON.stringify(controllerConfig.serialize(), null, "\t") + controllerConfigPath, JSON.stringify(controllerConfig, null, "\t") ); } diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 7242bd6b..990934ec 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -169,8 +169,8 @@ export default class ControlConnection extends BaseConnection { throw new Error("Should be unreachable"); } - async handleControllerConfigGetRequest(): Promise { - return new lib.RawConfig(this._controller.config.serialize("control")); + async handleControllerConfigGetRequest() { + return this._controller.config.toRemote("control"); } async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) { @@ -211,8 +211,7 @@ export default class ControlConnection extends BaseConnection { } async handleHostConfigCreateRequest(request: lib.HostConfigCreateRequest) { - let hostConfig = new lib.HostConfig("control"); - hostConfig.init(); + const hostConfig = new lib.HostConfig("host"); hostConfig.set("host.controller_url", this._controller.getControllerUrl()); if (request.id !== undefined) { @@ -225,7 +224,7 @@ export default class ControlConnection extends BaseConnection { this.user.checkPermission("core.host.generate_token"); hostConfig.set("host.controller_token", this._controller.generateHostToken(hostConfig.get("host.id"))); } - return new lib.RawConfig(hostConfig.serialize()); + return hostConfig.toJSON(); } async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) { @@ -236,10 +235,9 @@ export default class ControlConnection extends BaseConnection { return [...this._controller.instances.values()].map(instance => instance.toInstanceDetails()); } - // XXX should probably add a hook for host reuqests? async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) { - let instanceConfig = new lib.InstanceConfig("controller"); - instanceConfig.load(request.config as lib.SerializedConfig); + const instanceConfig = new lib.InstanceConfig("controller"); + instanceConfig.update(request.config, false, "control"); await this._controller.instanceCreate(instanceConfig); } @@ -249,7 +247,7 @@ export default class ControlConnection extends BaseConnection { async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) { let instance = this._controller.getRequestInstance(request.instanceId); - return new lib.InstanceConfigGetRequest.Response(instance.config.serialize("control")); + return instance.config.toRemote("control"); } async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) { diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index ef557bf9..6db5d9df 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -421,7 +421,7 @@ export default class Controller { private async _saveDataInternal() { if (this.config.dirty) { this.config.dirty = false; - await lib.safeOutputFile(this.configPath, JSON.stringify(this.config.serialize(), null, "\t")); + await lib.safeOutputFile(this.configPath, JSON.stringify(this.config, null, "\t")); } let databaseDirectory = this.config.get("controller.database_directory"); @@ -495,11 +495,9 @@ export default class Controller { ) as Static[]; for (let json of serialized) { if (!json.config) { // migrate: from pre Alpha 14 format. - json = { config: json, status: "running" }; // Use running to force updatedAt + json = { config: json as any, status: "running" }; // Use running to force updatedAt } - let instanceConfig = new lib.InstanceConfig("controller"); - instanceConfig.load(json.config as lib.SerializedConfig); - let instance = InstanceInfo.fromJSON(json, instanceConfig); + const instance = InstanceInfo.fromJSON(json, "controller"); const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown"; if (instance.status !== status) { instance.status = status; @@ -519,7 +517,7 @@ export default class Controller { } static async saveInstances(filePath: string, instances: Map) { - let serialized = [...instances.values()].map(instance => instance.toJSON()); + let serialized = [...instances.values()]; await lib.safeOutputFile(filePath, JSON.stringify(serialized, null, "\t")); } @@ -690,7 +688,6 @@ export default class Controller { * * @example * let instanceConfig = new lib.InstanceConfig("controller"); - * instanceConfig.init(); * instanceConfig.set("instance.name", "My instance"); * let instance = await controller.instanceAssign(instanceConfig); * await controller.instanceAssign(instance.id, hostId); @@ -806,7 +803,7 @@ export default class Controller { // "fieldChanged" event handler will set this.instancesDirty if (hostId !== undefined && newHostConnection) { await newHostConnection.send( - new lib.InstanceAssignInternalRequest(instanceId, instance.config.serialize("host")) + new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")) ); } else { instance.status = "unassigned"; @@ -855,7 +852,7 @@ export default class Controller { let connection = this.wsServer.hostConnections.get(hostId); if (connection) { await connection.send( - new lib.InstanceAssignInternalRequest(instance.id, instance.config.serialize("host")) + new lib.InstanceAssignInternalRequest(instance.id, instance.config.toRemote("host")) ); } } diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index d95198dc..037dabc8 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -189,7 +189,7 @@ export default class HostConnection extends BaseConnection { for (let instance of this._controller.instances.values()) { if (instance.config.get("instance.assigned_host") === this._id) { await this.send( - new lib.InstanceAssignInternalRequest(instance.id, instance.config.serialize("host")) + new lib.InstanceAssignInternalRequest(instance.id, instance.config.toRemote("host")) ); } } @@ -197,8 +197,8 @@ export default class HostConnection extends BaseConnection { // Assign instances the host has but controller does not const instanceUpdates: InstanceInfo[] = []; for (let instanceData of request.instances) { - let instanceConfig = new lib.InstanceConfig("controller"); - instanceConfig.load(instanceData.config as lib.SerializedConfig, "host"); + const instanceConfig = new lib.InstanceConfig("controller"); + instanceConfig.update(instanceData.config, false, "host"); let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id")); if (controllerInstance) { @@ -237,7 +237,7 @@ export default class HostConnection extends BaseConnection { this._controller.addInstanceHooks(newInstance); await this.send( new lib.InstanceAssignInternalRequest( - instanceConfig.get("instance.id"), instanceConfig.serialize("host") + instanceConfig.get("instance.id"), instanceConfig.toRemote("host") ) ); instanceUpdates.push(newInstance); diff --git a/packages/controller/src/InstanceInfo.ts b/packages/controller/src/InstanceInfo.ts index 320fa32c..e7db6cbf 100644 --- a/packages/controller/src/InstanceInfo.ts +++ b/packages/controller/src/InstanceInfo.ts @@ -18,30 +18,21 @@ export default class InstanceInfo { } static jsonSchema = Type.Object({ - "config": Type.Object({}), + "config": lib.InstanceConfig.jsonSchema, "status": lib.InstanceStatus, "gamePort": Type.Optional(Type.Number()), "updatedAt": Type.Optional(Type.Number()), }); - static fromJSON(json: Static, config: lib.InstanceConfig) { + static fromJSON(json: Static, location: lib.ConfigLocation) { return new this( - config, + lib.InstanceConfig.fromJSON(json.config, location), json.status, json.gamePort, json.updatedAt, ); } - toJSON(): Static { - return { - config: this.config.serialize(), - status: this.status, - gamePort: this.gamePort, - updatedAt: this.updatedAt, - }; - } - toInstanceDetails() { return new lib.InstanceDetails( this.config.get("instance.name"), diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index bf9e6175..8134964c 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -219,14 +219,15 @@ async function startControl() { ; logger.verbose(`Loading config from ${args.config}`); - let controlConfig = new lib.ControlConfig("control"); + let controlConfig; try { - controlConfig.load(JSON.parse(await fs.readFile(args.config, "utf8"))); + const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8")); + controlConfig = lib.ControlConfig.fromJSON(jsonConfig, "control"); } catch (err: any) { if (err.code === "ENOENT") { logger.verbose("Config not found, initializing new config"); - controlConfig.init(); + controlConfig = new lib.ControlConfig("control"); } else { throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`); diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 4cc5ca5c..9096680f 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -56,8 +56,8 @@ async function configToKeyVal(data: string) { return final; } -async function serializedConfigToString( - serializedConfig: any, +function serializedConfigToString( + serializedConfig: lib.SerializedConfig, configGroup: typeof lib.Config, disallowedList: Record, ) { @@ -91,9 +91,9 @@ const controllerConfigCommands = new lib.CommandTree({ controllerConfigCommands.add(new lib.Command({ definition: ["list", "List controller configuration"], handler: async function(args: object, control: Control) { - let response = await control.send(new lib.ControllerConfigGetRequest()); + let config = await control.send(new lib.ControllerConfigGetRequest()); - for (let group of (response.serializedConfig as any).groups) { + for (let group of config.groups) { for (let [name, value] of Object.entries(group.fields)) { print(`${group.name}.${name} ${JSON.stringify(value)}`); } @@ -156,15 +156,15 @@ controllerConfigCommands.add(new lib.Command({ }); }], handler: async function(args: { editor: string }, control: Control) { - let response = await control.send(new lib.ControllerConfigGetRequest()); + let config = await control.send(new lib.ControllerConfigGetRequest()); let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir()); let editor = await getEditor(args.editor); if (editor === undefined) { throw new lib.CommandError(`No editor avalible. Checked CLI input, EDITOR and VISUAL env vars Try "ctl controller config edit "`); } - let allConfigElements = await serializedConfigToString( - response.serializedConfig, lib.ControllerConfig, {} + let allConfigElements = serializedConfigToString( + config, lib.ControllerConfig, {} ); await fs.writeFile(tmpFile, allConfigElements); let editorSpawn = child_process.spawn(editor, [tmpFile], { @@ -272,11 +272,11 @@ hostCommands.add(new lib.Command({ args: { id?: number, name?: string, generateToken: boolean, output: string }, control: Control ) { - let rawConfig = await control.send( + let config = await control.send( new lib.HostConfigCreateRequest(args.id, args.name, args.generateToken) ); - let content = JSON.stringify(rawConfig.serializedConfig, null, "\t"); + let content = JSON.stringify(config, null, "\t"); if (args.output === "-") { print(content); } else { @@ -315,12 +315,11 @@ instanceCommands.add(new lib.Command({ }], handler: async function(args: { name: string, id?: number }, control: Control) { let instanceConfig = new lib.InstanceConfig("control"); - instanceConfig.init(); if (args.id !== undefined) { instanceConfig.set("instance.id", args.id); } instanceConfig.set("instance.name", args.name); - let serializedConfig = instanceConfig.serialize("controller"); + const serializedConfig = instanceConfig.toRemote("controller"); await control.send(new lib.InstanceCreateRequest(serializedConfig)); }, })); @@ -334,9 +333,9 @@ instanceConfigCommands.add(new lib.Command({ }], handler: async function(args: { instance: string }, control: Control) { let instanceId = await lib.resolveInstance(control, args.instance); - let response = await control.send(new lib.InstanceConfigGetRequest(instanceId)); + let config = await control.send(new lib.InstanceConfigGetRequest(instanceId)); - for (let group of response.serializedConfig.groups) { + for (let group of config.groups) { for (let [name, value] of Object.entries(group.fields)) { print(`${group.name}.${name} ${JSON.stringify(value)}`); } @@ -427,7 +426,7 @@ instanceConfigCommands.add(new lib.Command({ }], handler: async function(args: { instance: string, editor: string }, control: Control) { let instanceId = await lib.resolveInstance(control, args.instance); - let response = await control.send(new lib.InstanceConfigGetRequest(instanceId)); + let config = await control.send(new lib.InstanceConfigGetRequest(instanceId)); let tmpFile = await lib.getTempFile("ctl-", "-tmp", os.tmpdir()); let editor = await getEditor(args.editor); if (editor === undefined) { @@ -435,8 +434,8 @@ instanceConfigCommands.add(new lib.Command({ Try "ctl controller config edit "`); } let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0}; - let allConfigElements = await serializedConfigToString( - response.serializedConfig, + let allConfigElements = serializedConfigToString( + config, lib.InstanceConfig, disallowedList ); diff --git a/packages/host/host.ts b/packages/host/host.ts index 119183a5..867c1234 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -146,14 +146,15 @@ async function startHost() { lib.finalizeConfigs(); logger.info(`Loading config from ${args.config}`); - let hostConfig = new lib.HostConfig("host"); + let hostConfig; try { - hostConfig.load(JSON.parse(await fs.readFile(args.config, "utf8"))); + const jsonConfig = JSON.parse(await fs.readFile(args.config, "utf8")); + hostConfig = lib.HostConfig.fromJSON(jsonConfig, "host"); } catch (err: any) { if (err.code === "ENOENT") { logger.info("Config not found, initializing new config"); - hostConfig.init(); + hostConfig = new lib.HostConfig("host"); } else { throw new lib.StartupError(`Failed to load ${args.config}: ${err.message}`); diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index da047013..19a0c48f 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -47,7 +47,8 @@ async function discoverInstances(instancesDir: string) { let configPath = path.join(instancesDir, entry.name, "instance.json"); try { - instanceConfig.load(JSON.parse(await fs.readFile(configPath, "utf8"))); + const jsonConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + instanceConfig = lib.InstanceConfig.fromJSON(jsonConfig, "host"); } catch (err: any) { if (err.code === "ENOENT") { @@ -564,17 +565,17 @@ export default class Host extends lib.Link { let { instanceId, config } = request; let instanceInfo = this.instanceInfos.get(instanceId); if (instanceInfo) { - instanceInfo.config.update(config as any, true, "controller"); + instanceInfo.config.update(config, true, "controller"); logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instanceId, instanceInfo)); } else { instanceInfo = this.discoveredInstanceInfos.get(instanceId); if (instanceInfo) { - instanceInfo.config.update(config as any, true, "controller"); + instanceInfo.config.update(config, true, "controller"); } else { let instanceConfig = new lib.InstanceConfig("host"); - instanceConfig.load(config as any, "controller"); + instanceConfig.update(config, false, "controller"); let instanceDir = await this._createNewInstanceDir(instanceConfig.get("instance.name")); @@ -608,7 +609,7 @@ export default class Host extends lib.Link { // save a copy of the instance config let warnedOutput = { _warning: "Changes to this file will be overwritten by the controller's copy.", - ...instanceInfo.config.serialize(), + ...instanceInfo.config.toJSON(), }; await lib.safeOutputFile( path.join(instanceInfo.path, "instance.json"), @@ -920,7 +921,7 @@ export default class Host extends lib.Link { for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) { let instanceConnection = this.instanceConnections.get(instanceId); list.push(new lib.RawInstanceInfo( - instanceInfo.config.serialize("controller"), + instanceInfo.config.toRemote("controller"), instanceConnection ? instanceConnection.instance.status : "stopped", instanceConnection ? instanceConnection.instance.server.gamePort : undefined, )); @@ -984,7 +985,7 @@ export default class Host extends lib.Link { } logger.info("Saving config"); - await lib.safeOutputFile(this.configPath, JSON.stringify(this.config.serialize(), null, "\t")); + await lib.safeOutputFile(this.configPath, JSON.stringify(this.config, null, "\t")); try { // Clear silly interval in pidfile library. diff --git a/packages/lib/src/config/classes.ts b/packages/lib/src/config/classes.ts index 4c7d6c96..b513cd89 100644 --- a/packages/lib/src/config/classes.ts +++ b/packages/lib/src/config/classes.ts @@ -4,9 +4,12 @@ import events from "events"; import isDeepStrictEqual from "../is_deep_strict_equal"; import { basicType } from "../helpers"; +import * as libSchema from "../schema"; +import { StringEnum } from "../data"; -export type ConfigLocation = "controller" | "host" | "instance" | "control"; +const ConfigLocation = StringEnum(["controller", "host", "instance", "control"]); +export type ConfigLocation = Static; /** * Invalid Access exception @@ -59,8 +62,18 @@ function splitOn(separator: string, string: string) { return [string.slice(0, index), string.slice(index + separator.length)]; } -export type SerializedConfig = ReturnType; -type SerializedGroup = ReturnType; + +const ConfigGroupSchema = Type.Object({ + "name": Type.String(), + "fields": Type.Record(Type.String(), Type.Unknown()), +}); +type SerializedGroup = Static; + +const ConfigSchema = Type.Object({ + "groups": Type.Array(ConfigGroupSchema), +}); +export type SerializedConfig = Static + /** * Collection of config groups @@ -80,10 +93,10 @@ export class Config extends events.EventEmitter { declare static _finalized: boolean; declare static _initialized: boolean; declare static _groups: Map; + declare ["constructor"]: typeof Config; - _initialized = false; _groups = new Map(); - _unknownGroups = new Map(); + _unknownGroups = new Map>(); /** Set to true when a field in the config is changed. */ dirty = false; @@ -93,66 +106,64 @@ export class Config extends events.EventEmitter { * * @param location - * Location to evaluate access for this instance from + * @param groups - + * Serialized representation of groups to load. */ constructor( public location: ConfigLocation, + groups: Static["groups"] = [], ) { - super(); - - if (!(this.constructor as typeof Config)._finalized) { - throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`); - } - if (typeof location !== "string") { throw new Error("location must be a string"); } - } + super(); - init() { - for (let [name, GroupClass] of (this.constructor as typeof Config)._groups) { - if (!this._groups.has(name)) { - let group = new GroupClass(this); - group.init(); - this._groups.set(name, group); - } - } - this._initialized = true; - } - - _validate(serializedConfig: SerializedConfig) { - if (basicType(serializedConfig) !== "object") { - throw new Error(`Expected object, not ${basicType(serializedConfig)} for config`); + if (!this.constructor._finalized) { + throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`); } - if (basicType(serializedConfig.groups) !== "array") { - throw new Error(`Expected groups to be an array, not ${basicType(serializedConfig.groups)}`); - } - } - - /** - * Load config from a serialized object - * - * @param serializedConfig - Serialized config to load. - * @param location - Location used for access control. - */ - load( - serializedConfig: SerializedConfig, - location = this.location - ) { - this._validate(serializedConfig); - for (let serializedGroup of serializedConfig.groups) { - let GroupClass = (this.constructor as typeof Config)._groups.get(serializedGroup.name); + for (let serializedGroup of groups) { + let GroupClass = this.constructor._groups.get(serializedGroup.name); if (!GroupClass) { this._unknownGroups.set(serializedGroup.name, serializedGroup); continue; } - let group = new GroupClass(this); - group.load(serializedGroup, location); + let group = GroupClass.fromJSON(serializedGroup, this); this._groups.set(group.name, group); } - this.init(); + for (let [name, GroupClass] of this.constructor._groups) { + if (!this._groups.has(name)) { + this._groups.set(name, new GroupClass(this)); + } + } + } + + static jsonSchema = ConfigSchema; + + static validate = libSchema.compile(this.jsonSchema as any); + + /** + * Create config from a serialized object + * + * @param json - Serialized config to load. + * @param location - Location used for access control. + * @returns Instance of this config + */ + static fromJSON( + json: Static, + location: ConfigLocation, + ) { + const valid = Config.validate(json); + if (!valid) { + throw new Error("Invalid config"); + } + return new this(location, json.groups); + } + + toJSON() { + return this.toRemote(this.location); } /** @@ -161,10 +172,10 @@ export class Config extends events.EventEmitter { * @param location - Location used for access control. * @returns JSON serializable representation of the config. */ - serialize(location = this.location) { - let groups: SerializedGroup[] = [...this._unknownGroups.values()]; + toRemote(location: ConfigLocation): Static { + let groups: Static[] = [...this._unknownGroups.values()]; for (let group of this._groups.values()) { - groups.push(group.serialize(location)); + groups.push(group.toRemote(location)); } return { groups }; @@ -176,16 +187,19 @@ export class Config extends events.EventEmitter { * Updates all groups in the config with groups in the serialized * config passed. * - * @param serializedConfig - Output from .serialize(). + * @param serializedConfig - Output from .toRemote(). * @param notify - Invoke fieldChanged events if true. * @param location - Location used for access control. */ update( - serializedConfig: SerializedConfig, + serializedConfig: Static, notify: boolean, location = this.location ) { - this._validate(serializedConfig); + const valid = Config.validate(serializedConfig); + if (!valid) { + throw new Error("Invalid config"); + } for (let serializedGroup of serializedConfig.groups) { let group = this._groups.get(serializedGroup.name); if (group) { @@ -252,9 +266,6 @@ export class Config extends events.EventEmitter { * @returns config group. */ group(name: string) { - 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}'`); @@ -321,32 +332,6 @@ export class Config extends events.EventEmitter { } } -// TODO: remove after config refactor -export class RawConfig { - constructor( - public serializedConfig: SerializedConfig - ) { } - - static jsonSchema = Type.Object({ - "serializedConfig": Type.Object({ - "groups": Type.Array( - Type.Object({ - "name": Type.String(), - "fields": Type.Record( - Type.String(), - Type.Unknown(), - ), - }), - ), - }), - }); - - static fromJSON(json: Static) { - return new this(json.serializedConfig); - } -} - - type FieldType = "boolean" | "string" | "number" | "object"; export interface FieldDefinition { type: FieldType; @@ -376,6 +361,7 @@ export class ConfigGroup { declare static _definitions: Map; declare static groupName: string; declare static defaultAccess: ConfigLocation[]; + declare ["constructor"]: typeof ConfigGroup; _fields = new Map(); _unknownFields = new Map(); @@ -384,18 +370,41 @@ export 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. - * * @param config - * Parent config for this group instance. + * @param serializedGroup - + * Serialized representation of group to load. */ constructor( - public config: Config + public config: Config, + serializedGroup?: Static, ) { - if (!(this.constructor as typeof ConfigGroup)._finalized) { + 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)) { + continue; + } + + let value = null; + if (typeof def.initial_value === "function") { + value = def.initial_value(); + + } else if (def.initial_value !== undefined) { + value = def.initial_value; + } + + this._fields.set(name, value); + } } /** @@ -422,32 +431,7 @@ export class ConfigGroup { return true; } - /** - * Initialize new config group - * - * Computes and assigns the initial values for all fields of the - * config group. - */ - init() { - for (let [name, def] of (this.constructor as typeof ConfigGroup)._definitions) { - if (this._fields.has(name)) { - continue; - } - if (!def.access.includes(this.config.location)) { - continue; - } - - let value = null; - if (typeof def.initial_value === "function") { - value = def.initial_value(); - - } else if (def.initial_value !== undefined) { - value = def.initial_value; - } - - this._fields.set(name, value); - } - } + static jsonSchema = ConfigGroupSchema; /** * Load from serialized group @@ -456,13 +440,13 @@ export class ConfigGroup { * group and then initializes any possible missing fields from it to * their default values. * - * @param serializedGroup - - * Result from a previous call to .serialize(). - * @param location - Location used for access control. + * @param json - + * Result from a previous call to .toJSON(). + * @param config - Parent config for this group instance. + * @returns Instance of this group. */ - load(serializedGroup: SerializedGroup, location = this.config.location) { - this.update(serializedGroup, false, location); - this.init(); + static fromJSON(json: Static, config: Config) { + return new this(config, json); } /** @@ -471,7 +455,7 @@ export class ConfigGroup { * Shortcut for `group.constructor.groupName` */ get name() { - return (this.constructor as typeof ConfigGroup).groupName; + return this.constructor.groupName; } /** @@ -482,7 +466,7 @@ export class ConfigGroup { * @returns true if field is accessible */ canAccess(name: string, location = this.config.location) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name); + let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } @@ -497,7 +481,7 @@ export class ConfigGroup { * @returns Value stored for the field. */ get(name: string, location = this.config.location) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name); + let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } @@ -519,7 +503,7 @@ export class ConfigGroup { * @throws {InvalidValue} if value is not allowed for the field. */ set(name: string, value: unknown, location = this.config.location) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name); + let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } @@ -588,7 +572,7 @@ export class ConfigGroup { * @throws {InvalidValue} if field is not an object. */ setProp(name: string, prop: string, value: unknown, location = this.config.location) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name); + let def = this.constructor._definitions.get(name); if (!def) { throw new InvalidField(`No field named '${name}'`); } @@ -620,12 +604,12 @@ export class ConfigGroup { * has a valid value in the passed serialized group with that value. * * @param serializedGroup - - * Result from a previous call to .serialize(). + * Result from a previous call to .toRemote(). * @param notify - Invoke fieldChanged events if true. * @param location - Location used for access control. */ update( - serializedGroup: SerializedGroup, + serializedGroup: Static, notify: boolean, location = this.config.location ) { @@ -642,7 +626,7 @@ export class ConfigGroup { } for (let [name, value] of Object.entries(serializedGroup.fields)) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name); + let def = this.constructor._definitions.get(name); if (!def) { this._unknownFields.set(name, value); continue; @@ -674,16 +658,20 @@ export class ConfigGroup { } } + 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. */ - serialize(location: ConfigLocation = this.config.location) { + toRemote(location: ConfigLocation): Static { let fields: Record = {}; for (let [name, value] of this._fields) { - let def = (this.constructor as typeof ConfigGroup)._definitions.get(name)!; + let def = this.constructor._definitions.get(name)!; if (!this._checkAccess(def, location, false)) { continue; } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 098e8522..ad388a58 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -149,6 +149,7 @@ ControllerGroup.finalize(); * @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; @@ -255,6 +256,7 @@ HostGroup.finalize(); * @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; @@ -433,6 +435,8 @@ FactorioGroup.finalize(); * @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; diff --git a/packages/lib/src/data/composites.ts b/packages/lib/src/data/composites.ts index c4f912ef..ccf28b9f 100644 --- a/packages/lib/src/data/composites.ts +++ b/packages/lib/src/data/composites.ts @@ -1,4 +1,4 @@ -import { Type } from "@sinclair/typebox"; +import { Static, TSchema, Type } from "@sinclair/typebox"; export function StringEnum(values: [...T]) { return Type.Unsafe({ type: "string", enum: values }); @@ -32,3 +32,12 @@ export function jsonPrimitive(type: U) { export const JsonBoolean = jsonPrimitive("boolean"); export const JsonNumber = jsonPrimitive("number"); export const JsonString = jsonPrimitive("string"); + +export function plainJson(schema: T) { + return { + jsonSchema: schema, + fromJSON(json: Static) { + return json; + }, + }; +} diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index 5a0aefc8..d4a7d6f5 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -1,7 +1,7 @@ import { Type, Static } from "@sinclair/typebox"; -import { JsonString, StringEnum } from "./composites"; +import { JsonString, StringEnum, plainJson } from "./composites"; import { levels } from "../logging"; -import { RawConfig } from "../config"; +import { ControllerConfig, HostConfig } from "../config"; export class ControllerConfigGetRequest { declare ["constructor"]: typeof ControllerConfigGetRequest; @@ -9,7 +9,7 @@ export class ControllerConfigGetRequest { static src = "control" as const; static dst = "controller" as const; static permission = "core.controller.get_config" as const; - static Response = RawConfig; + static Response = plainJson(ControllerConfig.jsonSchema); } export class ControllerConfigSetFieldRequest { @@ -103,7 +103,7 @@ export class HostConfigCreateRequest { return new this(json.id, json.name, json.generateToken); } - static Response = RawConfig; + static Response = plainJson(HostConfig.jsonSchema); } export class LogSetSubscriptionsRequest { diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index dbc32be1..00af08e7 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -1,7 +1,7 @@ import { Type, Static } from "@sinclair/typebox"; import PlayerStats from "./PlayerStats"; -import { JsonString, StringEnum, jsonArray } from "./composites"; -import { RawConfig } from "../config"; +import { JsonString, StringEnum, jsonArray, plainJson } from "./composites"; +import { InstanceConfig } from "../config"; import type { IControllerUser } from "./User"; import type { MessageRequest } from "./messages_core"; import { CollectorResultSerialized } from "../prometheus"; @@ -133,15 +133,14 @@ export class InstanceCreateRequest { static permission = "core.instance.create" as const; constructor( - public config: object, + public config: Static, ) { } static jsonSchema = Type.Object({ - "config": Type.Object({}), + "config": InstanceConfig.jsonSchema, }); static fromJSON(json: Static) { - // TODO deserialise config here after config refactor return new this(json.config); } } @@ -152,8 +151,7 @@ export class InstanceConfigGetRequest { static src = "control" as const; static dst = "controller" as const; static permission = "core.instance.get_config" as const; - // TODO replace with InstanceConfig after config refactor - static Response = RawConfig; + static Response = plainJson(InstanceConfig.jsonSchema); constructor( public instanceId: number, @@ -716,15 +714,14 @@ export class InstanceSendRconRequest { // TODO remove this after config refactor export class RawInstanceInfo { - constructor( - public config: object, + public config: Static, public status: InstanceStatus, public gamePort: undefined | number, ) { } static jsonSchema = Type.Object({ - "config": Type.Object({}), + "config": InstanceConfig.jsonSchema, "status": StringEnum([ "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", ]), @@ -765,16 +762,15 @@ export class InstanceAssignInternalRequest { constructor( public instanceId: number, - public config: object, + public config: Static, ) { } static jsonSchema = Type.Object({ "instanceId": Type.Integer(), - "config": Type.Object({}), + "config": InstanceConfig.jsonSchema, }); static fromJSON(json: Static) { - // TODO deserialise config here after config refactor return new this(json.instanceId, json.config); } } diff --git a/packages/lib/src/shared_commands.ts b/packages/lib/src/shared_commands.ts index 801f54e0..fdc5a251 100644 --- a/packages/lib/src/shared_commands.ts +++ b/packages/lib/src/shared_commands.ts @@ -162,7 +162,7 @@ export async function handleConfigCommand( try { instance.set(args.field as string, args.value); - await libFileOps.safeOutputFile(configPath, JSON.stringify(instance.serialize(), null, "\t")); + await libFileOps.safeOutputFile(configPath, JSON.stringify(instance, null, "\t")); } catch (err) { if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) { logger.error(err.message); diff --git a/packages/web_ui/src/components/BaseConfigTree.tsx b/packages/web_ui/src/components/BaseConfigTree.tsx index 0a92fcb8..a3dcd638 100644 --- a/packages/web_ui/src/components/BaseConfigTree.tsx +++ b/packages/web_ui/src/components/BaseConfigTree.tsx @@ -48,8 +48,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) { async function updateConfig() { let serializedConfig = await props.retrieveConfig(); - let newConfig = new props.ConfigClass("control"); - newConfig.load(serializedConfig); + const newConfig = props.ConfigClass.fromJSON(serializedConfig, "control"); setConfig(newConfig); return newConfig; } @@ -197,12 +196,11 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {