diff --git a/.eslintrc.js b/.eslintrc.js index 8adba765..76d4a5b5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -136,7 +136,7 @@ module.exports = { "linebreak-style": ["error", "unix"], "lines-around-comment": "off", "lines-around-directive": "off", - "lines-between-class-members": ["error", "always"], + "lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }], "max-classes-per-file": "off", "max-depth": "error", "max-len": ["error", { "code": 120 }], diff --git a/docs/devs/json-serialisable-classes.md b/docs/devs/json-serialisable-classes.md new file mode 100644 index 00000000..ca152c2a --- /dev/null +++ b/docs/devs/json-serialisable-classes.md @@ -0,0 +1,119 @@ +# JSON Serialisable Classes + +To serialise and deserialise data the Clusterio codebase uses a JSON serialisable data class pattern. +Instances of these classes can be serialised to JSON by passing them directly to `JSON.serialise` and deserialised by passing the serialised JSON representation to its static `fromJSON` method. +The data class also has a static `jsonSchema` property describing the serialised JSON representation for validation purposes. +See the following example code for a hypothetical `Data` class with a single property `member` defaulting to a value of `"foo"`. + + +```js +// Data.js +"use strictc"; + +/** + * A Data class example + * @alias module:Data + */ +class Data { + /** @type {string} */ + member = "foo"; + + constructor(member) { + if (member !== undefined) { this.member = member; } + } + + static jsonSchema = { + type: "object", + properties: { + "member": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.member); + } + + // toJSON is called by JSON.stringify when serialising this class + toJSON() { + let json = {}; + // Omit property if it's set to the default value + if (this.member !== "foo") { json["member"] = this.member; } + return json; + } +} + +module.exports = Data; +``` + +Serialising an instance of `Data` for JSON is as simple as doing `serialised = JSON.stringify(data)` as the `toJSON` method is automatically called by `JSON.stringify` to create a suitable object. +Deserialising is done by calling the static `fromJSON` method with the serialised JSON data, like `data = Data.fromJSON(JSON.parse(serialised))`. + +By structuring the classes like this they can be easily composed together. +For example a class holding a data property with an instance of the Data class from the previous example can be created as follows. + +```js +// Composed.js +"use strict"; +const Data = require("./Data"); + +/** + * Composed data class example + * @alias module:Data + */ +class Composed { + /** @type {module:Data|undefined} */ + data; + + constructor(data) { + if (data) { this.data = data; } + } + + static jsonSchema = { + type: "object", + properties: { + "data": Data.jsonSchema, + }, + }; + + static fromJSON(json) { + let data; + if (json["data"]) { data = Data.fromJSON(json["data"]); } + return new this(data); + } + + // toJSON is optional and not needed for this particular class +} + +module.exports = Composed; +``` + +It's easy to create a new instance of Composed by calling `new Composed(new Data("spam"))` and serialising to and from JSON is the same as for Data. +Meaning `serialised = JSON.stringify(composed)` and `composed = Composed.fromJSON(JSON.parse(serialised))` does as expected. + +## Interface description + +Data classes are ES6 style class declarations with the following methods and fields: + +### toJSON(): any (optional) + +Method returning an object or primitive suitable for passing to JSON.stringify in order to create a JSON representation of the class instance. +The returned object may contain objects that have their own toJSON methods which JSON.stringify will then call recursively. + +This method is optional if calling `JSON.stringify` on an instance without it would produce the desired serialised form, however this is rarely the case as often you'll have to provide it to convert the values fram Map/Set properites, omit default values or do similar actions. + +Note that the primary use case for serialising data classes is to send them over the network, this method should therefore try to reduce the size of the resulting JSON by for example omitting properties who's values are the default value. + +### static fromJSON(json: any): this + +Method constructing an instance of this class from one that was previously serialised by passing an instance of it to `JSON.stringify`. + +While not a requirement it is preferrable if the input is not mutated and that no references to any any structures in the input ends up in the returned object. +In other words calling fromJSON twice with the same input should produce two independent objects. + +Note that directly passing the output of `toJSON` to `fromJSON` is not valid, as the output of `toJSON` may require further expansion calling `toJSON` on objects returned by it. +It has to go through `JSON.stringify` or an equivalent recursive expansion of `toJSON`. + +### static jsonSchema + +A [JSON schema](http://json-schema.org/) describing the format of the JSON resulting from passing an instance of this class to `JSON.stringify`. +This should be lenient towards adding additional properties that would be ignored by `fromJSON` as it makes it easier to extend these classes in the future without breaking compatibility. diff --git a/package.json b/package.json index 9e54a877..e5f81591 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "fast-cover": "FAST_TEST=y nyc pnpm test", "ci-cover": "nyc --reporter=lcovonly pnpm run-script test", "lint": "eslint \"*.js\" packages plugins test", + "lint-fix": "eslint --fix \"*.js\" packages plugins test", "docs": "jsdoc --configure docs/jsdoc.json", "bundle-dependencies": "bundle-dependencies" }, @@ -21,16 +22,16 @@ "node": ">=12" }, "devDependencies": { - "@clusterio/ctl": "workspace:*", - "@clusterio/lib": "workspace:*", "@clusterio/controller": "workspace:*", + "@clusterio/ctl": "workspace:*", "@clusterio/host": "workspace:*", + "@clusterio/lib": "workspace:*", "eslint": "^8.18.0", "eslint-plugin-node": "^11.1.0", "express": "^4.16.2", "form-data": "^4.0.0", "fs-extra": "^8.1.0", - "jsdoc": "^3.6.6", + "jsdoc": "^4.0.2", "jsonwebtoken": "^8.2.1", "jszip": "^3.2.2", "mocha": "^9.2.2", diff --git a/packages/controller/controller.js b/packages/controller/controller.js index 6eaa943d..dc9c1365 100755 --- a/packages/controller/controller.js +++ b/packages/controller/controller.js @@ -28,6 +28,7 @@ const jwt = require("jsonwebtoken"); // homebrew modules const libErrors = require("@clusterio/lib/errors"); const libFileOps = require("@clusterio/lib/file_ops"); +const libLink = require("@clusterio/lib/link"); const libPluginLoader = require("@clusterio/lib/plugin_loader"); const libPrometheus = require("@clusterio/lib/prometheus"); const libConfig = require("@clusterio/lib/config"); @@ -102,7 +103,7 @@ void new libPrometheus.Gauge( { labels: ["type"], callback: async function(gauge) { gauge.labels("host").set(controller.wsServer.hostConnections.size); - gauge.labels("control").set(controller.wsServer.controlConnections.length); + gauge.labels("control").set(controller.wsServer.controlConnections.size); }, }, ); @@ -290,6 +291,7 @@ async function initialize() { logger.info("Loading Plugin info"); parameters.pluginInfos = await libPluginLoader.loadPluginInfos(pluginList); + libLink.registerPluginMessages(parameters.pluginInfos); libConfig.registerPluginConfigGroups(parameters.pluginInfos); libConfig.finalizeConfigs(); diff --git a/packages/controller/src/BaseConnection.js b/packages/controller/src/BaseConnection.js index 3206b805..40fddbba 100644 --- a/packages/controller/src/BaseConnection.js +++ b/packages/controller/src/BaseConnection.js @@ -1,8 +1,10 @@ "use strict"; +const libData = require("@clusterio/lib/data"); const libErrors = require("@clusterio/lib/errors"); const libLink = require("@clusterio/lib/link"); const { logger } = require("@clusterio/lib/logging"); const libPlugin = require("@clusterio/lib/plugin"); +const ControllerRouter = require("./ControllerRouter"); /** @@ -12,68 +14,28 @@ const libPlugin = require("@clusterio/lib/plugin"); * @alias module:controller/src/BaseConnection */ class BaseConnection extends libLink.Link { - constructor(target, connector, controller) { + constructor(connector, controller) { /** @member {module:controller/src/WsServerConnector} module:controller/src/BaseConnection#connector */ - super("controller", target, connector); + super(connector); + this.router = new ControllerRouter(controller); this._controller = controller; - this._disconnecting = false; - libLink.attachAllMessages(this); - for (let controllerPlugin of this._controller.plugins.values()) { - libPlugin.attachPluginMessages(this, controllerPlugin); - } - } + for (let [Request, handler] of controller._registeredRequests) { this.handle(Request, handler); } + for (let [Request, handler] of controller._fallbackedRequests) { this.fallbackRequest(Request, handler); } + for (let [Event, handler] of controller._registeredEvents) { this.handle(Event, handler); } + for (let [Event, handler] of controller._snoopedEvents) { this.snoopEvent(Event, handler); } - async forwardRequestToInstance(message, request) { - return await this._controller.forwardRequestToInstance(request, message.data); - } - - async forwardEventToInstance(message, event) { - let instance = this._controller.instances.get(message.data.instance_id); - if (!instance) { return; } - - let hostId = instance.config.get("instance.assigned_host"); - if (hostId === null) { return; } - - let connection = this._controller.wsServer.hostConnections.get(hostId); - if (!connection || connection.closing) { return; } - if (event.plugin && !connection.plugins.has(event.plugin)) { return; } - - event.send(connection, message.data); - } - - async broadcastEventToHosts(message, event) { - for (let hostConnection of this._controller.wsServer.hostConnections.values()) { - // Do not broadcast back to the source - if (hostConnection === this) { continue; } - if (hostConnection.connector.closing) { continue; } - if (event.plugin && !hostConnection.plugins.has(event.plugin)) { continue; } - - event.send(hostConnection, message.data); - } - } - - async broadcastEventToInstance(message, event) { - await this.broadcastEventToHosts(message, event); - } - - async prepareDisconnectRequestHandler(message, request) { - await libPlugin.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this); - this._disconnecting = true; - this.connector.setClosing(); - return await super.prepareDisconnectRequestHandler(message, request); + this.handle(libData.ModPackGetRequest, this.handleModPackGetRequest.bind(this)); + this.handle(libData.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this)); } async disconnect(code, reason) { - this.connector.setClosing(); try { - await libLink.messages.prepareDisconnect.send(this); + await this.connector.disconnect(); } catch (err) { if (!(err instanceof libErrors.SessionLost)) { logger.error(`"Unexpected error preparing disconnect:\n${err.stack}`); } } - - await this.connector.close(code, reason); } /** @@ -82,19 +44,19 @@ class BaseConnection extends libLink.Link { * @type {boolean} */ get connected() { - return !this._disconnecting && this.connector.connected; + return this.connector.connected; } - async getModPackRequestHandler(message) { - let { id } = message.data; + async handleModPackGetRequest(request) { + let { id } = request; let modPack = this._controller.modPacks.get(id); if (!modPack) { throw new libErrors.RequestError(`Mod pack with ID ${id} does not exist`); } - return { mod_pack: modPack.toJSON() }; + return modPack; } - async getDefaultModPackRequestHandler(message) { + async handleModPackGetDefaultRequest() { let id = this._controller.config.get("controller.default_mod_pack_id"); if (id === null) { throw new libErrors.RequestError("Default mod pack not set on controller"); @@ -103,7 +65,7 @@ class BaseConnection extends libLink.Link { if (!modPack) { throw new libErrors.RequestError(`Default mod pack configured (${id}) does not exist`); } - return { mod_pack: modPack.toJSON() }; + return modPack; } } diff --git a/packages/controller/src/ControlConnection.js b/packages/controller/src/ControlConnection.js index de792ba5..de677f07 100644 --- a/packages/controller/src/ControlConnection.js +++ b/packages/controller/src/ControlConnection.js @@ -30,11 +30,12 @@ const queryLogTime = new libPrometheus.Summary( * @alias module:controller/src/ControlConnection */ class ControlConnection extends BaseConnection { - constructor(registerData, connector, controller, user) { - super("control", connector, controller); + constructor(registerData, connector, controller, user, id) { + super(connector, controller); this._agent = registerData.agent; this._version = registerData.version; + this.id = id; /** * The user making this connection. @@ -44,27 +45,27 @@ class ControlConnection extends BaseConnection { this.hostSubscriptions = { all: false, - host_ids: [], + hostIds: [], }; this.instanceSubscriptions = { all: false, - instance_ids: [], + instanceIds: [], }; this.saveListSubscriptions = { all: false, - instance_ids: [], + instanceIds: [], }; this.modPackSubscriptions = { all: false, - mod_pack_ids: [], + modPackIds: [], }; this.modSubscriptions = { all: false, - mod_names: [], + modNames: [], }; this.userSubscriptions = { @@ -76,8 +77,8 @@ class ControlConnection extends BaseConnection { this.logSubscriptions = { all: false, controller: false, - host_ids: [], - instance_ids: [], + hostIds: [], + instanceIds: [], }; this.ws_dumper = null; @@ -101,183 +102,289 @@ class ControlConnection extends BaseConnection { } }); } + + this.handle(libData.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this)); + this.handle(libData.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this)); + this.handle(libData.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this)); + this.handle(libData.HostListRequest, this.handleHostListRequest.bind(this)); + this.handle(libData.HostSetSubscriptionsRequest, this.handleHostSetSubscriptionsRequest.bind(this)); + this.handle(libData.HostGenerateTokenRequest, this.handleHostGenerateTokenRequest.bind(this)); + this.handle(libData.HostConfigCreateRequest, this.handleHostConfigCreateRequest.bind(this)); + this.handle(libData.InstanceDetailsGetRequest, this.handleInstanceDetailsGetRequest.bind(this)); + this.handle(libData.InstanceDetailsListRequest, this.handleInstanceDetailsListRequest.bind(this)); + this.handle( + libData.InstanceDetailsSetSubscriptionsRequest, this.handleInstanceDetailsSetSubscriptionsRequest.bind(this) + ); + this.handle(libData.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this)); + this.handle(libData.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this)); + this.handle(libData.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this)); + this.handle(libData.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this)); + this.handle(libData.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this)); + this.handle(libData.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this)); + this.handle( + libData.InstanceSetSaveListSubscriptionsRequest, + this.handleInstanceSetSaveListSubscriptionsRequest.bind(this) + ); + this.handle( + libData.InstanceRenameSaveRequest, this._controller.sendToHostByInstanceId.bind(this._controller) + ); + this.handle(libData.InstanceCopySaveRequest, this._controller.sendToHostByInstanceId.bind(this._controller)); + this.handle( + libData.InstanceDeleteSaveRequest, this._controller.sendToHostByInstanceId.bind(this._controller) + ); + this.handle(libData.InstanceDownloadSaveRequest, this.handleInstanceDownloadSaveRequest.bind(this)); + this.handle(libData.InstanceTransferSaveRequest, this.handleInstanceTransferSaveRequest.bind(this)); + this.handle(libData.ModPackListRequest, this.handleModPackListRequest.bind(this)); + this.handle(libData.ModPackSetSubscriptionsRequest, this.handleModPackSetSubscriptionsRequest.bind(this)); + this.handle(libData.ModPackCreateRequest, this.handleModPackCreateRequest.bind(this)); + this.handle(libData.ModPackUpdateRequest, this.handleModPackUpdateRequest.bind(this)); + this.handle(libData.ModPackDeleteRequest, this.handleModPackDeleteRequest.bind(this)); + this.handle(libData.ModGetRequest, this.handleModGetRequest.bind(this)); + this.handle(libData.ModListRequest, this.handleModListRequest.bind(this)); + this.handle(libData.ModSearchRequest, this.handleModSearchRequest.bind(this)); + this.handle(libData.ModSetSubscriptionsRequest, this.handleModSetSubscriptionsRequest.bind(this)); + this.handle(libData.ModDownloadRequest, this.handleModDownloadRequest.bind(this)); + this.handle(libData.ModDeleteRequest, this.handleModDeleteRequest.bind(this)); + this.handle(libData.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this)); + this.handle(libData.LogQueryRequest, this.handleLogQueryRequest.bind(this)); + this.handle(libData.PermissionListRequest, this.handlePermissionListRequest.bind(this)); + this.handle(libData.RoleListRequest, this.handleRoleListRequest.bind(this)); + this.handle(libData.RoleCreateRequest, this.handleRoleCreateRequest.bind(this)); + this.handle(libData.RoleUpdateRequest, this.handleRoleUpdateRequest.bind(this)); + this.handle( + libData.RoleGrantDefaultPermissionsRequest, this.handleRoleGrantDefaultPermissionsRequest.bind(this) + ); + this.handle(libData.RoleDeleteRequest, this.handleRoleDeleteRequest.bind(this)); + this.handle(libData.UserGetRequest, this.handleUserGetRequest.bind(this)); + this.handle(libData.UserListRequest, this.handleUserListRequest.bind(this)); + this.handle(libData.UserSetSubscriptionsRequest, this.handleUserSetSubscriptionsRequest.bind(this)); + this.handle(libData.UserCreateRequest, this.handleUserCreateRequest.bind(this)); + this.handle(libData.UserRevokeTokenRequest, this.handleUserRevokeTokenRequest.bind(this)); + this.handle(libData.UserUpdateRolesRequest, this.handleUserUpdateRolesRequest.bind(this)); + this.handle(libData.UserSetAdminRequest, this.handleUserSetAdminRequest.bind(this)); + this.handle(libData.UserSetBannedRequest, this.handleUserSetBannedRequest.bind(this)); + this.handle(libData.UserSetWhitelistedRequest, this.handleUserSetWhitelistedRequest.bind(this)); + this.handle(libData.UserDeleteRequest, this.handleUserDeleteRequest.bind(this)); + this.handle(libData.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); } - async getControllerConfigRequestHandler() { - return { serialized_config: this._controller.config.serialize("control") }; + validateIngress(message) { + let origin = this.connector.dst; + if (origin.type !== message.src.type || origin.id !== message.src.id) { + throw new libErrors.InvalidMessage(`Received message with invalid src ${message.src} from ${origin}`); + } } - async setControllerConfigFieldRequestHandler(message) { - this._controller.config.set(message.data.field, message.data.value, "control"); + validatePermission(message, entry) { + try { + this.checkPermission(message, entry); + } catch (err) { + this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src); + logger.audit(`Permission denied for ${message.name} by ${this.user.name} from ${this.connector.dst}`); + throw err; + } } - async setControllerConfigPropRequestHandler(message) { - let { field, prop, value } = message.data; + checkPermission(message, entry) { + let permission; + if (message.type === "request") { + permission = entry.Request.permission; + } else if (message.type === "event") { + permission = entry.Event.permission; + } else { + return; + } + + if (permission === null) { + return; + } + + if (typeof permission === "string") { + this.user.checkPermission(permission); + return; + } + + if (typeof permission === "function") { + permission(this.user, message); + return; + } + + throw new Error("Should be unreachable"); + } + + async handleControllerConfigGetRequest() { + return new libData.RawConfig(this._controller.config.serialize("control")); + } + + async handleControllerConfigSetFieldRequest(request) { + this._controller.config.set(request.field, request.value, "control"); + } + + async handleControllerConfigSetPropRequest(request) { + let { field, prop, value } = request; this._controller.config.setProp(field, prop, value, "control"); } - async listHostsRequestHandler(message) { + async handleHostListRequest() { let list = []; for (let host of this._controller.hosts.values()) { - list.push({ - agent: host.agent, - version: host.version, - id: host.id, - name: host.name, - public_address: host.public_address || null, - connected: this._controller.wsServer.hostConnections.has(host.id), - }); + list.push(new libData.HostDetails( + host.agent, + host.version, + host.name, + host.id, + this._controller.wsServer.hostConnections.has(host.id), + host.public_address, + )); } - return { list }; + return list; } - async setHostSubscriptionsRequestHandler(message) { - this.hostSubscriptions = message.data; + async handleHostSetSubscriptionsRequest(request) { + this.hostSubscriptions = { ...request }; } hostUpdated(host, update) { if ( this.hostSubscriptions.all - || this.hostSubscriptions.host_ids.includes(host.id) + || this.hostSubscriptions.hostIds.includes(host.id) ) { - libLink.messages.hostUpdate.send(this, update); + this.send(new libData.HostUpdateEvent(update)); } } - async generateHostTokenRequestHandler(message) { - let hostId = message.data.host_id; + async handleHostGenerateTokenRequest(message) { + let hostId = message.host_id; if (hostId === null) { hostId = Math.random() * 2**31 | 0; } - return { token: this._controller.generateHostToken(hostId) }; + return this._controller.generateHostToken(hostId); } - async createHostConfigRequestHandler(message) { + async handleHostConfigCreateRequest(request) { let hostConfig = new libConfig.HostConfig("control"); await hostConfig.init(); hostConfig.set("host.controller_url", this._controller.getControllerUrl()); - if (message.data.id !== null) { - hostConfig.set("host.id", message.data.id); + if (request.id !== null) { + hostConfig.set("host.id", request.id); } - if (message.data.name !== null) { - hostConfig.set("host.name", message.data.name); + if (request.name !== null) { + hostConfig.set("host.name", request.name); } - if (message.data.generate_token) { + if (request.generateToken) { this.user.checkPermission("core.host.generate_token"); hostConfig.set("host.controller_token", this._controller.generateHostToken(hostConfig.get("host.id"))); } - return { serialized_config: hostConfig.serialize() }; + return new libData.RawConfig(hostConfig.serialize()); } - async getInstanceRequestHandler(message) { - let id = message.data.id; - let instance = this._controller.getRequestInstance(id); + async handleInstanceDetailsGetRequest(request) { + let instance = this._controller.getRequestInstance(request.instanceId); - return { - id, - name: instance.config.get("instance.name"), - assigned_host: instance.config.get("instance.assigned_host"), - game_port: instance.game_port || null, - status: instance.status, - }; + return new libData.InstanceDetails( + instance.config.get("instance.name"), + instance.id, + instance.config.get("instance.assigned_host"), + instance.game_port || null, + instance.status, + ); } - async listInstancesRequestHandler(message) { + async handleInstanceDetailsListRequest() { let list = []; for (let instance of this._controller.instances.values()) { - list.push({ - id: instance.id, - name: instance.config.get("instance.name"), - assigned_host: instance.config.get("instance.assigned_host"), - game_port: instance.game_port || null, - status: instance.status, - }); + list.push(new libData.InstanceDetails( + instance.config.get("instance.name"), + instance.id, + instance.config.get("instance.assigned_host"), + instance.game_port || null, + instance.status, + )); } - return { list }; + return list; } - async setInstanceSubscriptionsRequestHandler(message) { - this.instanceSubscriptions = message.data; + async handleInstanceDetailsSetSubscriptionsRequest(request) { + this.instanceSubscriptions = { ...request }; } instanceUpdated(instance) { if ( this.instanceSubscriptions.all - || this.instanceSubscriptions.instance_ids.includes(instance.id) + || this.instanceSubscriptions.instanceIds.includes(instance.id) ) { - libLink.messages.instanceUpdate.send(this, { - id: instance.id, - name: instance.config.get("instance.name"), - assigned_host: instance.config.get("instance.assigned_host"), - game_port: instance.game_port || null, - status: instance.status, - }); + this.send(new libData.InstanceDetailsUpdateEvent( + new libData.InstanceDetails( + instance.config.get("instance.name"), + instance.id, + instance.config.get("instance.assigned_host"), + instance.game_port || null, + instance.status, + ) + )); } } // XXX should probably add a hook for host reuqests? - async createInstanceRequestHandler(message) { + async handleInstanceCreateRequest(request) { let instanceConfig = new libConfig.InstanceConfig("controller"); - await instanceConfig.load(message.data.serialized_config); + await instanceConfig.load(request.config); await this._controller.instanceCreate(instanceConfig); } - async deleteInstanceRequestHandler(message, request) { - await this._controller.instanceDelete(message.data.instance_id); + async handleInstanceDeleteRequest(request) { + await this._controller.instanceDelete(request.instanceId); } - async getInstanceConfigRequestHandler(message) { - let instance = this._controller.getRequestInstance(message.data.instance_id); - return { - serialized_config: instance.config.serialize("control"), - }; + async handleInstanceConfigGetRequest(request) { + let instance = this._controller.getRequestInstance(request.instanceId); + return new libData.InstanceConfigGetRequest.Response(instance.config.serialize("control")); } - async setInstanceConfigFieldRequestHandler(message) { - let instance = this._controller.getRequestInstance(message.data.instance_id); - if (message.data.field === "instance.assigned_host") { + async handleInstanceConfigSetFieldRequest(request) { + let instance = this._controller.getRequestInstance(request.instanceId); + if (request.field === "instance.assigned_host") { throw new libErrors.RequestError("instance.assigned_host must be set through the assign-host interface"); } - if (message.data.field === "instance.id") { + if (request.field === "instance.id") { // XXX is this worth implementing? It's race condition galore. throw new libErrors.RequestError("Setting instance.id is not supported"); } - instance.config.set(message.data.field, message.data.value, "control"); + instance.config.set(request.field, request.value, "control"); await this._controller.instanceConfigUpdated(instance); } - async setInstanceConfigPropRequestHandler(message) { - let instance = this._controller.getRequestInstance(message.data.instance_id); - let { field, prop, value } = message.data; + async handleInstanceConfigSetPropRequest(request) { + let instance = this._controller.getRequestInstance(request.instanceId); + let { field, prop, value } = request; instance.config.setProp(field, prop, value, "control"); await this._controller.instanceConfigUpdated(instance); } - async assignInstanceCommandRequestHandler(message, request) { - let { host_id, instance_id } = message.data; - await this._controller.instanceAssign(instance_id, host_id); + async handleInstanceAssignRequest(request) { + await this._controller.instanceAssign(request.instanceId, request.hostId); } - async setSaveListSubscriptionsRequestHandler(message) { - this.saveListSubscriptions = message.data; + async handleInstanceSetSaveListSubscriptionsRequest(request) { + this.saveListSubscriptions = { ...request }; } - saveListUpdate(data) { + saveListUpdate(instanceId, saves) { if ( this.saveListSubscriptions.all - || this.saveListSubscriptions.instance_ids.includes(data.instance_id) + || this.saveListSubscriptions.instanceIds.includes(instanceId) ) { - libLink.messages.saveListUpdate.send(this, data); + this.send(new libData.InstanceSaveListUpdateEvent(instanceId, saves)); } } - async downloadSaveRequestHandler(message) { - let { instance_id, save } = message.data; + async handleInstanceDownloadSaveRequest(request) { + let { instanceId, name } = request; let stream = await routes.createProxyStream(this._controller.app); - stream.filename = save; + stream.filename = name; let ready = new Promise((resolve, reject) => { stream.events.on("source", resolve); @@ -287,29 +394,22 @@ class ControlConnection extends BaseConnection { }); ready.catch(() => {}); - await this._controller.forwardRequestToInstance(libLink.messages.pushSave, { - instance_id, - stream_id: stream.id, - save, - }); + await this._controller.sendToHostByInstanceId(new libData.InstancePushSaveRequest( + instanceId, + stream.id, + name, + )); await ready; - return { stream_id: stream.id }; + return stream.id; } - async transferSaveRequestHandler(message, request) { - let { source_save, target_save, copy, instance_id, target_instance_id } = message.data; - if (copy) { - this.user.checkPermission("core.instance.save.copy"); - } else if (source_save !== target_save) { - this.user.checkPermission("core.instance.save.rename"); - } - - if (instance_id === target_instance_id) { + async handleInstanceTransferSaveRequest(request) { + if (request.sourceInstanceId === request.targetInstanceId) { throw new libErrors.RequestError("Source and target instance may not be the same"); } - let sourceInstance = this._controller.getRequestInstance(instance_id); - let targetInstance = this._controller.getRequestInstance(target_instance_id); + let sourceInstance = this._controller.getRequestInstance(request.sourceInstanceId); + let targetInstance = this._controller.getRequestInstance(request.targetInstanceId); let sourceHostId = sourceInstance.config.get("instance.assigned_host"); let targetHostId = targetInstance.config.get("instance.assigned_host"); if (sourceHostId === null) { @@ -321,7 +421,7 @@ class ControlConnection extends BaseConnection { // Let host handle request if source and target is on the same host. if (sourceHostId === targetHostId) { - return await this.forwardRequestToInstance(message, request); + return await this._controller.sendTo({ hostId: sourceHostId }, request); } // Check connectivity @@ -350,42 +450,40 @@ class ControlConnection extends BaseConnection { // Establish push from source host to stream, this is done first to // ensure the file size is known prior to the target host pull. await Promise.all([ - this._controller.forwardRequestToInstance(libLink.messages.pushSave, { - instance_id, - stream_id: stream.id, - save: source_save, - }), + this._controller.sendTo( + { hostId: sourceHostId }, + new libData.InstancePushSaveRequest(request.sourceInstanceId, stream.id, request.sourceName), + ), events.once(stream.events, "source"), ]); // Establish pull from target host to stream and wait for completion. - let result = await this._controller.forwardRequestToInstance(libLink.messages.pullSave, { - instance_id: target_instance_id, - stream_id: stream.id, - filename: target_save, - }); + let storedName = await this._controller.sendTo( + { hostId: targetHostId }, + new libData.InstancePullSaveRequest(request.targetInstanceId, stream.id, request.targetName), + ); // Delete source save if this is not a copy - if (!copy) { - await this._controller.forwardRequestToInstance(libLink.messages.deleteSave, { - instance_id, - save: source_save, - }); + if (!request.copy) { + await this._controller.sendTo( + { hostId: sourceHostId }, + new libData.InstanceDeleteSaveRequest(request.sourceInstanceId, request.sourceName), + ); } - return { save: result.save }; + return storedName; } - async listModPacksRequestHandler(message) { - return { list: [...this._controller.modPacks.values()].map(pack => pack.toJSON()) }; + async handleModPackListRequest() { + return [...this._controller.modPacks.values()]; } - async setModPackSubscriptionsRequestHandler(message) { - this.modPackSubscriptions = message.data; + async handleModPackSetSubscriptionsRequest(request) { + this.modPackSubscriptions = { ...request }; } - async createModPackRequestHandler(message) { - let modPack = new libData.ModPack(message.data.mod_pack); + async handleModPackCreateRequest(request) { + let modPack = request.modPack; if (this._controller.modPacks.has(modPack.id)) { throw new libErrors.RequestError(`Mod pack with ID ${modPack.id} already exist`); } @@ -393,8 +491,8 @@ class ControlConnection extends BaseConnection { this._controller.modPackUpdated(modPack); } - async updateModPackRequestHandler(message) { - let modPack = new libData.ModPack(message.data.mod_pack); + async handleModPackUpdateRequest(request) { + let modPack = request.modPack; if (!this._controller.modPacks.has(modPack.id)) { throw new libErrors.RequestError(`Mod pack with ID ${modPack.id} does not exist`); } @@ -402,8 +500,8 @@ class ControlConnection extends BaseConnection { this._controller.modPackUpdated(modPack); } - async deleteModPackRequestHandler(message) { - let { id } = message.data; + async handleModPackDeleteRequest(request) { + let { id } = request; let modPack = this._controller.modPacks.get(id); if (!modPack) { throw new libErrors.RequestError(`Mod pack with ID ${id} does not exist`); @@ -416,26 +514,24 @@ class ControlConnection extends BaseConnection { modPackUpdated(modPack) { if ( this.modPackSubscriptions.all - || this.modPackSubscriptions.mod_pack_ids.includes(modPack.id) + || this.modPackSubscriptions.modPackIds.includes(modPack.id) ) { - libLink.messages.modPackUpdate.send(this, { mod_pack: modPack.toJSON() }); + this.send(new libData.ModPackUpdateEvent(modPack)); } } - async getModRequestHandler(message) { - let { name, version } = message.data; + async handleModGetRequest(request) { + let { name, version } = request; let filename = `${name}_${version}.zip`; let mod = this._controller.mods.get(filename); if (!mod) { throw new libErrors.RequestError(`Mod ${filename} does not exist`); } - return { - mod: mod.toJSON(), - }; + return mod; } - async listModsRequestHandler(message) { - return { list: [...this._controller.mods.values()].map(mod => mod.toJSON()) }; + async handleModListRequest() { + return [...this._controller.mods.values()]; } static termsMatchesMod(terms, mod) { @@ -456,8 +552,8 @@ class ControlConnection extends BaseConnection { return true; } - async searchModsRequestHandler(message) { - let query = libHelpers.parseSearchString(message.data.query, { + async handleModSearchRequest(request) { + let query = libHelpers.parseSearchString(request.query, { name: "word", // version title: "word", @@ -471,7 +567,7 @@ class ControlConnection extends BaseConnection { // size sha1: "word", }); - let factorioVersion = message.data.factorio_version; + let factorioVersion = request.factorioVersion; let results = new Map(); for (let mod of this._controller.mods.values()) { @@ -497,7 +593,7 @@ class ControlConnection extends BaseConnection { } let resultList = [...results.values()]; - const sort = message.data.sort; + const sort = request.sort; if (sort) { const sorters = { name: (a, b) => strcmp(a.versions[0].name, b.versions[0].name), @@ -508,30 +604,30 @@ class ControlConnection extends BaseConnection { throw new libErrors.RequestError(`Invalid value for sort: ${sort}`); } resultList.sort(sorters[sort]); - let order = message.data.sort_order; + let order = request.sortOrder; if (order === "desc") { resultList.reverse(); } } - const page = message.data.page; - const pageSize = message.data.page_size || 10; + const page = request.page; + const pageSize = request.pageSize || 10; resultList = resultList.slice((page - 1) * pageSize, page * pageSize); return { - query_issues: query.issues, - page_count: Math.ceil(results.size / pageSize), - result_count: results.size, + queryIssues: query.issues, + pageCount: Math.ceil(results.size / pageSize), + resultCount: results.size, results: resultList, }; } - async setModSubscriptionsRequestHandler(message) { - this.modSubscriptions = message.data; + async handleModSetSubscriptionsRequest(request) { + this.modSubscriptions = { ...request }; } - async downloadModRequestHandler(message) { - let { name, version } = message.data; + async handleModDownloadRequest(request) { + let { name, version } = request; let filename = `${name}_${version}.zip`; let mod = this._controller.mods.get(filename); if (!mod) { @@ -545,30 +641,30 @@ class ControlConnection extends BaseConnection { stream.mime = "application/zip"; stream.size = mod.size; - return { stream_id: stream.id }; + return stream.id; } - async deleteModRequestHandler(message) { - await this._controller.deleteMod(message.data.name, message.data.version); + async handleModDeleteRequest(request) { + await this._controller.deleteMod(request.name, request.version); } - async setLogSubscriptionsRequestHandler(message) { - this.logSubscriptions = message.data; + async handleLogSetSubscriptionsRequest(request) { + this.logSubscriptions = { ...request }; this.updateLogSubscriptions(); } modUpdated(mod) { if ( this.modSubscriptions.all - || this.modSubscriptions.mod_names.includes(mod.name) + || this.modSubscriptions.modNames.includes(mod.name) ) { - libLink.messages.modUpdate.send(this, { mod: mod.toJSON() }); + this.send(new libData.ModUpdateEvent(mod)); } } updateLogSubscriptions() { - let { all, controller, host_ids, instance_ids } = this.logSubscriptions; - if (all || controller || host_ids.length || instance_ids.length) { + let { all, controller, hostIds, instanceIds } = this.logSubscriptions; + if (all || controller || hostIds.length || instanceIds.length) { if (!this.logTransport) { this.logTransport = new libLoggingUtils.LinkTransport({ link: this }); this._controller.clusterLogger.add(this.logTransport); @@ -581,57 +677,57 @@ class ControlConnection extends BaseConnection { } } - async queryLogRequestHandler(message) { + async handleLogQueryRequest(request) { let observeDuration = queryLogTime.startTimer(); - let { all, controller, host_ids, instance_ids } = message.data; + let { all, controller, hostIds, instanceIds } = request; let log; - if (!all && controller && !host_ids.length && !instance_ids.length) { - log = await this._controller.queryControllerLog(message.data); + if (!all && controller && !hostIds.length && !instanceIds.length) { + log = await this._controller.queryControllerLog(request); } else { - log = await this._controller.queryClusterLog(message.data); + log = await this._controller.queryClusterLog(request); } observeDuration(); return { log }; } - async listPermissionsRequestHandler(message) { + async handlePermissionListRequest() { let list = []; for (let permission of libUsers.permissions.values()) { - list.push({ - name: permission.name, - title: permission.title, - description: permission.description, - }); + list.push(new libData.RawPermission( + permission.name, + permission.title, + permission.description, + )); } - return { list }; + return list; } - async listRolesRequestHandler(message) { + async handleRoleListRequest() { let list = []; for (let role of this._controller.userManager.roles.values()) { - list.push({ - id: role.id, - name: role.name, - description: role.description, - permissions: [...role.permissions], - }); + list.push(new libData.RawRole( + role.id, + role.name, + role.description, + [...role.permissions], + )); } - return { list }; + return list; } - async createRoleRequestHandler(message) { + async handleRoleCreateRequest(request) { let lastId = Math.max.apply(null, [...this._controller.userManager.roles.keys()]); // Start at 5 to leave space for future default roles let id = Math.max(5, lastId+1); - this._controller.userManager.roles.set(id, new libUsers.Role({ id, ...message.data })); - return { id }; + this._controller.userManager.roles.set(id, new libUsers.Role({ id, ...request })); + return id; } - async updateRoleRequestHandler(message) { - let { id, name, description, permissions } = message.data; + async handleRoleUpdateRequest(request) { + let { id, name, description, permissions } = request; let role = this._controller.userManager.roles.get(id); if (!role) { throw new libErrors.RequestError(`Role with ID ${id} does not exist`); @@ -643,18 +739,18 @@ class ControlConnection extends BaseConnection { this._controller.rolePermissionsUpdated(role); } - async grantDefaultRolePermissionsRequestHandler(message) { - let role = this._controller.userManager.roles.get(message.data.id); + async handleRoleGrantDefaultPermissionsRequest(request) { + let role = this._controller.userManager.roles.get(request.id); if (!role) { - throw new libErrors.RequestError(`Role with ID ${message.data.id} does not exist`); + throw new libErrors.RequestError(`Role with ID ${request.id} does not exist`); } role.grantDefaultPermissions(); this._controller.rolePermissionsUpdated(role); } - async deleteRoleRequestHandler(message) { - let id = message.data.id; + async handleRoleDeleteRequest(request) { + let id = request.id; let role = this._controller.userManager.roles.get(id); if (!role) { throw new libErrors.RequestError(`Role with ID ${id} does not exist`); @@ -667,44 +763,48 @@ class ControlConnection extends BaseConnection { } } - async getUserRequestHandler(message) { - let name = message.data.name; + async handleUserGetRequest(request) { + let name = request.name; let user = this._controller.userManager.users.get(name); if (!user) { throw new libErrors.RequestError(`User ${name} does not exist`); } - return { - name: user.name, - roles: [...user.roles].map(role => role.id), - is_admin: user.isAdmin, - is_banned: user.isBanned, - is_whitelisted: user.isWhitelisted, - ban_reason: user.banReason, - instances: [...user.instances], - player_stats: user.playerStats, - instance_stats: [...user.instanceStats], - }; + return new libData.RawUser( + user.name, + [...user.roles].map(role => role.id), + [...user.instances], + user.isAdmin, + user.isBanned, + user.isWhitelisted, + user.banReason, + undefined, + user.playerStats, + [...user.instanceStats], + ); } - async listUsersRequestHandler(message) { + async handleUserListRequest() { let list = []; for (let user of this._controller.userManager.users.values()) { - list.push({ - name: user.name, - roles: [...user.roles].map(role => role.id), - is_admin: user.isAdmin, - is_banned: user.isBanned, - is_whitelisted: user.isWhitelisted, - instances: [...user.instances], - player_stats: user.playerStats, - }); + list.push(new libData.RawUser( + user.name, + [...user.roles].map(role => role.id), + [...user.instances], + user.isAdmin, + user.isBanned, + user.isWhitelisted, + undefined, + undefined, + user.playerStats, + undefined, + )); } - return { list }; + return list; } - async setUserSubscriptionsRequestHandler(message) { - this.userSubscriptions = message.data; + async handleUserSetSubscriptionsRequest(request) { + this.userSubscriptions = { ...request }; } userUpdated(user) { @@ -712,37 +812,39 @@ class ControlConnection extends BaseConnection { this.userSubscriptions.all || this.userSubscriptions.names.includes(user.name) ) { - libLink.messages.userUpdate.send(this, { - name: user.name, - roles: [...user.roles].map(role => role.id), - is_admin: user.isAdmin, - is_banned: user.isBanned, - is_whitelisted: user.isWhitelisted, - ban_reason: user.banReason, - instances: [...user.instances], - player_stats: user.playerStats, - instance_stats: [...user.instanceStats], - is_deleted: user.isDeleted, - }); + this.send(new libData.UserUpdateEvent( + new libData.RawUser( + user.name, + [...user.roles].map(role => role.id), + [...user.instances], + user.isAdmin, + user.isBanned, + user.isWhitelisted, + user.banReason, + user.isDeleted, + user.playerStats, + [...user.instanceStats], + ) + )); } } - async createUserRequestHandler(message) { - let user = this._controller.userManager.createUser(message.data.name); + async handleUserCreateRequest(request) { + let user = this._controller.userManager.createUser(request.name); this._controller.userUpdated(user); } - async revokeUserTokenRequestHandler(message) { - let user = this._controller.userManager.users.get(message.data.name); + async handleUserRevokeTokenRequest(request) { + let user = this._controller.userManager.users.get(request.name); if (!user) { - throw new libErrors.RequestError(`User '${message.data.name}' does not exist`); + throw new libErrors.RequestError(`User '${request.name}' does not exist`); } if (user.name !== this.user.name) { this.user.checkPermission("core.user.revoke_other_token"); } user.invalidateToken(); - for (let controlConnection of this._controller.wsServer.controlConnections) { + for (let controlConnection of this._controller.wsServer.controlConnections.values()) { if (controlConnection.user.name === user.name) { controlConnection.connector.terminate(); } @@ -750,14 +852,14 @@ class ControlConnection extends BaseConnection { this._controller.userUpdated(user); } - async updateUserRolesRequestHandler(message) { - let user = this._controller.userManager.users.get(message.data.name); + async handleUserUpdateRolesRequest(request) { + let user = this._controller.userManager.users.get(request.name); if (!user) { - throw new libErrors.RequestError(`User '${message.data.name}' does not exist`); + throw new libErrors.RequestError(`User '${request.name}' does not exist`); } let resolvedRoles = new Set(); - for (let roleId of message.data.roles) { + for (let roleId of request.roles) { let role = this._controller.userManager.roles.get(roleId); if (!role) { throw new libErrors.RequestError(`Role with ID ${roleId} does not exist`); @@ -771,8 +873,8 @@ class ControlConnection extends BaseConnection { this._controller.userUpdated(user); } - async setUserAdminRequestHandler(message) { - let { name, create, admin } = message.data; + async handleUserSetAdminRequest(request) { + let { name, create, admin } = request; let user = this._controller.userManager.users.get(name); if (!user) { if (create) { @@ -785,11 +887,11 @@ class ControlConnection extends BaseConnection { user.isAdmin = admin; this._controller.userUpdated(user); - this.broadcastEventToHosts({ data: { name, admin }}, libLink.messages.adminlistUpdate); + this._controller.sendTo("allInstances", new libData.InstanceAdminlistUpdateEvent(name, admin)); } - async setUserBannedRequestHandler(message) { - let { name, create, banned, reason } = message.data; + async handleUserSetBannedRequest(request) { + let { name, create, banned, reason } = request; let user = this._controller.userManager.users.get(name); if (!user) { if (create) { @@ -803,11 +905,11 @@ class ControlConnection extends BaseConnection { user.isBanned = banned; user.banReason = reason; this._controller.userUpdated(user); - this.broadcastEventToHosts({ data: { name, banned, reason }}, libLink.messages.banlistUpdate); + this._controller.sendTo("allInstances", new libData.InstanceBanlistUpdateEvent(name, banned, reason)); } - async setUserWhitelistedRequestHandler(message) { - let { name, create, whitelisted } = message.data; + async handleUserSetWhitelistedRequest(request) { + let { name, create, whitelisted } = request; let user = this._controller.userManager.users.get(name); if (!user) { if (create) { @@ -820,34 +922,35 @@ class ControlConnection extends BaseConnection { user.isWhitelisted = whitelisted; this._controller.userUpdated(user); - this.broadcastEventToHosts({ data: { name, whitelisted }}, libLink.messages.whitelistUpdate); + this._controller.sendTo("allInstances", new libData.InstanceWhitelistUpdateEvent(name, whitelisted)); } - async deleteUserRequestHandler(message) { - let user = this._controller.userManager.users.get(message.data.name); + async handleUserDeleteRequest(request) { + let name = request.name; + let user = this._controller.userManager.users.get(name); if (!user) { - throw new libErrors.RequestError(`User '${message.data.name}' does not exist`); + throw new libErrors.RequestError(`User '${name}' does not exist`); } user.isDeleted = true; - this._controller.userManager.users.delete(message.data.name); + this._controller.userManager.users.delete(name); this._controller.userUpdated(user); if (user.is_admin) { - this.broadcastEventToHosts({ data: { name, admin: false }}, libLink.messages.adminlistUpdate); + this._controller.sendTo("allInstances", new libData.InstanceAdminlistUpdateEvent(name, false)); } if (user.is_whitelisted) { - this.broadcastEventToHosts({ data: { name, whitelisted: false }}, libLink.messages.whitelistUpdate); + this._controller.sendTo("allInstances", new libData.InstanceWhitelistUpdateEvent(name, false)); } if (user.is_banned) { - this.broadcastEventToHosts({ data: { name, banned: false, reason: "" }}, libLink.messages.banlistUpdate); + this._controller.sendTo("allInstances", new libData.InstanceBanlistUpdateEvent(name, false, "")); } } - async debugDumpWsRequestHandler(message) { + async handleDebugDumpWsRequest(request) { this.ws_dumper = data => { if (this.connector.connected) { - libLink.messages.debugWsMessage.send(this, data); + this.send(new libData.DebugWsMessageEvent(data.direction, data.content)); } }; this.connector._socket.clusterio_ignore_dump = true; diff --git a/packages/controller/src/Controller.js b/packages/controller/src/Controller.js index 1da93e12..6d9ecaed 100644 --- a/packages/controller/src/Controller.js +++ b/packages/controller/src/Controller.js @@ -114,6 +114,11 @@ class Controller { this._state = "new"; this._shouldStop = false; + this._fallbackedRequests = new Map(); + this._registeredRequests = new Map(); + this._registeredEvents = new Map(); + this._snoopedEvents = new Map(); + this.devMiddleware = null; } @@ -413,7 +418,7 @@ class Controller { } return new Map(); } - return new Map(json.map(e => [e.id, new libData.ModPack(e)])); + return new Map(json.map(e => [e.id, libData.ModPack.fromJSON(e)])); } static async saveModPacks(filePath, modPacks) { @@ -559,16 +564,16 @@ class Controller { } hostUpdated(host) { - let update = { - agent: host.agent, - version: host.version, - id: host.id, - name: host.name, - public_address: host.public_address || null, - connected: this.wsServer.hostConnections.has(host.id), - }; + let update = new libData.HostDetails( + host.agent, + host.version, + host.name, + host.id, + this.wsServer.hostConnections.has(host.id), + host.public_address === null ? undefined : host.public_address, + ); - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } @@ -602,7 +607,8 @@ class Controller { * let instanceConfig = new libConfig.InstanceConfig("controller"); * await instanceConfig.init(); * instanceConfig.set("instance.name", "My instance"); - * await controller.createInstance(instanceConfig); + * let instance = await controller.instanceAssign(instanceConfig); + * await controller.instanceAssign(instance.id, hostId); * * @param {module:lib/config.InstanceConfig} instanceConfig - * Config to base newly created instance on. @@ -681,17 +687,16 @@ class Controller { if (currentAssignedHost !== null && hostId !== currentAssignedHost) { let oldHostConnection = this.wsServer.hostConnections.get(currentAssignedHost); if (oldHostConnection && !oldHostConnection.connector.closing) { - await libLink.messages.unassignInstance.send(oldHostConnection, { instance_id: instanceId }); + await oldHostConnection.send(new libData.InstanceUnassignInternalRequest(instanceId)); } } // Assign to target instance.config.set("instance.assigned_host", hostId); if (hostId !== null) { - await libLink.messages.assignInstance.send(newHostConnection, { - instance_id: instanceId, - serialized_config: instance.config.serialize("host"), - }); + await newHostConnection.send( + new libData.InstanceAssignInternalRequest(instanceId, instance.config.serialize("host")) + ); } else { instance.status = "unassigned"; this.instanceUpdated(instance); @@ -708,8 +713,9 @@ class Controller { */ async instanceDelete(instanceId) { let instance = this.getRequestInstance(instanceId); - if (instance.config.get("instance.assigned_host") !== null) { - await this.forwardRequestToInstance(libLink.messages.deleteInstance, { instance_id: instanceId }); + let hostId = instance.config.get("instance.assigned_host"); + if (hostId !== null) { + await this.sendTo({ hostId }, new libData.InstanceDeleteInternalRequest(instanceId)); } this.instances.delete(instanceId); @@ -733,10 +739,9 @@ class Controller { if (hostId !== null) { let connection = this.wsServer.hostConnections.get(hostId); if (connection) { - await libLink.messages.assignInstance.send(connection, { - instance_id: instance.id, - serialized_config: instance.config.serialize("host"), - }); + await connection.send( + new libData.InstanceAssignInternalRequest(instance.id, instance.config.serialize("host")) + ); } } } @@ -754,7 +759,7 @@ class Controller { } instanceUpdated(instance) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } @@ -763,18 +768,18 @@ class Controller { } } - saveListUpdate(data) { - for (let controlConnection of this.wsServer.controlConnections) { + saveListUpdate(instanceId, saves) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } - controlConnection.saveListUpdate(data); + controlConnection.saveListUpdate(instanceId, saves); } } modPackUpdated(modPack) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } @@ -785,7 +790,7 @@ class Controller { } modUpdated(mod) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } @@ -797,7 +802,7 @@ class Controller { } userUpdated(user) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.connector.closing) { continue; } @@ -812,15 +817,15 @@ class Controller { * @param {module:lib/users.User} user - User permisions updated for. */ userPermissionsUpdated(user) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.user === user) { - libLink.messages.accountUpdate.send(controlConnection, { - "roles": [...user.roles].map(r => ({ + controlConnection.send( + new libData.AccountUpdateEvent([...user.roles].map(r => ({ name: r.name, id: r.id, permissions: [...r.permissions], - })), - }); + }))) + ); } } } @@ -831,15 +836,17 @@ class Controller { * @param {module:lib/users.Role} role - Role permisions updated for. */ rolePermissionsUpdated(role) { - for (let controlConnection of this.wsServer.controlConnections) { + for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.user.roles.has(role)) { - libLink.messages.accountUpdate.send(controlConnection, { - "roles": [...controlConnection.user.roles].map(r => ({ - name: r.name, - id: r.id, - permissions: [...r.permissions], - })), - }); + controlConnection.send( + new libData.AccountUpdateEvent( + [...controlConnection.user.roles].map(r => ({ + name: r.name, + id: r.id, + permissions: [...r.permissions], + })) + ) + ); } } } @@ -964,35 +971,196 @@ class Controller { }; } + handle(Class, handler) { + if (Class.type === "request") { + this.handleRequest(Class, handler); + } else if (Class.type === "event") { + this.handleEvent(Class, handler); + } else { + throw new Error(`Class ${Class.name} has unrecognized type ${Class.type}`); + } + } + + handleRequest(Request, handler) { + if (!libLink.Link._requestsByClass.has(Request)) { + throw new Error(`Unregistered Request class ${Request.name}`); + } + if (this._registeredRequests.has(Request)) { + throw new Error(`Request ${Request.name} is already registered`); + } + this._registeredRequests.set(Request, handler); + } + + fallbackRequest(Request, handler) { + if (!libLink.Link._requestsByClass.has(Request)) { + throw new Error(`Unregistered Request class ${Request.name}`); + } + if (this._fallbackedRequests.has(Request)) { + throw new Error(`Request ${Request.name} is already fallbacked`); + } + this._fallbackedRequests.set(Request, handler); + } + + handleEvent(Event, handler) { + if (!libLink.Link._eventsByClass.has(Event)) { + throw new Error(`Unregistered Event class ${Event.name}`); + } + if (this._registeredEvents.has(Event)) { + throw new Error(`Event ${Event.name} is already registered`); + } + this._registeredEvents.set(Event, handler); + } + + snoopEvent(Event, handler) { + if (!libLink.Link._eventsByClass.has(Event)) { + throw new Error(`Unregistered Event class ${Event.name}`); + } + if (this._snoopedEvents.has(Event)) { + throw new Error(`Event ${Event.name} is already snooped`); + } + this._snoopedEvents.set(Event, handler); + } + /** - * Forward the given request to the host contaning the instance + * Send request or event * - * Finds the host which the instance with the given instance ID is - * currently assigned to and forwards the request to that instance. - * The request data must have an instance_id parameter. + * Routes the given request or event to the given destination. The + * destination argument supports address shorthands, see {@link + * module:lib/data.Address.fromShorthand} * - * @param {module:lib/link.Request} request - The request to send. - * @param {object} data - Data to pass with the request. - * @param {number} data.instance_id - - * ID of instance to send this request towards - * @returns {Promise} data returned from the request. + * @param {*|module:lib/data.Address} address - Where to send it. + * @param {*} requestOrEvent - The request or event to send. + * @returns {Promise<*>|undefined} + * Promise that resolves to the response if a request was sent or + * undefined if it was an event. */ - async forwardRequestToInstance(request, data) { - let instance = this.getRequestInstance(data.instance_id); + sendTo(address, requestOrEvent) { + let dst = libData.Address.fromShorthand(address); + if (requestOrEvent.constructor.type === "request") { + return this.sendRequest(requestOrEvent, dst); + } + if (requestOrEvent.constructor.type === "event") { + return this.sendEvent(requestOrEvent, dst); + } + throw Error(`Unknown type ${requestOrEvent.constructor.type}.`); + } + + async sendRequest(request, dst) { + let connection; + if (dst.type === libData.Address.controller) { + throw new Error("controller as dst is not supported."); + + } else if (dst.type === libData.Address.control) { + connection = this.wsServer.controlConnections.get(dst.id); + if (!connection) { + throw new libErrors.RequestError("Target control connection does not exist."); + } + + } else if (dst.type === libData.Address.instance) { + let instance = this.getRequestInstance(dst.id); + let hostId = instance.config.get("instance.assigned_host"); + if (hostId === null) { + throw new libErrors.RequestError("Instance is not assigned to a host"); + } + connection = this.wsServer.hostConnections.get(hostId); + if (!connection) { + throw new libErrors.RequestError("Host containing instance is not connected"); + } + + } else if (dst.type === libData.Address.host) { + connection = this.wsServer.hostConnections.get(dst.id); + if (!connection) { + throw new libErrors.RequestError("Host is not connected"); + } + + } else { + throw new Error(`Unknown address type ${dst.type}`); + } + + return connection.sendRequest(request, dst); + } + + sendEvent(event, dst) { + let connection; + if (dst.type === libData.Address.controller) { + throw new Error("controller as dst is not supported."); + + } else if (dst.type === libData.Address.control) { + connection = this.wsServer.controlConnections.get(dst.id); + + } else if (dst.type === libData.Address.instance) { + let instance = this.instances.get(dst.id); + if (!instance) { + return; + } + let hostId = instance.config.get("instance.assigned_host"); + if (hostId === null) { + return; + } + connection = this.wsServer.hostConnections.get(hostId); + + } else if (dst.type === libData.Address.host) { + connection = this.wsServer.hostConnections.get(dst.id); + + } else if (dst.type === libData.Address.broadcast) { + if (dst.id === libData.Address.control) { + for (let controlConnection of this.wsServer.controlConnections.values()) { + controlConnection.sendEvent(event, dst); + } + + } else if (dst.id === libData.Address.instance || dst.id === libData.Address.host) { + for (let hostConnection of this.wsServer.hostConnections.values()) { + hostConnection.sendEvent(event, dst); + } + + } else { + throw new Error(`Unexpected broacdast target ${dst.id}`); + } + return; + + } else { + throw new Error(`Unknown address type ${dst.type}`); + } + + if (connection) { + connection.sendEvent(event, dst); + } + } + + sendToHostByInstanceId(requestOrEvent) { + if (requestOrEvent.constructor.type === "request") { + return this.sendRequestToHostByInstanceId(requestOrEvent); + } + if (requestOrEvent.constructor.type === "event") { + return this.sendEventToHostByInstanceId(requestOrEvent); + } + throw Error(`Unknown type ${requestOrEvent.constructor.type}.`); + } + + async sendRequestToHostByInstanceId(request) { + let instance = this.getRequestInstance(request.instanceId); let hostId = instance.config.get("instance.assigned_host"); if (hostId === null) { throw new libErrors.RequestError("Instance is not assigned to a host"); } - let connection = this.wsServer.hostConnections.get(hostId); if (!connection) { throw new libErrors.RequestError("Host containing instance is not connected"); } - if (request.plugin && !connection.plugins.has(request.plugin)) { - throw new libErrors.RequestError(`Host containing instance does not have ${request.plugin} plugin`); - } + return await connection.send(request); + } - return await request.send(connection, data); + sendEventToHostByInstanceId(event) { + let instance = this.getRequestInstance(event.instanceId); + let hostId = instance.config.get("instance.assigned_host"); + if (hostId === null) { + return; + } + let connection = this.wsServer.hostConnections.get(hostId); + if (!connection) { + return; + } + connection.send(event); } } diff --git a/packages/controller/src/ControllerRouter.js b/packages/controller/src/ControllerRouter.js new file mode 100644 index 00000000..8bc29fcb --- /dev/null +++ b/packages/controller/src/ControllerRouter.js @@ -0,0 +1,127 @@ +"use strict"; +const libData = require("@clusterio/lib/data"); + +class ControllerRouter { + /** @type {module:controller/src/Controller} */ + controller; + + pendingRequests = new Map(); + + constructor(controller) { + this.controller = controller; + } + + addHostConnection(connection) { + this.hostConnections.set(connection.id, connection); + } + + addControlConnection(connection) { + this.controlConnections.set(connection.id, connection); + } + + /** + * @param {module:lib/link.Link} origin - Source link of the message. + * @param {module:lib/data.Message} message + * Link the message originated from. + * @param {boolean} hasFallback - true if fallback handling is available. + * @returns {boolean} true if the message was handled + */ + forwardMessage(origin, message, hasFallback) { + if (!["request", "response", "responseError", "event"].includes(message.type)) { + throw new Error(`Message type ${message.type} can't be forwarded`); + } + + let dst = message.dst; + let nextHop; + let msg; + if (dst.type === libData.Address.broadcast) { + this.broadcastMessage(origin, message); + return true; + } else if (dst.type === libData.Address.host) { + nextHop = this.controller.wsServer.hostConnections.get(dst.id); + if (!nextHop) { + msg = `Host ${dst.id} is offline`; + } + } else if (dst.type === libData.Address.instance) { + let instance = this.controller.instances.get(dst.id); + if (!instance) { + msg = `Instance ${dst.id} does not exist`; + } else { + let assignedHost = instance.config.get("instance.assigned_host"); + if (assignedHost === null) { + msg = `Instance ${dst.id} is not assigned a host`; + } else { + nextHop = this.controller.wsServer.hostConnections.get(assignedHost); + if (!nextHop) { + msg = `Assigned host for instance ${dst.id} is offline`; + } + } + } + } else if (dst.type === libData.Address.control) { + nextHop = this.controller.wsServer.controlConnections.get(dst.id); + if (!nextHop) { + msg = "Control connection does not exist"; + } + } else if (dst.type === libData.Address.controller) { + msg = `Unexpected message forwarded to ${dst}`; + } + + if (nextHop === origin) { + msg = `Message would return back to sender ${origin.dst}.`; + nextHop = undefined; + } + + if (message.type === "request") { + if (!nextHop) { + if (hasFallback) { + return false; + } + origin.connector.sendResponseError( + new libData.ResponseError(msg || "Unroutable destination"), message.src + ); + return true; + } + } + + if (nextHop) { + if (message.type === "request") { + nextHop.forwardRequest(message, origin); + } else { + nextHop.connector.send(message); + } + } else { + this.warnUnrouted(message, msg); + } + + return true; + } + + broadcastMessage(origin, message) { + let dst = message.dst; + if (message.type !== "event") { + this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`); + } else if (dst.id === libData.Address.host || dst.id === libData.Address.instance) { + for (let hostConnection of this.controller.wsServer.hostConnections.values()) { + if (hostConnection !== origin) { + hostConnection.connector.send(message); + } + } + } else if (dst.id === libData.Address.control) { + for (let controlConnection of this.controller.wsServer.controlConnections.values()) { + if (controlConnection !== origin) { + controlConnection.connector.send(message); + } + } + } else { + this.warnUnrouted(message, `Unexpected broacdast target ${dst.id}`); + } + } + + warnUnrouted(message, msg) { + let dst = message.dst; + let baseMsg = `No destination for ${message.constructor.name} routed from ${message.src} to ${dst}`; + logger.warn(msg ? `${baseMsg}: ${msg}.` : `${baseMsg}.`); + } +} + +module.exports = ControllerRouter; diff --git a/packages/controller/src/HostConnection.js b/packages/controller/src/HostConnection.js index c37830a8..232d7c7c 100644 --- a/packages/controller/src/HostConnection.js +++ b/packages/controller/src/HostConnection.js @@ -1,6 +1,7 @@ "use strict"; +const libData = require("@clusterio/lib/data"); const libConfig = require("@clusterio/lib/config"); -const libLink = require("@clusterio/lib/link"); +const libErrors = require("@clusterio/lib/errors"); const { logger } = require("@clusterio/lib/logging"); const libPlugin = require("@clusterio/lib/plugin"); const PlayerStats = require("@clusterio/lib/PlayerStats"); @@ -17,7 +18,7 @@ const InstanceInfo = require("./InstanceInfo"); */ class HostConnection extends BaseConnection { constructor(registerData, connector, controller) { - super("host", connector, controller); + super(connector, controller); this._agent = registerData.agent; this._id = registerData.id; @@ -31,7 +32,7 @@ class HostConnection extends BaseConnection { id: this._id, name: this._name, version: this._version, - public_address: registerData.public_address, + public_address: registerData.publicAddress, plugins: registerData.plugins, }); @@ -57,6 +58,41 @@ class HostConnection extends BaseConnection { libPlugin.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); } }); + + this.handle(libData.InstanceStatusChangedEvent, this.handleInstanceStatusChangedEvent.bind(this)); + this.handle(libData.InstancesUpdateRequest, this.handleInstancesUpdateRequest.bind(this)); + this.handle(libData.InstanceSaveListUpdateEvent, this.handleInstanceSaveListUpdateEvent.bind(this)); + this.handle(libData.LogMessageEvent, this.handleLogMessageEvent.bind(this)); + this.handle(libData.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this)); + } + + validateIngress(message) { + let origin = this.connector.dst; + switch (message.src.type) { + case libData.Address.control: + case libData.Address.controller: + throw new libErrors.InvalidMessage(`Received message with invalid src ${message.src} from ${origin}`); + + case libData.Address.host: + if (message.src.id !== origin.id) { + throw new libErrors.InvalidMessage( + `Received message with invalid src ${message.src} from ${origin}` + ); + } + break; + + case libData.Address.instance: + let instance = this._controller.instances.get(message.src.id); + if (!instance || instance.config.get("instance.assigned_host") !== this._id) { + throw new libErrors.InvalidMessage( + `Received message with invalid src ${message.src} from ${origin}` + ); + } + break; + + default: + throw new Error("Should be unreachable"); + } } _checkPluginVersions() { @@ -83,6 +119,11 @@ class HostConnection extends BaseConnection { } } + async prepareDisconnect() { + await libPlugin.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this); + return await super.prepareDisconnect(); + } + /** * ID of the host this connection is connected to * @@ -92,15 +133,15 @@ class HostConnection extends BaseConnection { return this._id; } - async instanceStatusChangedEventHandler(message, event) { - let instance = this._controller.instances.get(message.data.instance_id); + async handleInstanceStatusChangedEvent(request) { + let instance = this._controller.instances.get(request.instanceId); // It's possible to get updates from an instance that does not exist // or is not assigned to the host it originated from if it was // reassigned or deleted while the connection to the host it was // originally on was down at the time. if (!instance || instance.config.get("instance.assigned_host") !== this._id) { - logger.warn(`Got bogus update for instance id ${message.data.instance_id}`); + logger.warn(`Got bogus update for instance id ${request.instanceId}`); return; } @@ -109,41 +150,40 @@ class HostConnection extends BaseConnection { // changed. Hosts also send status updates on assignInstance which // for hacky reason is also used to push config changes and // restablish status after a connection loss. - if (instance.status === message.data.status) { + if (instance.status === request.status) { return; } let prev = instance.status; - instance.status = message.data.status; - instance.game_port = message.data.game_port; + instance.status = request.status; + instance.game_port = request.gamePort; logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`); this._controller.instanceUpdated(instance); await libPlugin.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); } - async updateInstancesRequestHandler(message) { + async handleInstancesUpdateRequest(request) { // Push updated instance configs for (let instance of this._controller.instances.values()) { if (instance.config.get("instance.assigned_host") === this._id) { - await libLink.messages.assignInstance.send(this, { - instance_id: instance.id, - serialized_config: instance.config.serialize("host"), - }); + await this.send( + new libData.InstanceAssignInternalRequest(instance.id, instance.config.serialize("host")) + ); } } // Assign instances the host has but controller does not - for (let instanceData of message.data.instances) { + for (let instanceData of request.instances) { let instanceConfig = new libConfig.InstanceConfig("controller"); - await instanceConfig.load(instanceData.serialized_config, "host"); + await instanceConfig.load(instanceData.config, "host"); let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id")); if (controllerInstance) { // Check if this instance is assigned somewhere else. if (controllerInstance.config.get("instance.assigned_host") !== this._id) { - await libLink.messages.unassignInstance.send(this, { - instance_id: controllerInstance.id, - }); + await this.send( + new libData.InstanceUnassignInternalRequest(instanceConfig.get("instance.id")) + ); continue; } @@ -152,7 +192,7 @@ class HostConnection extends BaseConnection { let prev = controllerInstance.status; controllerInstance.status = instanceData.status; logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`); - this._controller.instanceUpdated(instance); + this._controller.instanceUpdated(controllerInstance); await libPlugin.invokeHook( this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev ); @@ -164,10 +204,11 @@ class HostConnection extends BaseConnection { let newInstance = new InstanceInfo({ config: instanceConfig, status: instanceData.status }); this._controller.instances.set(instanceConfig.get("instance.id"), newInstance); this._controller.addInstanceHooks(newInstance); - await libLink.messages.assignInstance.send(this, { - instance_id: instanceConfig.get("instance.id"), - serialized_config: instanceConfig.serialize("host"), - }); + await this.send( + new libData.InstanceAssignInternalRequest( + instanceConfig.get("instance.id"), instanceConfig.serialize("host") + ) + ); await libPlugin.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance, null); } @@ -188,40 +229,43 @@ class HostConnection extends BaseConnection { } } - libLink.messages.syncUserLists.send(this, { adminlist, banlist, whitelist }); + await this.send(new libData.SyncUserListsEvent(adminlist, banlist, whitelist)); } - async saveListUpdateEventHandler(message) { - this._controller.saveListUpdate(message.data); + async handleInstanceSaveListUpdateEvent(event) { + this._controller.saveListUpdate(event.instanceId, event.saves); } - async logMessageEventHandler(message) { + async handleLogMessageEvent(event) { this._controller.clusterLogger.log({ - ...message.data.info, + ...event.info, host_id: this._id, host_name: this._name, }); } - async playerEventEventHandler(message) { - let { instance_id, name, type, stats } = message.data; - let user = this._controller.userManager.users.get(name); + async handleInstancePlayerUpdateEvent(event, src) { + let instanceId = src.id; + let user = this._controller.userManager.users.get(event.name); if (!user) { - user = this._controller.userManager.createUser(name); + user = this._controller.userManager.createUser(event.name); } - if (type === "join") { - user.notifyJoin(instance_id); - } else if (type === "leave") { - user.notifyLeave(instance_id); + if (event.type === "join") { + user.notifyJoin(instanceId); + } else if (event.type === "leave") { + user.notifyLeave(instanceId); } - user.instanceStats.set(instance_id, new PlayerStats(stats)); + user.instanceStats.set(instanceId, new PlayerStats(event.stats)); user.recalculatePlayerStats(); this._controller.userUpdated(user); - delete message.data.stats; - let instance = this._controller.instances.get(instance_id); - await libPlugin.invokeHook(this._controller.plugins, "onPlayerEvent", instance, message.data); + let instance = this._controller.instances.get(instanceId); + await libPlugin.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { + type: event.type, + name: event.name, + reason: event.reason, + }); } } diff --git a/packages/controller/src/InstanceInfo.js b/packages/controller/src/InstanceInfo.js index 090f00e5..ef0cd66b 100644 --- a/packages/controller/src/InstanceInfo.js +++ b/packages/controller/src/InstanceInfo.js @@ -26,11 +26,9 @@ class InstanceInfo { */ status; - // This is not actually json as a config can't be loaded synchronously but - // that is likely to change in the future. - constructor(json) { - this.config = json["config"]; - this.status = json["status"]; + constructor({ config, status }) { + this.config = config; + this.status = status; } toJSON() { diff --git a/packages/controller/src/WsServer.js b/packages/controller/src/WsServer.js index bad09f17..483fca5c 100644 --- a/packages/controller/src/WsServer.js +++ b/packages/controller/src/WsServer.js @@ -2,10 +2,10 @@ const jwt = require("jsonwebtoken"); const WebSocket = require("ws"); +const libData = require("@clusterio/lib/data"); const libErrors = require("@clusterio/lib/errors"); const { logger } = require("@clusterio/lib/logging"); const libPrometheus = require("@clusterio/lib/prometheus"); -const libSchema = require("@clusterio/lib/schema"); const ControlConnection = require("./ControlConnection"); const packageVersion = require("../package").version; @@ -40,7 +40,7 @@ class WsServer { this.stopAcceptingNewSessions = false; /** @type {Array} */ - this.controlConnections = []; + this.controlConnections = new Map(); /** @type {Map} */ this.hostConnections = new Map(); /** @type {Map} */ @@ -51,6 +51,7 @@ class WsServer { // Unique string for the session token audience this.sessionAud = `session-${Date.now()}`; this.nextSessionId = 1; + this.nextControlId = 1; /** @type {module:ws.Server} */ this.wss = new WebSocket.Server({ @@ -64,7 +65,7 @@ class WsServer { this.stopAcceptingNewSessions = true; let disconnectTasks = []; - for (let controlConnection of this.controlConnections) { + for (let controlConnection of this.controlConnections.values()) { disconnectTasks.push(controlConnection.disconnect(1001, "Server Quit")); } @@ -122,11 +123,7 @@ class WsServer { loadedPlugins[name] = plugin.info.version; } - socket.send(JSON.stringify({ seq: null, type: "hello", data: { - version: packageVersion, - plugins: loadedPlugins, - }})); - + socket.send(JSON.stringify(new libData.MessageHello(new libData.HelloData(packageVersion, loadedPlugins)))); this.attachHandler(socket, req); } @@ -159,49 +156,31 @@ ${err.stack}` }); } - async handleHandshake(message, socket, req) { + async handleHandshake(rawMessage, socket, req) { + let message; try { - message = JSON.parse(message); + const json = JSON.parse(rawMessage); + libData.Message.validate(json); + message = libData.Message.fromJSON(json); } catch (err) { - logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after receiving invalid JSON`); + logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after receiving invalid message`); wsRejectedConnectionsCounter.inc(); - socket.close(1002, "Invalid JSON"); + socket.close(1002, `Invalid message: ${err.message}`); return; } - if (!libSchema.clientHandshake(message)) { + if (message.type === "resume") { + this.resumeSession(message.data, socket, req); + return; + } + + if (message.type !== "registerHost" && message.type !== "registerControl") { logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after receiving invalid handshake`); wsRejectedConnectionsCounter.inc(); socket.close(1002, "Bad handshake"); return; } - let { type, data } = message; - - if (type === "resume") { - let connector; - try { - let payload = jwt.verify( - data.session_token, - Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), - { audience: this.sessionAud } - ); - - connector = this.activeConnectors.get(payload.sid); - if (!connector) { - throw new Error(); - } - - } catch (err) { - socket.send(JSON.stringify({ seq: null, type: "invalidate", data: {}})); - this.attachHandler(socket, req); - return; - } - - connector.continue(socket, data.last_seq); - return; - } - if (this.stopAcceptingNewSessions) { logger.verbose(`WsServer | closing ${req.socket.remoteAddress}, server is shutting down`); wsRejectedConnectionsCounter.inc(); @@ -209,46 +188,20 @@ ${err.stack}` return; } - // Validate token - let user; - try { - if (type === "register_host") { - let tokenPayload = jwt.verify( - data.token, - Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), - // migrate: allow pre-rename tokens issued to hosts before alpha-14 - { audience: ["host", "slave"] } - ); - - // migrate: allow pre-rename tokens issued to hosts before alpha-14 - if ((tokenPayload.host !== undefined ? tokenPayload.host : tokenPayload.slave) !== data.id) { - throw new Error("missmatched host id"); - } - - } else if (type === "register_control") { - let tokenPayload = jwt.verify( - data.token, - Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), - { audience: "user" } - ); - - user = this.controller.userManager.users.get(tokenPayload.user); - if (!user) { - throw new Error("invalid user"); - } - if (tokenPayload.iat < user.tokenValidAfter) { - throw new Error("invalid token"); - } - user.checkPermission("core.control.connect"); - } - - } catch (err) { - logger.verbose(`WsServer | authentication failed for ${req.socket.remoteAddress}`); - wsRejectedConnectionsCounter.inc(); - socket.close(4003, `Authentication failed: ${err.message}`); + if (message.type === "registerHost") { + await this.registerHost(message.data, socket, req); return; } + if (message.type === "registerControl") { + await this.registerControl(message.data, socket, req); + return; + } + + throw new Error("This should be unreachable"); + } + + createSession(dst) { let sessionId = this.nextSessionId; this.nextSessionId += 1; let sessionToken = jwt.sign( @@ -257,66 +210,137 @@ ${err.stack}` ); let sessionTimeout = this.controller.config.get("controller.session_timeout"); let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval"); - let connector = new WsServerConnector(sessionId, sessionTimeout, heartbeatInterval); + let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval); this.activeConnectors.set(sessionId, connector); connector.on("close", () => { this.activeConnectors.delete(sessionId); }); - let additionalReadyData = {}; - if (type === "register_host") { - let connection = this.hostConnections.get(data.id); - if (connection) { - logger.verbose(`WsServer | disconnecting existing connection for host ${data.id}`); - await connection.disconnect(1008, "Registered from another connection"); - } + return [connector, sessionToken]; + } - logger.info( - `WsServer | registered host ${data.name} (${data.id}) using agent ${data.agent} ${data.version}` + async registerHost(data, socket, req) { + try { + let tokenPayload = jwt.verify( + data.token, + Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), + // migrate: allow pre-rename tokens issued to hosts before alpha-14 + { audience: ["host", "slave"] } ); - if (data.agent === "Clusterio Host" && data.version !== packageVersion) { - logger.warn( - `Host ${data.name} (${data.id}) connected using version ${data.version} which does not match the ` + - `version of the controller is currently running (${packageVersion}). It may not work as expected.` - ); + + // migrate: allow pre-rename tokens issued to hosts before alpha-14 + if ((tokenPayload.host !== undefined ? tokenPayload.host : tokenPayload.slave) !== data.id) { + throw new Error("missmatched host id"); } - - connection = new HostConnection(data, connector, this.controller); - connector.on("close", () => { - if (this.hostConnections.get(data.id) === connection) { - this.hostConnections.delete(data.id); - this.controller.hostUpdated(this.controller.hosts.get(data.id)); - } else { - logger.warn("Unlisted HostConnection closed"); - } - }); - this.hostConnections.set(data.id, connection); - this.controller.hostUpdated(this.controller.hosts.get(data.id)); - - - } else if (type === "register_control") { - logger.verbose(`WsServer | registered control from ${req.socket.remoteAddress}`); - let connection = new ControlConnection(data, connector, this.controller, user); - connector.on("close", () => { - let index = this.controlConnections.indexOf(connection); - if (index !== -1) { - this.controlConnections.splice(index, 1); - } else { - logger.warn("Unlisted ControlConnection closed"); - } - }); - this.controlConnections.push(connection); - additionalReadyData["account"] = { - name: user.name, - roles: [...user.roles].map(r => ({ - name: r.name, - id: r.id, - permissions: [...r.permissions], - })), - }; + } catch (err) { + logger.verbose(`WsServer | authentication failed for ${req.socket.remoteAddress}`); + wsRejectedConnectionsCounter.inc(); + socket.close(4003, `Authentication failed: ${err.message}`); + return; } - connector.ready(socket, sessionToken, additionalReadyData); + let [connector, sessionToken] = this.createSession(new libData.Address(libData.Address.host, data.id)); + let connection = this.hostConnections.get(data.id); + if (connection) { + logger.verbose(`WsServer | disconnecting existing connection for host ${data.id}`); + await connection.disconnect(1008, "Registered from another connection"); + } + + logger.info( + `WsServer | registered host ${data.name} (${data.id}) using agent ${data.agent} ${data.version}` + ); + if (data.agent === "Clusterio Host" && data.version !== packageVersion) { + logger.warn( + `Host ${data.name} (${data.id}) connected using version ${data.version} which does not match the ` + + `version of the controller is currently running (${packageVersion}). It may not work as expected.` + ); + } + + connection = new HostConnection(data, connector, this.controller); + connector.on("close", () => { + if (this.hostConnections.get(data.id) === connection) { + this.hostConnections.delete(data.id); + this.controller.hostUpdated(this.controller.hosts.get(data.id)); + } else { + logger.warn("Unlisted HostConnection closed"); + } + }); + this.hostConnections.set(data.id, connection); + this.controller.hostUpdated(this.controller.hosts.get(data.id)); + let src = new libData.Address(libData.Address.host, data.id); + connector.ready(socket, src, sessionToken); + } + + async registerControl(data, socket, req) { + let user; + try { + let tokenPayload = jwt.verify( + data.token, + Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), + { audience: "user" } + ); + + user = this.controller.userManager.users.get(tokenPayload.user); + if (!user) { + throw new Error("invalid user"); + } + if (tokenPayload.iat < user.tokenValidAfter) { + throw new Error("invalid token"); + } + user.checkPermission("core.control.connect"); + + } catch (err) { + logger.verbose(`WsServer | authentication failed for ${req.socket.remoteAddress}`); + wsRejectedConnectionsCounter.inc(); + socket.close(4003, `Authentication failed: ${err.message}`); + return; + } + + let id = this.nextControlId; + this.nextControlId += 1; + let [connector, sessionToken] = this.createSession(new libData.Address(libData.Address.control, id)); + + logger.verbose(`WsServer | registered control ${id} from ${req.socket.remoteAddress}`); + let connection = new ControlConnection(data, connector, this.controller, user, id); + connector.on("close", () => { + this.controlConnections.delete(id); + }); + this.controlConnections.set(id, connection); + let account = new libData.AccountDetails( + user.name, + [...user.roles].map(r => ({ + name: r.name, + id: r.id, + permissions: [...r.permissions], + })) + ); + + let src = new libData.Address(libData.Address.control, id); + connector.ready(socket, src, sessionToken, account); + } + + resumeSession(data, socket, req) { + let connector; + try { + let payload = jwt.verify( + data.sessionToken, + Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"), + { audience: this.sessionAud } + ); + + connector = this.activeConnectors.get(payload.sid); + if (!connector) { + throw new Error(); + } + + } catch (err) { + socket.send(JSON.stringify(new libData.MessageInvalidate())); + this.attachHandler(socket, req); + return; + } + + connector.continue(socket, data.lastSeq); + } } diff --git a/packages/controller/src/WsServerConnector.js b/packages/controller/src/WsServerConnector.js index 004ef841..0b3fda8f 100644 --- a/packages/controller/src/WsServerConnector.js +++ b/packages/controller/src/WsServerConnector.js @@ -1,6 +1,7 @@ "use strict"; const events = require("events"); +const libData = require("@clusterio/lib/data"); const libLink = require("@clusterio/lib/link"); const { logger } = require("@clusterio/lib/logging"); @@ -12,9 +13,10 @@ const { logger } = require("@clusterio/lib/logging"); * @alias module:controller/src/WsServerConnector */ class WsServerConnector extends libLink.WebSocketBaseConnector { - constructor(sessionId, sessionTimeout, heartbeatInterval) { - super(sessionTimeout); + constructor(dst, sessionId, sessionTimeout, heartbeatInterval) { + super(new libData.Address(libData.Address.controller, 0), dst); + this._sessionTimeout = sessionTimeout; this._socket = null; this._sessionId = sessionId; this._heartbeatInterval = heartbeatInterval; @@ -38,27 +40,27 @@ class WsServerConnector extends libLink.WebSocketBaseConnector { * Sends the ready message over the socket to initiate the session. * * @param {Object} socket - WebSocket connection to client. + * @param {module:lib/data.Address} src - Source address for this link. * @param {string} sessionToken - * the session token to send to the client. - * @param {Object=} additionalData - - * extra properties to send along the ready message. + * @param {module:lib/data.AccountDetails=} account - + * account data to provide to control connection */ - ready(socket, sessionToken, additionalData) { + ready(socket, src, sessionToken, account) { this._socket = socket; - this._socket.send(JSON.stringify({ - seq: null, - type: "ready", - data: { - session_token: sessionToken, - session_timeout: this._sessionTimeout, - heartbeat_interval: this._heartbeatInterval, - ...additionalData, - }, - })); + this._sendInternal(new libData.MessageReady( + new libData.ReadyData( + src, + sessionToken, + this._sessionTimeout, + this._heartbeatInterval, + account, + ), + )); this._state = "connected"; this._attachSocketHandlers(); - this.emit("connect"); + this.emit("connect", { src: this.src }); } /** @@ -68,7 +70,7 @@ class WsServerConnector extends libLink.WebSocketBaseConnector { * socket given from the message sequence given. * * @param {module:net.Socket} socket - New socket to continue on. - * @param {number} lastSeq - The last message the client received. + * @param {number=} lastSeq - The last message the client received. */ continue(socket, lastSeq) { @@ -87,15 +89,9 @@ class WsServerConnector extends libLink.WebSocketBaseConnector { this._timeoutId = null; } - this._socket.send(JSON.stringify({ - seq: null, - type: "continue", - data: { - last_seq: this._lastReceivedSeq, - session_timeout: this._sessionTimeout, - heartbeat_interval: this._heartbeatInterval, - }, - })); + this._sendInternal(new libData.MessageContinue( + new libData.ContinueData(this._sessionTimeout, this._heartbeatInterval, this._lastReceivedSeq) + )); this._state = "connected"; this._attachSocketHandlers(); @@ -118,6 +114,7 @@ class WsServerConnector extends libLink.WebSocketBaseConnector { this._socket.on("close", (code, reason) => { logger.verbose(`Connector | Close (code: ${code}, reason: ${reason})`); this.stopHeartbeat(); + this._socket = null; if (this._closing) { this._reset(); @@ -148,18 +145,12 @@ class WsServerConnector extends libLink.WebSocketBaseConnector { // Handle messages this._socket.on("message", data => { - let message = JSON.parse(data); - if (["connected", "closing"].includes(this._state)) { - if (message.seq !== null) { - this._lastReceivedSeq = message.seq; - } - - if (message.type === "heartbeat") { - this._processHeartbeat(message); - - } else { - this.emit("message", message); - } + let message = this._parseMessage(data); + if (!message) { + return; + } + if (this._state === "connected") { + this._processMessage(message); } else { throw new Error(`Received message in unexpected state ${this._state}`); diff --git a/packages/controller/src/routes.js b/packages/controller/src/routes.js index a1270df6..04a98bf1 100644 --- a/packages/controller/src/routes.js +++ b/packages/controller/src/routes.js @@ -49,12 +49,12 @@ async function getMetrics(req, res, next) { let requests = []; let timeout = req.app.locals.controller.config.get("controller.metrics_timeout") * 1000; - for (let hostConnection of req.app.locals.controller.wsServer.hostConnections.values()) { + for (let [hostId, hostConnection] of req.app.locals.controller.wsServer.hostConnections) { if (!hostConnection.connected) { continue; } requests.push(libHelpers.timeout( - libLink.messages.getMetrics.send(hostConnection).catch(err => { + hostConnection.send(new libData.HostMetricsRequest()).catch(err => { if (!(err instanceof libErrors.SessionLost)) { logger.error(`Unexpected error gathering metrics from host:\n${err.stack}`); } @@ -232,7 +232,7 @@ async function uploadExport(req, res) { await libFileOps.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer")); } - modPack.exportManifest = new libData.ExportManifest({ assets }); + modPack.exportManifest = new libData.ExportManifest(assets); modPack.fillDefaultSettings(settingPrototypes, logger); res.app.locals.controller.modPackUpdated(modPack); @@ -361,15 +361,17 @@ async function uploadSave(req, res) { }); try { - let result = await Promise.race([ - req.app.locals.controller.forwardRequestToInstance(libLink.messages.pullSave, { - instance_id: instanceId, - stream_id: proxyStream.id, - filename, - }), + let storedName = await Promise.race([ + req.app.locals.controller.sendToHostByInstanceId( + new libData.InstancePullSaveRequest( + instanceId, + proxyStream.id, + filename, + ) + ), timeout, ]); - saves.push(result.save); + saves.push(storedName); } catch (err) { proxyStream.events.emit("close"); diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 8eeb56c4..024f5f11 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -11,6 +11,7 @@ const version = require("./package").version; const setBlocking = require("set-blocking"); const libLink = require("@clusterio/lib/link"); +const libData = require("@clusterio/lib/data"); const libErrors = require("@clusterio/lib/errors"); const libConfig = require("@clusterio/lib/config"); const libPlugin = require("@clusterio/lib/plugin"); @@ -35,11 +36,15 @@ class ControlConnector extends libLink.WebSocketClientConnector { register() { logger.verbose("Connector | registering control"); - this.sendHandshake("register_control", { - token: this._token, - agent: "clusterioctl", - version: version, - }); + this.sendHandshake( + new libData.MessageRegisterControl( + new libData.RegisterControlData( + this._token, + "clusterioctl", + version, + ) + ) + ); } } @@ -50,10 +55,8 @@ class ControlConnector extends libLink.WebSocketClientConnector { * @static */ class Control extends libLink.Link { - constructor(connector, controlConfig, tlsCa, controlPlugins) { - super("control", "controller", connector); - libLink.attachAllMessages(this); + super(connector); /** * Control config used for connecting to the controller. @@ -70,67 +73,54 @@ class Control extends libLink.Link { * @type {Map} */ this.plugins = controlPlugins; - for (let controlPlugin of controlPlugins.values()) { - libPlugin.attachPluginMessages(this, controlPlugin); - } /** * Keep the control connection alive after the command completes. * @type {boolean} */ this.keepOpen = false; + + this.handle(libData.LogMessageEvent, this.handleLogMessageEvent.bind(this)); + this.handle(libData.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this)); } - async accountUpdateEventHandler() { } - - async hostUpdateEventHandler() { } - - async instanceUpdateEventHandler() { } - - async saveListUpdateEventHandler() { } - - async modPackUpdateEventHandler() { } - - async modUpdateEventHandler() { } - - async userUpdateEventHandler() { } - async setLogSubscriptions({ all = false, controller = false, - host_ids = [], - instance_ids = [], - max_level = null, + hostIds = [], + instanceIds = [], + maxLevel = null, }) { - await libLink.messages.setLogSubscriptions.send(this, { - all, controller, host_ids, instance_ids, max_level, - }); + await this.send( + new libData.LogSetSubscriptionsRequest( + all, controller, hostIds, instanceIds, maxLevel, + ) + ); } - async logMessageEventHandler(message) { - logger.log(message.data.info); + async handleLogMessageEvent(event) { + logger.log(event.info); } - async debugWsMessageEventHandler(message) { + async handleDebugWsMessageEvent(event) { // eslint-disable-next-line no-console - console.log("WS", message.data.direction, message.data.content); + console.log("WS", event.direction, event.content); } async shutdown() { try { - await libLink.messages.prepareDisconnect.send(this); + await this.connector.disconnect(); } catch (err) { if (!(err instanceof libErrors.SessionLost)) { throw err; } } - - await this.connector.close(1000, "Control Quit"); } } async function loadPlugins(pluginList) { let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList); + libLink.registerPluginMessages(pluginInfos); libConfig.registerPluginConfigGroups(pluginInfos); libConfig.finalizeConfigs(); @@ -182,11 +172,13 @@ async function startControl() { let args = yargs.parse(); // Log stream for the ctl session. - logger.add(new ConsoleTransport({ - errorLevels: Object.keys(levels), - level: args.logLevel, - format: new libLoggingUtils.TerminalFormat(), - })); + logger.add( + new ConsoleTransport({ + errorLevels: Object.keys(levels), + level: args.logLevel, + format: new libLoggingUtils.TerminalFormat(), + }) + ); libLoggingUtils.handleUnhandledErrors(logger); logger.verbose(`Loading available plugins from ${args.pluginList}`); @@ -300,7 +292,7 @@ async function startControl() { process.exitCode = 1; } else if (err instanceof libErrors.RequestError) { - logger.error(`Error sending request: ${err.message}`); + logger.error(`Error sending request:\n${err.stack}`); process.exitCode = 1; } else { diff --git a/packages/ctl/src/commands.js b/packages/ctl/src/commands.js index b6d647bd..c3a437e5 100644 --- a/packages/ctl/src/commands.js +++ b/packages/ctl/src/commands.js @@ -10,7 +10,6 @@ const child_process = require("child_process"); const stream = require("stream"); const util = require("util"); -const libLink = require("@clusterio/lib/link"); const libErrors = require("@clusterio/lib/errors"); const libConfig = require("@clusterio/lib/config"); const libCommand = require("@clusterio/lib/command"); @@ -91,9 +90,9 @@ const controllerConfigCommands = new libCommand.CommandTree({ controllerConfigCommands.add(new libCommand.Command({ definition: ["list", "List controller configuration"], handler: async function(args, control) { - let response = await libLink.messages.getControllerConfig.send(control); + let response = await control.send(new libData.ControllerConfigGetRequest()); - for (let group of response.serialized_config.groups) { + for (let group of response.serializedConfig.groups) { for (let [name, value] of Object.entries(group.fields)) { print(`${group.name}.${name} ${JSON.stringify(value)}`); } @@ -115,10 +114,7 @@ controllerConfigCommands.add(new libCommand.Command({ } else if (args.value === undefined) { args.value = ""; } - await libLink.messages.setControllerConfigField.send(control, { - field: args.field, - value: args.value, - }); + await control.send(new libData.ControllerConfigSetFieldRequest(args.field, args.value)); }, })); @@ -135,22 +131,19 @@ controllerConfigCommands.add(new libCommand.Command({ if (args.stdin) { args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); } - let request = { - field: args.field, - prop: args.prop, - }; + let value; try { if (args.value !== undefined) { - request.value = JSON.parse(args.value); + value = JSON.parse(args.value); } } catch (err) { // See note for the instance version of set-prop if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) { throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`); } - request.value = args.value; + value = args.value; } - await libLink.messages.setControllerConfigProp.send(control, request); + await control.send(new libData.ControllerConfigSetPropRequest(args.field, args.prop, value)); }, })); controllerConfigCommands.add(new libCommand.Command({ @@ -162,7 +155,7 @@ controllerConfigCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.getControllerConfig.send(control); + let response = await control.send(new ControllerConfigGetRequest()); let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir()); let editor = await getEditor(args.editor); if (editor === -1) { @@ -170,7 +163,7 @@ controllerConfigCommands.add(new libCommand.Command({ Try "ctl controller config edit "`); } let allConfigElements = await serializedConfigToString( - response.serialized_config, libConfig.ControllerConfig, {} + response.serializedConfig, libConfig.ControllerConfig, {} ); await fs.writeFile(tmpFile, allConfigElements, (err) => { if (err) { @@ -191,10 +184,10 @@ controllerConfigCommands.add(new libCommand.Command({ for (let index in final) { if (index in final) { try { - await libLink.messages.setControllerConfigField.send(control, { - field: index, - value: final[index], - }); + await control.send(new ControllerConfigSetFieldRequest( + index, + final[index], + )); } catch (err) { // eslint-disable-next-line print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`); @@ -241,8 +234,8 @@ const hostCommands = new libCommand.CommandTree({ name: "host", description: "Ho hostCommands.add(new libCommand.Command({ definition: [["list", "l"], "List hosts connected to the controller"], handler: async function(args, control) { - let response = await libLink.messages.listHosts.send(control); - print(asTable(response.list)); + let hosts = await control.send(new libData.HostListRequest()); + print(asTable(hosts)); }, })); @@ -252,7 +245,7 @@ hostCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let hostId = typeof args.id === "number" ? args.id : null; - let response = await libLink.messages.generateHostToken.send(control, { host_id: hostId }); + let response = await control.send(new libData.HostGenerateTokenRequest(hostId)); print(response.token); }, })); @@ -269,11 +262,11 @@ hostCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.createHostConfig.send(control, { - id: args.id, name: args.name, generate_token: args.generateToken, - }); + let rawConfig = await control.send( + new libData.HostConfigCreateRequest(args.id, args.name, args.generateToken) + ); - let content = JSON.stringify(response.serialized_config, null, 4); + let content = JSON.stringify(rawConfig.serializedConfig, null, 4); if (args.output === "-") { print(content); } else { @@ -297,8 +290,8 @@ const instanceCommands = new libCommand.CommandTree({ instanceCommands.add(new libCommand.Command({ definition: [["list", "l"], "List instances known to the controller"], handler: async function(args, control) { - let response = await libLink.messages.listInstances.send(control); - print(asTable(response.list)); + let list = await control.send(new libData.InstanceDetailsListRequest()); + print(asTable(list)); }, })); @@ -317,8 +310,8 @@ instanceCommands.add(new libCommand.Command({ instanceConfig.set("instance.id", args.id); } instanceConfig.set("instance.name", args.name); - let serialized_config = instanceConfig.serialize("controller"); - await libLink.messages.createInstance.send(control, { serialized_config }); + let serializedConfig = instanceConfig.serialize("controller"); + await control.send(new libData.InstanceCreateRequest(serializedConfig)); }, })); @@ -331,9 +324,9 @@ instanceConfigCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId }); + let response = await control.send(new libData.InstanceConfigGetRequest(instanceId)); - for (let group of response.serialized_config.groups) { + for (let group of response.config.groups) { for (let [name, value] of Object.entries(group.fields)) { print(`${group.name}.${name} ${JSON.stringify(value)}`); } @@ -357,11 +350,7 @@ instanceConfigCommands.add(new libCommand.Command({ } else if (args.value === undefined) { args.value = ""; } - await libLink.messages.setInstanceConfigField.send(control, { - instance_id: instanceId, - field: args.field, - value: args.value, - }); + await control.send(new libData.InstanceConfigSetFieldRequest(instanceId, args.field, args.value)); }, })); @@ -380,14 +369,10 @@ instanceConfigCommands.add(new libCommand.Command({ if (args.stdin) { args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, ""); } - let request = { - instance_id: instanceId, - field: args.field, - prop: args.prop, - }; + let value; try { if (args.value !== undefined) { - request.value = JSON.parse(args.value); + value = JSON.parse(args.value); } } catch (err) { // If this is from stdin or looks like an array, object or string @@ -406,9 +391,9 @@ instanceConfigCommands.add(new libCommand.Command({ if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) { throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`); } - request.value = args.value; + value = args.value; } - await libLink.messages.setInstanceConfigProp.send(control, request); + await control.send(new libData.InstanceConfigSetPropRequest(instanceId, args.field, args.prop, value)); }, })); @@ -423,7 +408,7 @@ instanceConfigCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId }); + let response = await control.send(new libData.InstanceConfigGetRequest(instanceId)); let tmpFile = await libFileOps.getTempFile("ctl-", "-tmp", os.tmpdir()); let editor = await getEditor(args.editor); if (editor === -1) { @@ -432,7 +417,7 @@ instanceConfigCommands.add(new libCommand.Command({ } let disallowedList = {"instance.id": 0, "instance.assigned_host": 0, "factorio.settings": 0}; let allConfigElements = await serializedConfigToString( - response.serialized_config, + response.serializedConfig, libConfig.InstanceConfig, disallowedList ); @@ -455,11 +440,11 @@ instanceConfigCommands.add(new libCommand.Command({ for (let index in final) { if (index in final) { try { - await libLink.messages.setInstanceConfigField.send(control, { - instance_id: instanceId, - field: index, - value: final[index], - }); + await control.send(new libData.InstanceConfigSetField( + instanceId, + index, + final[index], + )); } catch (err) { // eslint-disable-next-line print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`); @@ -494,10 +479,7 @@ instanceCommands.add(new libCommand.Command({ handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); let hostId = args.host ? await libCommand.resolveHost(control, args.host) : null; - await libLink.messages.assignInstanceCommand.send(control, { - instance_id: instanceId, - host_id: hostId, - }); + await control.send(new libData.InstanceAssignRequest(instanceId, hostId)); }, })); @@ -533,12 +515,12 @@ instanceSaveCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - let response = await libLink.messages.listSaves.send(control, { instance_id: instanceId }); - for (let entry of response.list) { - entry.mtime = new Date(entry.mtime_ms).toLocaleString(); - delete entry.mtime_ms; + let saves = await control.sendTo({ instanceId }, new libData.InstanceListSavesRequest()); + for (let entry of saves) { + entry.mtime = new Date(entry.mtimeMs).toLocaleString(); + delete entry.mtimeMs; } - print(asTable(response.list)); + print(asTable(saves)); }, })); @@ -556,14 +538,11 @@ instanceSaveCommands.add(new libCommand.Command({ handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.createSave.send(control, { - instance_id: instanceId, - name: args.name, - seed, - map_gen_settings: mapGenSettings, - map_settings: mapSettings, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo( + { instanceId }, + new libData.InstanceCreateSaveRequest(args.name, seed, mapGenSettings, mapSettings), + ); }, })); @@ -575,11 +554,7 @@ instanceSaveCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await libLink.messages.renameSave.send(control, { - instance_id: instanceId, - old_name: args.oldName, - new_name: args.newName, - }); + await control.send(new libData.InstanceRenameSaveRequest(instanceId, args.oldName, args.newName)); }, })); @@ -591,11 +566,7 @@ instanceSaveCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await libLink.messages.copySave.send(control, { - instance_id: instanceId, - source: args.source, - destination: args.destination, - }); + await control.send(new libData.InstanceCopySaveRequest(instanceId, args.source, args.destination)); }, })); @@ -667,14 +638,16 @@ instanceSaveCommands.add(new libCommand.Command({ handler: async function(args, control) { let sourceInstanceId = await libCommand.resolveInstance(control, args.sourceInstance); let targetInstanceId = await libCommand.resolveInstance(control, args.targetInstance); - let result = await libLink.messages.transferSave.send(control, { - instance_id: sourceInstanceId, - source_save: args.sourceSave, - target_instance_id: targetInstanceId, - target_save: args.targetSave || args.sourceSave, - copy: args.copy, - }); - print(`Transferred as ${result.save} to ${args.targetInstance}.`); + let storedName = await control.send( + new libData.InstanceTransferSaveRequest( + sourceInstanceId, + args.sourceSave, + targetInstanceId, + args.targetSave || args.sourceSave, + args.copy + ) + ); + print(`Transferred as ${storedName} to ${args.targetInstance}.`); }, })); @@ -685,13 +658,10 @@ instanceSaveCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - let result = await libLink.messages.downloadSave.send(control, { - instance_id: instanceId, - save: args.save, - }); + let streamId = await control.send(new libData.InstanceDownloadSaveRequest(instanceId, args.save)); let url = new URL(control.config.get("control.controller_url")); - url.pathname += `api/stream/${result.stream_id}`; + url.pathname += `api/stream/${streamId}`; let response = await phin({ url, method: "GET", core: { ca: control.tlsCa }, @@ -730,10 +700,7 @@ instanceSaveCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await libLink.messages.deleteSave.send(control, { - instance_id: instanceId, - save: args.save, - }); + await control.send(new libData.InstanceDeleteSaveRequest(instanceId, args.save)); }, })); instanceCommands.add(instanceSaveCommands); @@ -744,10 +711,8 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.exportData.send(control, { - instance_id: instanceId, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new libData.InstanceExportDataRequest()); }, })); @@ -757,10 +722,8 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.extractPlayers.send(control, { - instance_id: instanceId, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new libData.InstanceExtractPlayersRequest()); }, })); @@ -774,11 +737,8 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.startInstance.send(control, { - instance_id: instanceId, - save: args.save || null, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new libData.InstanceStartRequest(args.save)); control.keepOpen = args.keepOpen; }, })); @@ -797,15 +757,12 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args); - await libLink.messages.loadScenario.send(control, { - instance_id: instanceId, - scenario: args.scenario, - seed, - map_gen_settings: mapGenSettings, - map_settings: mapSettings, - }); + await control.sendTo( + { instanceId }, + new libData.InstanceLoadScenarioRequest(args.scenario, seed, mapGenSettings, mapSettings), + ); control.keepOpen = args.keepOpen; }, })); @@ -816,10 +773,8 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.stopInstance.send(control, { - instance_id: instanceId, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new libData.InstanceStopRequest(args.save)); }, })); @@ -829,10 +784,8 @@ instanceCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let instanceId = await libCommand.resolveInstance(control, args.instance); - await control.setLogSubscriptions({ instance_ids: [instanceId] }); - await libLink.messages.killInstance.send(control, { - instance_id: instanceId, - }); + await control.setLogSubscriptions({ instanceIds: [instanceId] }); + await control.sendTo({ instanceId }, new libData.InstanceKillRequest(args.save)); }, })); @@ -841,9 +794,8 @@ instanceCommands.add(new libCommand.Command({ yargs.positional("instance", { describe: "Instance to delete", type: "string" }); }], handler: async function(args, control) { - await libLink.messages.deleteInstance.send(control, { - instance_id: await libCommand.resolveInstance(control, args.instance), - }); + let instanceId = await libCommand.resolveInstance(control, args.instance); + await control.send(new libData.InstanceDeleteRequest(instanceId)); }, })); @@ -853,13 +805,13 @@ instanceCommands.add(new libCommand.Command({ yargs.positional("command", { describe: "command to send", type: "string" }); }], handler: async function(args, control) { - let response = await libLink.messages.sendRcon.send(control, { - instance_id: await libCommand.resolveInstance(control, args.instance), - command: args.command, - }); + let result = await control.sendTo( + { instanceId: await libCommand.resolveInstance(control, args.instance) }, + new libData.InstanceSendRconRequest(args.command), + ); - // Factorio includes a newline in it's response output. - process.stdout.write(response.result); + // Factorio includes a newline in its response output. + process.stdout.write(result); }, })); @@ -870,36 +822,38 @@ modPackCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let mods = new Map( - (await libLink.messages.listMods.send(control)).list.map(m => [`${m.name}_${m.version}`, m]) + (await control.send(new libData.ModListRequest())).map(m => [`${m.name}_${m.version}`, m]) + ); + let modPack = await control.send( + new libData.ModPackGetRequest(await libCommand.resolveModPack(control, args.modPack)) ); - let response = await libLink.messages.getModPack.send(control, { - id: await libCommand.resolveModPack(control, args.modPack), - }); - for (let [field, value] of Object.entries(response.mod_pack)) { + for (let [field, value] of Object.entries(modPack)) { if (field === "mods") { print(`${field}:`); - for (let entry of value) { - const mod = mods.get(`${entry.name}_${entry.version}`); + for (let [name, entry] of value) { + const mod = mods.get(`${name}_${entry.version}`); const missing = !mod && "? "; const badChecksum = mod && entry.sha1 && entry.sha1 !== mod.sha1 && "! "; const warn = missing || badChecksum || ""; const enabled = entry.enabled ? "" : "(disabled) "; - print(` ${warn}${enabled}${entry.name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`); + print(` ${warn}${enabled}${name} ${entry.version}${entry.sha1 ? ` (${entry.sha1})` : ""}`); } } else if (field === "settings") { print(`${field}:`); for (let [scope, settings] of Object.entries(value)) { print(` ${scope}:`); - for (let [setting, settingValue] of Object.entries(settings)) { + for (let [setting, settingValue] of settings) { print(` ${setting}: ${JSON.stringify(settingValue.value)}`); } } - } else if (field === "export_manifest") { + } else if (field === "exportManifest") { print(`${field}:`); - print(" assets:"); - for (let [name, fileName] of Object.entries(value.assets)) { - print(` ${name}: ${fileName}`); + if (value && value.assets) { + print(" assets:"); + for (let [name, fileName] of Object.entries(value.assets)) { + print(` ${name}: ${fileName}`); + } } } else { @@ -912,16 +866,16 @@ modPackCommands.add(new libCommand.Command({ modPackCommands.add(new libCommand.Command({ definition: [["list", "l"], "List mod packs in the cluster"], handler: async function(args, control) { - let response = await libLink.messages.listModPacks.send(control); - let fields = ["id", "name", "factorio_version"]; - for (let entry of response.list) { + let modPacks = await control.send(new libData.ModPackListRequest()); + let fields = ["id", "name", "factorioVersion"]; + for (let entry of modPacks) { for (let field of Object.keys(entry)) { if (!fields.includes(field)) { delete entry[field]; } } } - print(asTable(response.list)); + print(asTable(modPacks)); }, })); @@ -1002,7 +956,7 @@ modPackCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - const modPack = new libData.ModPack(); + const modPack = libData.ModPack.fromJSON({}); modPack.name = args.name; if (args.description) { modPack.description = args.description; } if (args.factorioVersion) { @@ -1014,7 +968,7 @@ modPackCommands.add(new libCommand.Command({ setModPackMods(modPack, args.mods); setModPackModsEnabled(modPack, args.disabledMods, false); setModPackSettings(modPack, args); - await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() }); + await control.send(new libData.ModPackCreateRequest(modPack)); print(`Created mod pack ${modPack.name} (${modPack.id})`); }, })); @@ -1025,7 +979,7 @@ modPackCommands.add(new libCommand.Command({ }], handler: async function(args, control) { const modPack = libData.ModPack.fromModPackString(args.string); - await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() }); + await control.send(new libData.ModPackCreateRequest(modPack)); print(`Created mod pack ${modPack.name} (${modPack.id})`); }, })); @@ -1035,10 +989,9 @@ modPackCommands.add(new libCommand.Command({ yargs.positional("string", { describe: "Mod pack to export", type: "string" }); }], handler: async function(args, control) { - const response = await libLink.messages.getModPack.send(control, { - id: await libCommand.resolveModPack(control, args.modPack), - }); - const modPack = new libData.ModPack(response.mod_pack); + const modPack = await control.send( + new libData.ModPackGetRequest(await libCommand.resolveModPack(control, args.modPack)) + ); print(modPack.toModPackString()); }, })); @@ -1062,10 +1015,9 @@ modPackCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.getModPack.send(control, { - id: await libCommand.resolveModPack(control, args.modPack), - }); - let modPack = new libData.ModPack(response.mod_pack); + const modPack = await control.send( + new libData.ModPackGetRequest(await libCommand.resolveModPack(control, args.modPack)) + ); if (args.name) { modPack.name = args.name; } if (args.description) { modPack.description = args.description; } @@ -1103,7 +1055,7 @@ modPackCommands.add(new libCommand.Command({ } } } - await libLink.messages.updateModPack.send(control, { mod_pack: modPack.toJSON() }); + await control.send(new libData.ModPackUpdateRequest(modPack)); }, })); @@ -1113,7 +1065,7 @@ modPackCommands.add(new libCommand.Command({ }], handler: async function(args, control) { const id = await libCommand.resolveModPack(control, args.modPack); - await libLink.messages.deleteModPack.send(control, { id }); + await control.send(new libData.ModPackDeleteRequest(id)); }, })); @@ -1124,8 +1076,8 @@ modCommands.add(new libCommand.Command({ yargs.positional("mod-version", { describe: "Version of the mod", type: "string" }); }], handler: async function(args, control) { - let response = await libLink.messages.getMod.send(control, { name: args.name, version: args.modVersion }); - for (let [field, value] of Object.entries(response.mod)) { + let modInfo = await control.send(new libData.ModGetRequest(args.name, args.modVersion)); + for (let [field, value] of Object.entries(modInfo)) { if (value instanceof Array) { print(`${field}:`); for (let entry of value) { @@ -1150,9 +1102,9 @@ modCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.listMods.send(control); + let mods = await control.send(new libData.ModListRequest()); if (!args.fields.includes("all")) { - for (let entry of response.list) { + for (let entry of mods) { for (let field of Object.keys(entry)) { if (!args.fields.includes(field)) { delete entry[field]; @@ -1160,7 +1112,7 @@ modCommands.add(new libCommand.Command({ } } } - print(asTable(response.list)); + print(asTable(mods)); }, })); @@ -1197,14 +1149,14 @@ modCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.searchMods.send(control, { - "query": args.query, - "factorio_version": args.factorioVersion, - "page_size": args.pageSize, - "page": args.page, - "sort": args.sort, - "sort_order": args.sortOrder, - }); + let response = await control.send(new libData.ModSearchRequest( + args.query, + args.factorioVersion, + args.page, + args.pageSize, + args.sort, + args.sortOrder + )); let results = response.results.flatMap(result => result.versions); if (!args.fields.includes("all")) { for (let entry of results) { @@ -1215,10 +1167,10 @@ modCommands.add(new libCommand.Command({ } } } - for (let issue of response.query_issues) { + for (let issue of response.queryIssues) { print(issue); } - print(`page ${args.page} of ${response.page_count} (${response.result_count} results)`); + print(`page ${args.page} of ${response.pageCount} (${response.resultCount} results)`); print(asTable(results)); }, })); @@ -1259,7 +1211,7 @@ modCommands.add(new libCommand.Command({ } if (result.body.mods && result.body.mods.length) { - const mod = new libData.ModInfo(result.body.mods[0]); + const mod = libData.ModInfo.fromJSON(result.body.mods[0]); logger.info(`Successfully uploaded ${mod.filename}`); } @@ -1275,13 +1227,10 @@ modCommands.add(new libCommand.Command({ yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" }); }], handler: async function(args, control) { - let result = await libLink.messages.downloadMod.send(control, { - name: args.name, - version: args.modVersion, - }); + let streamId = await control.send(new libData.ModDownloadRequest(args.name, args.modVersion)); let url = new URL(control.config.get("control.controller_url")); - url.pathname += `api/stream/${result.stream_id}`; + url.pathname += `api/stream/${streamId}`; let response = await phin({ url, method: "GET", core: { ca: control.tlsCa }, @@ -1318,7 +1267,7 @@ modCommands.add(new libCommand.Command({ yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" }); }], handler: async function(args, control) { - await libLink.messages.deleteMod.send(control, { name: args.name, version: args.modVersion }); + await control.send(new libData.ModDeleteRequest(args.name, args.modVersion)); }, })); @@ -1326,8 +1275,8 @@ const permissionCommands = new libCommand.CommandTree({ name: "permission", desc permissionCommands.add(new libCommand.Command({ definition: [["list", "l"], "List permissions in the cluster"], handler: async function(args, control) { - let response = await libLink.messages.listPermissions.send(control); - print(asTable(response.list)); + let permissions = await control.send(new libData.PermissionListRequest()); + print(asTable(permissions)); }, })); @@ -1336,8 +1285,8 @@ const roleCommands = new libCommand.CommandTree({ name: "role", description: "Ro roleCommands.add(new libCommand.Command({ definition: [["list", "l"], "List roles in the cluster"], handler: async function(args, control) { - let response = await libLink.messages.listRoles.send(control); - print(asTable(response.list)); + let roles = await control.send(new libData.RoleListRequest()); + print(asTable(roles)); }, })); @@ -1350,12 +1299,12 @@ roleCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.createRole.send(control, { - name: args.name, - description: args.description, - permissions: args.permissions, - }); - logger.info(`Created role ID ${response.id}`); + let id = await control.send(new libData.RoleCreateRequest( + args.name, + args.description, + args.permissions, + )); + logger.info(`Created role ID ${id}`); }, })); @@ -1395,10 +1344,10 @@ roleCommands.add(new libCommand.Command({ if (args.setPerms !== undefined) { role.permissions = args.setPerms; } - await libLink.messages.updateRole.send(control, role); + await control.send(new libData.RoleUpdateRequest(role.id, role.name, role.description, role.permissions)); if (args.grantDefault) { - await libLink.messages.grantDefaultRolePermissions.send(control, { id: role.id }); + await control.send(new libData.RoleGrantDefaultPermissionsRequest(role.id)); } }, })); @@ -1409,7 +1358,7 @@ roleCommands.add(new libCommand.Command({ }], handler: async function(args, control) { let role = await libCommand.retrieveRole(control, args.role); - await libLink.messages.deleteRole.send(control, { id: role.id }); + await control.send(new libData.RoleDeleteRequest(role.id)); }, })); @@ -1423,16 +1372,15 @@ userCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.getUser.send(control, { name: args.name }); - delete response["seq"]; - Object.assign(response, response["player_stats"]); - delete response["player_stats"]; - let instanceStats = response["instance_stats"]; - delete response["instance_stats"]; - print(asTable(Object.entries(response).map(([property, value]) => ({ property, value })))); + let user = await control.send(new libData.UserGetRequest(args.name)); + Object.assign(user, user.playerStats); + delete user.playerStats; + let instanceStats = user.instanceStats; + delete user.instanceStats; + print(asTable(Object.entries(user).map(([property, value]) => ({ property, value })))); if (args.instanceStats) { - let instances = (await libLink.messages.listInstances.send(control)).list; + let instances = await control.send(new libData.InstanceDetailsListRequest()); function instanceName(id) { let instance = instances.find(i => i.id === id); if (instance) { @@ -1457,19 +1405,22 @@ userCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - let response = await libLink.messages.listUsers.send(control); - for (let user of response.list) { + let users = await control.send(new libData.UserListRequest()); + for (let user of users) { if (args.stats) { - Object.assign(user, user["player_stats"]); + Object.assign(user, user.playerStats); } - delete user["player_stats"]; + delete user.playerStats; + delete user.isDeleted; + delete user.banReason; + delete user.instanceStats; if (!args.attributes) { - delete user["is_admin"]; - delete user["is_whitelisted"]; - delete user["is_banned"]; + delete user.isAdmin; + delete user.isWhitelisted; + delete user.isBanned; } } - print(asTable(response.list)); + print(asTable(users)); }, })); @@ -1478,7 +1429,7 @@ userCommands.add(new libCommand.Command({ yargs.positional("name", { describe: "Name of user to create", type: "string" }); }], handler: async function(args, control) { - await libLink.messages.createUser.send(control, { name: args.name }); + await control.send(new libData.UserCreateRequest(args.name)); }, })); @@ -1487,7 +1438,7 @@ userCommands.add(new libCommand.Command({ yargs.positional("name", { describe: "Name of user to revoke token for", type: "string" }); }], handler: async function(args, control) { - await libLink.messages.revokeUserToken.send(control, { name: args.name }); + await control.send(new libData.UserRevokeTokenRequest(args.name)); }, })); @@ -1500,9 +1451,7 @@ userCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - await libLink.messages.setUserAdmin.send(control, { - name: args.user, create: args.create, admin: !args.revoke, - }); + await control.send(new libData.UserSetAdminRequest(args.user, args.create, !args.revoke)); }, })); @@ -1515,9 +1464,7 @@ userCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - await libLink.messages.setUserWhitelisted.send(control, { - name: args.user, create: args.create, whitelisted: !args.remove, - }); + await control.send(new libData.UserSetWhitelistedRequest(args.user, args.create, !args.remove)); }, })); @@ -1531,9 +1478,7 @@ userCommands.add(new libCommand.Command({ }); }], handler: async function(args, control) { - await libLink.messages.setUserBanned.send(control, { - name: args.user, create: args.create, banned: !args.pardon, reason: args.reason, - }); + await control.send(new libData.UserSetBannedRequest(args.user, args.create, !args.pardon, args.reason)); }, })); @@ -1543,7 +1488,7 @@ userCommands.add(new libCommand.Command({ yargs.positional("roles", { describe: "roles to assign", type: "string" }); }], handler: async function(args, control) { - let response = await libLink.messages.listRoles.send(control); + let roles = await control.send(new libData.RoleListRequest()); let resolvedRoles = []; for (let roleName of args.roles) { @@ -1553,7 +1498,7 @@ userCommands.add(new libCommand.Command({ } else { let found = false; - for (let role of response.list) { + for (let role of roles) { if (role.name === roleName) { resolvedRoles.push(role.id); found = true; @@ -1567,7 +1512,7 @@ userCommands.add(new libCommand.Command({ } } - await libLink.messages.updateUserRoles.send(control, { name: args.user, roles: resolvedRoles }); + await control.send(new libData.UserUpdateRolesRequest(args.user, resolvedRoles)); }, })); @@ -1576,7 +1521,7 @@ userCommands.add(new libCommand.Command({ yargs.positional("user", { describe: "Name of user to delete", type: "string" }); }], handler: async function(args, control) { - await libLink.messages.deleteUser.send(control, { name: args.user }); + await control.send(new libData.UserDeleteRequest(args.user)); }, })); @@ -1596,9 +1541,9 @@ logCommands.add(new libCommand.Command({ process.exitCode = 1; return; } - let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; - let host_ids = args.host ? [await libCommand.resolveHost(control, args.host)] : []; - await control.setLogSubscriptions({ all: args.all, controller: args.controller, host_ids, instance_ids }); + let instanceIds = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; + let hostIds = args.host ? [await libCommand.resolveHost(control, args.host)] : []; + await control.setLogSubscriptions({ all: args.all, controller: args.controller, hostIds, instanceIds }); control.keepOpen = true; }, })); @@ -1621,17 +1566,17 @@ logCommands.add(new libCommand.Command({ process.exitCode = 1; return; } - let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; - let host_ids = args.host ? [await libCommand.resolveHost(control, args.host)] : []; - let result = await libLink.messages.queryLog.send(control, { - all: args.all, - controller: args.controller, - host_ids, - instance_ids, - max_level: args.maxLevel, - limit: args.limit, - order: args.start ? "asc" : "desc", - }); + let instanceIds = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : []; + let hostIds = args.host ? [await libCommand.resolveHost(control, args.host)] : []; + let result = await control.send(new libData.LogQueryRequest( + args.all, + args.controller, + hostIds, + instanceIds, + args.maxLevel, + args.limit, + args.start ? "asc" : "desc", + )); if (!args.start) { result.log.reverse(); @@ -1655,7 +1600,7 @@ const debugCommands = new libCommand.CommandTree({ name: "debug", description: " debugCommands.add(new libCommand.Command({ definition: ["dump-ws", "Dump WebSocket messages sent and received by controller", (yargs) => { }], handler: async function(args, control) { - await libLink.messages.debugDumpWs.send(control); + await control.send(new libData.DebugDumpWsRequest()); control.keepOpen = true; }, })); diff --git a/packages/host/host.js b/packages/host/host.js index a8c4ecd1..679e926b 100755 --- a/packages/host/host.js +++ b/packages/host/host.js @@ -22,6 +22,7 @@ const winston = require("winston"); require("winston-daily-rotate-file"); // internal libraries +const libData = require("@clusterio/lib/data"); const libLink = require("@clusterio/lib/link"); const libPluginLoader = require("@clusterio/lib/plugin_loader"); const libErrors = require("@clusterio/lib/errors"); @@ -51,15 +52,19 @@ class HostConnector extends libLink.WebSocketClientConnector { plugins[pluginInfo.name] = pluginInfo.version; } - this.sendHandshake("register_host", { - token: this.hostConfig.get("host.controller_token"), - agent: "Clusterio Host", - version, - id: this.hostConfig.get("host.id"), - name: this.hostConfig.get("host.name"), - public_address: this.hostConfig.get("host.public_address"), - plugins, - }); + this.sendHandshake( + new libData.MessageRegisterHost( + new libData.RegisterHostData( + this.hostConfig.get("host.controller_token"), + "Clusterio Host", + version, + this.hostConfig.get("host.name"), + this.hostConfig.get("host.id"), + this.hostConfig.get("host.public_address"), + plugins, + ) + ) + ); } } @@ -140,6 +145,7 @@ async function startHost() { logger.info("Loading Plugin info"); let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList); + libLink.registerPluginMessages(pluginInfos); libConfig.registerPluginConfigGroups(pluginInfos); libConfig.finalizeConfigs(); diff --git a/packages/host/src/Host.js b/packages/host/src/Host.js index 169e1e9a..b616820e 100644 --- a/packages/host/src/Host.js +++ b/packages/host/src/Host.js @@ -10,6 +10,7 @@ const stream = require("stream"); const util = require("util"); // internal libraries +const libData = require("@clusterio/lib/data"); const libFileOps = require("@clusterio/lib/file_ops"); const libLink = require("@clusterio/lib/link"); const libPlugin = require("@clusterio/lib/plugin"); @@ -80,6 +81,157 @@ async function discoverInstances(instancesDir) { return instanceInfos; } +const instanceStartingMessages = new Set([ + libData.InstanceStartRequest.name, + libData.InstanceLoadScenarioRequest.name, + libData.InstanceCreateSaveRequest.name, + libData.InstanceExportDataRequest.name, +]); + +class HostRouter { + /** @type {module:lib/host/src/Host} */ + host; + + constructor(host) { + this.host = host; + } + + /** + * Forward a message to the next hop towards its destination. + * + * @param {module:lib/link.Link} origin - + * Link the message originated from. + * @param {module:lib/data.Message} message - Message to process. + * @param {boolean} hasFallback - is fallback available + * @returns {boolean} true if the message was handled, false if fallback + * is requested + */ + forwardMessage(origin, message, hasFallback) { + let dst = message.dst; + let nextHop; + let msg; + if (dst.type === libData.Address.broadcast) { + this.broadcastMessage(origin, message); + return true; + } else if ( + dst.type === libData.Address.controller + || dst.type === libData.Address.control + || dst.type === libData.Address.host && dst.id !== this.host.config.get("host.id") + || dst.type === libData.Address.instance && !this.host.instanceInfos.has(dst.id) + ) { + nextHop = this.host; + } else if (dst.type === libData.Address.instance && this.host.instanceInfos.has(dst.id)) { + nextHop = this.host.instanceConnections.get(dst.id); + } + + if (nextHop === origin) { + msg = `Message would return back to sender ${origin.dst}.`; + nextHop = undefined; + } + + if (message.type === "request") { + if (dst.type === libData.Address.instance && instanceStartingMessages.has(message.name)) { + this.wakeInstance(origin, message, nextHop); + return true; + + } else if (!nextHop) { + if (dst.type === libData.Address.instance) { + if (hasFallback) { + return false; + } + origin.connector.sendResponseError( + new libData.ResponseError("Instance is not running."), message.src + ); + } + return true; + } + + // XXX What if the session is invalidated and there is no + // response? Need to track pending requests here. + } + + if (nextHop) { + this.sendMessage(nextHop, message, origin); + } else { + this.warnUnrouted(message, msg); + } + + return true; + } + + broadcastMessage(origin, message) { + let dst = message.dst; + if (message.type !== "event") { + this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`); + } else if (dst.id === libData.Address.host || dst.id === libData.Address.instance) { + for (let instanceConnection of this.host.instanceConnections.values()) { + if (instanceConnection !== origin) { + instanceConnection.connector.send(message); + } + } + if (this.host !== origin) { + this.host.connector.send(message); + } + } else if (dst.id === libData.Address.control) { + if (this.host !== origin) { + this.host.connector.send(message); + } else { + logger.warn(`Received control broadcast of ${message.name} from master.`); + } + } else { + this.warnUnrouted(message, `Unexpected broacdast target ${dst.id}`); + } + } + + wakeInstance(origin, message, nextHop) { + let dst = message.dst; + if (nextHop) { + origin.connector.sendResponseError( + new libData.ResponseError("Instance is already running."), message.src + ); + return; + } + if (this.host._shuttingDown) { + origin.connector.sendResponseError( + new libData.ResponseError("Host is shutting down."), message.src + ); + return; + } + + this.host._connectInstance(dst.id).then(instanceConnection => { + instanceConnection.connector.send(message); + }).catch(err => { + logger.error(`Error starting instance:\n${err.stack}`, this.host.instanceLogMeta(dst.id)); + origin.connector.sendResponseError( + new libData.ResponseError(err.message, err.code, err.stack), message.src + ); + }); + } + + sendMessage(nextHop, message, origin) { + try { + if (message.type === "request") { + nextHop.forwardRequest(message, origin); + } else { + nextHop.connector.send(message); + } + } catch (err) { + if (message.type === "request") { + origin.connector.sendResponseError( + new libData.ResponseError(err.message, err.code, err.stack), message.src + ); + } + logger.warn(`Failed to deliver ${message.name || "message"} (${message.type}): ${err.message}`); + } + } + + warnUnrouted(message, msg) { + let dst = message.dst; + let baseMsg = `No destination for ${message.constructor.name} routed from ${message.src} to ${dst}`; + logger.warn(msg ? `${baseMsg}: ${msg}.` : `${baseMsg}.`); + } +} + /** * Handles running the host * @@ -87,17 +239,11 @@ async function discoverInstances(instancesDir) { * @alias module:host/src/Host */ class Host extends libLink.Link { - // I don't like God classes, but the alternative of putting all this state - // into global variables is not much better. constructor(connector, hostConfig, tlsCa, pluginInfos) { - super("host", "controller", connector); - libLink.attachAllMessages(this); + super(connector); + this.router = new HostRouter(this); this.pluginInfos = pluginInfos; - for (let pluginInfo of pluginInfos) { - libPlugin.attachPluginMessages(this, { info: pluginInfo }); - } - this.config = hostConfig; /** @@ -158,11 +304,28 @@ class Host extends libLink.Link { for (let event of ["connect", "drop", "resume", "close"]) { this.connector.on(event, () => { + let message = new libData.ControllerConnectionEvent(event); for (let instanceConnection of this.instanceConnections.values()) { - libLink.messages.controllerConnectionEvent.send(instanceConnection, { event }); + instanceConnection.send(message); } }); } + + this.handle(libData.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this)); + this.snoopEvent(libData.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this)); + this.snoopEvent(libData.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this)); + this.snoopEvent(libData.InstanceWhitelistUpdateEvent, this.handleWhitelistUpdateEvent.bind(this)); + this.handle(libData.InstanceAssignInternalRequest, this.handleInstanceAssignInternalRequest.bind(this)); + this.handle(libData.InstanceUnassignInternalRequest, this.handleInstanceUnassignInternalRequest.bind(this)); + this.handle(libData.HostMetricsRequest, this.handleHostMetricsRequest.bind(this)); + this.fallbackRequest(libData.InstanceListSavesRequest, this.fallbackInstanceListSavesRequest.bind(this)); + this.handle(libData.InstanceRenameSaveRequest, this.handleInstanceRenameSaveRequest.bind(this)); + this.handle(libData.InstanceCopySaveRequest, this.handleInstanceCopySaveRequest.bind(this)); + this.handle(libData.InstanceTransferSaveRequest, this.handleInstanceTransferSaveRequest.bind(this)); + this.handle(libData.InstanceDeleteSaveRequest, this.handleInstanceDeleteSaveRequest.bind(this)); + this.handle(libData.InstancePullSaveRequest, this.handleInstancePullSaveRequest.bind(this)); + this.handle(libData.InstancePushSaveRequest, this.handleInstancePushSaveRequest.bind(this)); + this.handle(libData.InstanceDeleteInternalRequest, this.handleInstanceDeleteInternalRequest.bind(this)); } async _createNewInstanceDir(name) { @@ -189,45 +352,15 @@ class Host extends libLink.Link { throw Error("Unable to create instance dir, retry threshold reached"); } - async forwardRequestToInstance(message, request) { - let instanceId = message.data.instance_id; - if (!this.instanceInfos.has(instanceId)) { - throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`); - } - - let instanceConnection = this.instanceConnections.get(instanceId); - if (!instanceConnection) { - throw new libErrors.RequestError(`Instance ID ${instanceId} is not running`); - } - - if (request.plugin && !instanceConnection.plugins.has(request.plugin)) { - throw new libErrors.RequestError(`Instance ID ${instanceId} does not have ${request.plugin} plugin loaded`); - } - - return await request.send(instanceConnection, message.data); - } - - async forwardEventToInstance(message, event) { - let instanceId = message.data.instance_id; - if (!this.instanceInfos.has(instanceId)) { return; } - - let instanceConnection = this.instanceConnections.get(instanceId); - if (!instanceConnection) { return; } - if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { return; } - - event.send(instanceConnection, message.data); - } - - async broadcastEventToInstance(message, event) { + async broadcastEventToInstance(event) { for (let instanceConnection of this.instanceConnections.values()) { - if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { continue; } - - event.send(instanceConnection, message.data); + if (event.constructor.plugin && !instanceConnection.plugins.has(event.plugin)) { continue; } + instanceConnection.send(event); } } - async syncUserListsEventHandler(message) { - let updateList = (list, updatedList, prop, event) => { + async handleSyncUserListsEvent(request) { + let updateList = (list, updatedList, Event) => { let added = new Set(updatedList); let removed = new Set(list); list.forEach(el => added.delete(el)); @@ -235,23 +368,19 @@ class Host extends libLink.Link { for (let name of added) { list.add(name); - this.broadcastEventToInstance( - { data: { name, [prop]: true }}, event - ); + this.broadcastEventToInstance(new Event(name, true)); } for (let name of removed) { list.delete(name); - this.broadcastEventToInstance( - { data: { name, [prop]: false }}, event - ); + this.broadcastEventToInstance(new Event(name, false)); } }; - updateList(this.adminlist, message.data.adminlist, "admin", libLink.messages.adminlistUpdate); - updateList(this.whitelist, message.data.whitelist, "whitelisted", libLink.messages.whitelistUpdate); + updateList(this.adminlist, request.adminlist, libData.InstanceAdminlistUpdateEvent); + updateList(this.whitelist, request.whitelist, libData.InstanceWhitelistUpdateEvent); - let addedOrChanged = new Map(message.data.banlist); + let addedOrChanged = new Map(request.banlist); let removed = new Set(this.banlist.keys()); addedOrChanged.forEach((_, name) => removed.delete(name)); this.banlist.forEach((reason, name) => { @@ -262,21 +391,17 @@ class Host extends libLink.Link { for (let [name, reason] of addedOrChanged) { this.banlist.set(name, reason); - this.broadcastEventToInstance( - { data: { name, banned: true, reason }}, libLink.messages.banlistUpdate - ); + this.broadcastEventToInstance(new libData.InstanceBanlistUpdateEvent(name, true, reason)); } for (let name of removed) { this.banlist.delete(name); - this.broadcastEventToInstance( - { data: { name, banned: false, reason: "" }}, libLink.messages.banlistUpdate - ); + this.broadcastEventToInstance(new libData.InstanceBanlistUpdateEvent(name, false, "")); } } - async adminlistUpdateEventHandler(message) { - let { name, admin } = message.data; + async handleAdminlistUpdateEvent(request) { + let { name, admin } = request; if (admin) { this.adminlist.add(name); } else { @@ -284,8 +409,8 @@ class Host extends libLink.Link { } } - async banlistUpdateEventHandler(message) { - let { name, banned, reason } = message.data; + async handleBanlistUpdateEvent(request) { + let { name, banned, reason } = request; if (banned) { this.banlist.set(name, reason); } else { @@ -293,8 +418,8 @@ class Host extends libLink.Link { } } - async whitelistUpdateEventHandler(message) { - let { name, whitelisted } = message.data; + async handleWhitelistUpdateEvent(request) { + let { name, whitelisted } = request; if (whitelisted) { this.whitelist.add(name); } else { @@ -302,21 +427,21 @@ class Host extends libLink.Link { } } - async assignInstanceRequestHandler(message) { - let { instance_id, serialized_config } = message.data; - let instanceInfo = this.instanceInfos.get(instance_id); + async handleInstanceAssignInternalRequest(request) { + let { instanceId, config } = request; + let instanceInfo = this.instanceInfos.get(instanceId); if (instanceInfo) { - instanceInfo.config.update(serialized_config, true, "controller"); - logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instance_id, instanceInfo)); + instanceInfo.config.update(config, true, "controller"); + logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instanceId, instanceInfo)); } else { - instanceInfo = this.discoveredInstanceInfos.get(instance_id); + instanceInfo = this.discoveredInstanceInfos.get(instanceId); if (instanceInfo) { - instanceInfo.config.update(serialized_config, true, "controller"); + instanceInfo.config.update(config, true, "controller"); } else { let instanceConfig = new libConfig.InstanceConfig("host"); - await instanceConfig.load(serialized_config, "controller"); + await instanceConfig.load(config, "controller"); let instanceDir = await this._createNewInstanceDir(instanceConfig.get("instance.name")); @@ -327,21 +452,22 @@ class Host extends libLink.Link { config: instanceConfig, }; - this.discoveredInstanceInfos.set(instance_id, instanceInfo); + this.discoveredInstanceInfos.set(instanceId, instanceInfo); } - this.instanceInfos.set(instance_id, instanceInfo); + this.instanceInfos.set(instanceId, instanceInfo); logger.verbose(`assigned instance ${instanceInfo.config.get("instance.name")}`); } // Somewhat hacky, but in the event of a lost session the status is // resent on assigment since the controller sends an assigment // request for all the instances it knows should be on this host. - let instanceConnection = this.instanceConnections.get(instance_id); - libLink.messages.instanceStatusChanged.send(this, { - instance_id, - status: instanceConnection ? instanceConnection.status : "stopped", - }); + let instanceConnection = this.instanceConnections.get(instanceId); + this.send( + new libData.InstanceStatusChangedEvent( + instanceId, instanceConnection ? instanceConnection.status : "stopped", null + ) + ); // save a copy of the instance config let warnedOutput = { @@ -354,13 +480,13 @@ class Host extends libLink.Link { ); } - async unassignInstanceRequestHandler(message) { - let instanceId = message.data.instance_id; + async handleInstanceUnassignInternalRequest(request) { + let instanceId = request.instanceId; let instanceInfo = this.instanceInfos.get(instanceId); if (instanceInfo) { let instanceConnection = this.instanceConnections.get(instanceId); if (instanceConnection && ["starting", "running"].includes(instanceConnection.status)) { - await libLink.messages.stopInstance.send(instanceConnection, { instance_id: instanceId }); + await instanceConnection.send(new libData.InstanceStopRequest()); } this.instanceInfos.delete(instanceId); @@ -396,7 +522,9 @@ class Host extends libLink.Link { throw new libErrors.RequestError(`Instance with ID ${instanceId} is running`); } - let [connectionClient, connectionServer] = libLink.VirtualConnector.makePair(); + let hostAddress = new libData.Address(libData.Address.host, this.id); + let instanceAddress = new libData.Address(libData.Address.instance, instanceId); + let [connectionClient, connectionServer] = libLink.VirtualConnector.makePair(instanceAddress, hostAddress); let instanceConnection = new InstanceConnection(connectionServer, this, instanceId); let instance = new Instance( this, connectionClient, instanceInfo.path, this.config.get("host.factorio_directory"), instanceInfo.config @@ -408,10 +536,10 @@ class Host extends libLink.Link { return instanceConnection; } - async getMetricsRequestHandler() { + async handleHostMetricsRequest() { let requests = []; for (let instanceConnection of this.instanceConnections.values()) { - requests.push(libLink.messages.getMetrics.send(instanceConnection)); + requests.push(instanceConnection.send(new libData.InstanceMetricsRequest())); } let results = []; @@ -434,86 +562,36 @@ class Host extends libLink.Link { return { results }; } - async startInstanceRequestHandler(message, request) { - let instanceId = message.data.instance_id; - try { - let instanceConnection = await this._connectInstance(instanceId); - return await request.send(instanceConnection, message.data); - } catch (err) { - if (!(err instanceof libErrors.RequestError)) { - logger.error(`Error starting instance:\n${err.stack}`, this.instanceLogMeta(instanceId)); - throw new libErrors.RequestError(err.message); - } - throw err; - } + async fallbackInstanceListSavesRequest(request, src, dst) { + let instanceInfo = this.getRequestInstanceInfo(dst.id); + return await Instance.listSaves(path.join(instanceInfo.path, "saves"), null); } - async loadScenarioRequestHandler(message, request) { - let instanceId = message.data.instance_id; - try { - let instanceConnection = await this._connectInstance(instanceId); - return await request.send(instanceConnection, message.data); - } catch (err) { - if (!(err instanceof libErrors.RequestError)) { - logger.error(`Error starting instance:\n${err.stack}`, this.instanceLogMeta(instanceId)); - throw new libErrors.RequestError(err.message); - } - throw err; - } - } - - async listSavesRequestHandler(message, request) { - let instanceId = message.data.instance_id; - let instanceConnection = this.instanceConnections.get(instanceId); - if (instanceConnection) { - return await request.send(instanceConnection, message.data); - } - + async handleInstanceRenameSaveRequest(request) { + let { instanceId, oldName, newName } = request; + checkRequestSaveName(oldName); + checkRequestSaveName(newName); let instanceInfo = this.getRequestInstanceInfo(instanceId); - return { - list: await Instance.listSaves(path.join(instanceInfo.path, "saves"), null), - }; - } - - async createSaveRequestHandler(message, request) { - let instanceId = message.data.instance_id; - try { - let instanceConnection = await this._connectInstance(instanceId); - await request.send(instanceConnection, message.data); - } catch (err) { - if (!(err instanceof libErrors.RequestError)) { - logger.error(`Error creating save:\n${err.stack}`, this.instanceLogMeta(instanceId)); - throw new libErrors.RequestError(err.message); - } - throw err; - } - } - - async renameSaveRequestHandler(message) { - let { instance_id, old_name, new_name } = message.data; - checkRequestSaveName(old_name); - checkRequestSaveName(new_name); - let instanceInfo = this.getRequestInstanceInfo(instance_id); try { await fs.move( - path.join(instanceInfo.path, "saves", old_name), - path.join(instanceInfo.path, "saves", new_name), + path.join(instanceInfo.path, "saves", oldName), + path.join(instanceInfo.path, "saves", newName), { overwrite: false }, ); } catch (err) { if (err.code === "ENOENT") { - throw new libErrors.RequestError(`${old_name} does not exist`); + throw new libErrors.RequestError(`${oldName} does not exist`); } throw err; } - await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves")); + await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); } - async copySaveRequestHandler(message) { - let { instance_id, source, destination } = message.data; + async handleInstanceCopySaveRequest(request) { + let { instanceId, source, destination } = request; checkRequestSaveName(source); checkRequestSaveName(destination); - let instanceInfo = this.getRequestInstanceInfo(instance_id); + let instanceInfo = this.getRequestInstanceInfo(instanceId); try { await fs.copy( path.join(instanceInfo.path, "saves", source), @@ -526,83 +604,83 @@ class Host extends libLink.Link { } throw err; } - await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves")); + await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); } - async transferSaveRequestHandler(message) { - let { source_save, target_save, copy, instance_id, target_instance_id } = message.data; - checkRequestSaveName(source_save); - checkRequestSaveName(target_save); - let sourceInstanceInfo = this.getRequestInstanceInfo(instance_id); - let targetInstanceInfo = this.getRequestInstanceInfo(target_instance_id); + async handleInstanceTransferSaveRequest(request) { + let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request; + checkRequestSaveName(sourceName); + checkRequestSaveName(targetName); + let sourceInstanceInfo = this.getRequestInstanceInfo(sourceInstanceId); + let targetInstanceInfo = this.getRequestInstanceInfo(targetInstanceId); // For consistency with remote transfer initiated through pullSave the // target is renamed if it already exists. - target_save = await libFileOps.findUnusedName( - path.join(targetInstanceInfo.path, "saves"), target_save, ".zip" + targetName = await libFileOps.findUnusedName( + path.join(targetInstanceInfo.path, "saves"), targetName, ".zip" ); try { if (copy) { await fs.copy( - path.join(sourceInstanceInfo.path, "saves", source_save), - path.join(targetInstanceInfo.path, "saves", target_save), + path.join(sourceInstanceInfo.path, "saves", sourceName), + path.join(targetInstanceInfo.path, "saves", targetName), { overwrite: true }, ); } else { await fs.move( - path.join(sourceInstanceInfo.path, "saves", source_save), - path.join(targetInstanceInfo.path, "saves", target_save), + path.join(sourceInstanceInfo.path, "saves", sourceName), + path.join(targetInstanceInfo.path, "saves", targetName), { overwrite: true }, ); } } catch (err) { if (err.code === "ENOENT") { - throw new libErrors.RequestError(`${source_save} does not exist`); + throw new libErrors.RequestError(`${sourceName} does not exist`); } throw err; } - await this.sendSaveListUpdate(instance_id, path.join(sourceInstanceInfo.path, "saves")); - await this.sendSaveListUpdate(target_instance_id, path.join(targetInstanceInfo.path, "saves")); + await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstanceInfo.path, "saves")); + await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstanceInfo.path, "saves")); - return { save: target_save }; + return targetName; } - async sendSaveListUpdate(instance_id, savesDir) { - let instanceConnection = this.instanceConnections.get(instance_id); + async sendSaveListUpdate(instanceId, savesDir) { + let instanceConnection = this.instanceConnections.get(instanceId); let saveList; if (instanceConnection) { - saveList = (await libLink.messages.listSaves.send(instanceConnection, { instance_id })).list; + saveList = await instanceConnection.send(new libData.InstanceListSavesRequest()); } else { saveList = await Instance.listSaves(savesDir, null); } - libLink.messages.saveListUpdate.send(this, { instance_id, list: saveList }); + this.send(new libData.InstanceSaveListUpdateEvent(instanceId, saveList)); } - async deleteSaveRequestHandler(message) { - let { instance_id, save } = message.data; - checkRequestSaveName(save); - let instanceInfo = this.getRequestInstanceInfo(instance_id); + async handleInstanceDeleteSaveRequest(request) { + let { instanceId, name } = request; + checkRequestSaveName(name); + let instanceInfo = this.getRequestInstanceInfo(instanceId); try { - await fs.unlink(path.join(instanceInfo.path, "saves", save)); + await fs.unlink(path.join(instanceInfo.path, "saves", name)); } catch (err) { if (err.code === "ENOENT") { - throw new libErrors.RequestError(`${save} does not exist`); + throw new libErrors.RequestError(`${name} does not exist`); } throw err; } - await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves")); + await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); } - async pullSaveRequestHandler(message) { - let { instance_id, stream_id, filename } = message.data; - checkRequestSaveName(filename); - let instanceInfo = this.getRequestInstanceInfo(instance_id); + async handleInstancePullSaveRequest(request) { + let { instanceId, streamId, name } = request; + checkRequestSaveName(name); + let instanceInfo = this.getRequestInstanceInfo(instanceId); let url = new URL(this.config.get("host.controller_url")); - url.pathname += `api/stream/${stream_id}`; + url.pathname += `api/stream/${streamId}`; let response = await phin({ url, method: "GET", core: { ca: this.tlsCa }, @@ -615,7 +693,7 @@ class Host extends libLink.Link { } let savesDir = path.join(instanceInfo.path, "saves"); - let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip"); + let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip"); let writeStream; while (true) { try { @@ -633,61 +711,47 @@ class Host extends libLink.Link { response.pipe(writeStream); await finished(writeStream); - filename = await libFileOps.findUnusedName(savesDir, filename, ".zip"); - await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, filename)); + name = await libFileOps.findUnusedName(savesDir, name, ".zip"); + await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, name)); - await this.sendSaveListUpdate(instance_id, savesDir); - return { save: filename }; + await this.sendSaveListUpdate(instanceId, savesDir); + return name; } - async pushSaveRequestHandler(message) { - let { instance_id, stream_id, save } = message.data; - checkRequestSaveName(save); - let instanceInfo = this.getRequestInstanceInfo(instance_id); + async handleInstancePushSaveRequest(request) { + let { instanceId, streamId, name } = request; + checkRequestSaveName(name); + let instanceInfo = this.getRequestInstanceInfo(instanceId); let content; try { // phin doesn't support streaming requests :( - content = await fs.readFile(path.join(instanceInfo.path, "saves", save)); + content = await fs.readFile(path.join(instanceInfo.path, "saves", name)); } catch (err) { if (err.code === "ENOENT") { - throw new libErrors.RequestError(`${save} does not exist`); + throw new libErrors.RequestError(`${name} does not exist`); } throw err; } let url = new URL(this.config.get("host.controller_url")); - url.pathname += `api/stream/${stream_id}`; + url.pathname += `api/stream/${streamId}`; phin({ url, method: "PUT", core: { ca: this.tlsCa }, data: content, }).catch(err => { - logger.error(`Error pushing save to controller:\n${err.stack}`, this.instanceLogMeta(instance_id)); + logger.error(`Error pushing save to controller:\n${err.stack}`, this.instanceLogMeta(instanceId)); }); } - async exportDataRequestHandler(message, request) { - let instanceId = message.data.instance_id; - try { - let instanceConnection = await this._connectInstance(instanceId); - await request.send(instanceConnection, message.data); - } catch (err) { - if (!(err instanceof libErrors.RequestError)) { - logger.error(`Error exporting data:\n${err.stack}`, this.instanceLogMeta(instanceId)); - throw new libErrors.RequestError(err.message); - } - throw err; - } - } - async stopInstance(instanceId) { let instanceConnection = this.instanceConnections.get(instanceId); - await libLink.messages.stopInstance.send(instanceConnection, { instance_id: instanceId }); + await instanceConnection.send(new libData.InstanceStopRequest()); } - async deleteInstanceRequestHandler(message) { - let instanceId = message.data.instance_id; + async handleInstanceDeleteInternalRequest(request) { + let instanceId = request.instanceId; if (this.instanceConnections.has(instanceId)) { throw new libErrors.RequestError(`Instance with ID ${instanceId} is running`); } @@ -713,12 +777,12 @@ class Host extends libLink.Link { let list = []; for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) { let instanceConnection = this.instanceConnections.get(instanceId); - list.push({ - serialized_config: instanceInfo.config.serialize("controller"), - status: instanceConnection ? instanceConnection.status : "stopped", - }); + list.push(new libData.RawInstanceInfo( + instanceInfo.config.serialize("controller"), + instanceConnection ? instanceConnection.status : "stopped", + )); } - await libLink.messages.updateInstances.send(this, { instances: list }); + await this.send(new libData.InstancesUpdateRequest(list)); // Handle configured auto startup instances if (this._startup) { @@ -728,10 +792,7 @@ class Host extends libLink.Link { if (instanceInfo.config.get("instance.auto_start")) { try { let instanceConnection = await this._connectInstance(instanceId); - await libLink.messages.startInstance.send(instanceConnection, { - instance_id: instanceId, - save: null, - }); + await instanceConnection.send(new libData.InstanceStartRequest(null)); } catch (err) { logger.error( `Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`, @@ -743,13 +804,12 @@ class Host extends libLink.Link { } } - async prepareDisconnectRequestHandler(message, request) { + async prepareDisconnect() { for (let instanceConnection of this.instanceConnections.values()) { - await libLink.messages.prepareControllerDisconnect.send(instanceConnection); + await instanceConnection.send(new libData.PrepareControllerDisconnectRequest()); } this._disconnecting = true; - this.connector.setClosing(); - return await super.prepareDisconnectRequestHandler(message, request); + return await super.prepareDisconnect(); } /** @@ -761,8 +821,16 @@ class Host extends libLink.Link { } this._shuttingDown = true; + for (let instanceId of this.instanceConnections.keys()) { + try { + await this.stopInstance(instanceId); + } catch (err) { + logger.error(`Unexpected error stopping instance:\n${err.stack}`); + } + } + try { - await libLink.messages.prepareDisconnect.send(this); + await this.connector.disconnect(); } catch (err) { if (!(err instanceof libErrors.SessionLost)) { logger.error(`Unexpected error preparing disconnect:\n${err.stack}`); @@ -770,11 +838,6 @@ class Host extends libLink.Link { } try { - for (let instanceId of this.instanceConnections.keys()) { - await this.stopInstance(instanceId); - } - await this.connector.close(1000, "Host Shutdown"); - // Clear silly interval in pidfile library. pidusage.clear(); } catch (err) { diff --git a/packages/host/src/Instance.js b/packages/host/src/Instance.js index d416e46c..d4e2b8aa 100644 --- a/packages/host/src/Instance.js +++ b/packages/host/src/Instance.js @@ -102,8 +102,7 @@ const serverSettingsActions = { */ class Instance extends libLink.Link { constructor(host, connector, dir, factorioDir, instanceConfig) { - super("instance", "host", connector); - libLink.attachAllMessages(this); + super(connector); this._host = host; this._dir = dir; @@ -211,6 +210,24 @@ class Instance extends libLink.Link { this._playerCheckInterval = null; this._hadPlayersOnline = false; this._playerAutosaveSlot = 1; + + this.handle(libData.InstanceExtractPlayersRequest, this.handleInstanceExtractPlayersRequest.bind(this)); + this.handle(libData.InstanceAdminlistUpdateEvent, this.handleInstanceAdminlistUpdateEvent.bind(this)); + this.handle(libData.InstanceBanlistUpdateEvent, this.handleInstanceBanlistUpdateEvent.bind(this)); + this.handle(libData.InstanceWhitelistUpdateEvent, this.handleInstanceWhitelistUpdateEvent.bind(this)); + this.handle(libData.ControllerConnectionEvent, this.handleControllerConnectionEvent.bind(this)); + this.handle( + libData.PrepareControllerDisconnectRequest, this.handlePrepareControllerDisconnectRequest.bind(this) + ); + this.handle(libData.InstanceMetricsRequest, this.handleInstanceMetricsRequest.bind(this)); + this.handle(libData.InstanceStartRequest, this.handleInstanceStartRequest.bind(this)); + this.handle(libData.InstanceLoadScenarioRequest, this.handleInstanceLoadScenarioRequest.bind(this)); + this.handle(libData.InstanceListSavesRequest, this.handleInstanceListSavesRequest.bind(this)); + this.handle(libData.InstanceCreateSaveRequest, this.handleInstanceCreateSaveRequest.bind(this)); + this.handle(libData.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this)); + this.handle(libData.InstanceStopRequest, this.handleInstanceStopRequest.bind(this)); + this.handle(libData.InstanceKillRequest, this.handleInstanceKillRequest.bind(this)); + this.handle(libData.InstanceSendRconRequest, this.handleInstanceSendRconRequest.bind(this)); } _watchPlayerJoinsByChat() { @@ -284,9 +301,9 @@ class Instance extends libLink.Link { instance_id: this.id, type: "join", name, - stats: stats.toJSON(), + stats, }; - libLink.messages.playerEvent.send(this, event); + this.sendTo("controller", new libData.InstancePlayerUpdateEvent("join", name, undefined, stats)); libPlugin.invokeHook(this.plugins, "onPlayerEvent", event); } @@ -306,13 +323,13 @@ class Instance extends libLink.Link { type: "leave", name, reason, - stats: stats.toJSON(), + stats, }; - libLink.messages.playerEvent.send(this, event); + this.sendTo("controller", new libData.InstancePlayerUpdateEvent("leave", name, reason, stats)); libPlugin.invokeHook(this.plugins, "onPlayerEvent", event); } - async extractPlayersRequestHandler() { + async handleInstanceExtractPlayersRequest() { const exportPlayerTimes = `/sc local players = {} for _, p in pairs(game.players) do @@ -320,6 +337,7 @@ for _, p in pairs(game.players) do end rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); let playerTimes = JSON.parse(await this.sendRcon(exportPlayerTimes)); + let count = 0; for (let [name, onlineTimeTicks] of Object.entries(playerTimes)) { let stats = this.playerStats.get(name); @@ -335,9 +353,11 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); name, stats: stats.toJSON(), }; - libLink.messages.playerEvent.send(this, event); + this.sendTo("controller", new libData.InstancePlayerUpdateEvent("import", name, undefined, stats)); libPlugin.invokeHook(this.plugins, "onPlayerEvent", event); + count += 1; } + this.logger.info(`Extracted data for ${count} player(s)`); } async sendRcon(message, expectEmpty, plugin = "") { @@ -371,24 +391,27 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); type = "special"; } - list.push({ - name, + list.push(new libData.SaveDetails( type, - size: stat.size, - mtime_ms: stat.mtimeMs, - loaded: name === loadedSave, - default: name === defaultSave, - }); + name, + stat.size, + stat.mtimeMs, + name === loadedSave, + name === defaultSave, + )); } return list; } async sendSaveListUpdate() { - libLink.messages.saveListUpdate.send(this, { - instance_id: this.id, - list: await Instance.listSaves(this.path("saves"), this._loadedSave), - }); + this.sendTo( + "controller", + new libData.InstanceSaveListUpdateEvent( + this.id, + await Instance.listSaves(this.path("saves"), this._loadedSave), + ), + ); } async _autosave(name) { @@ -415,11 +438,14 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); notifyStatus(status) { this._status = status; - libLink.messages.instanceStatusChanged.send(this, { - instance_id: this.id, - status, - game_port: this.server && this.server.gamePort || this.config.get("factorio.game_port") || null, - }); + this.sendTo( + "controller", + new libData.InstanceStatusChangedEvent( + this.id, + status, + this.server && this.server.gamePort || this.config.get("factorio.game_port") || null, + ), + ); } /** @@ -467,7 +493,6 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); let instancePlugin = new InstancePluginClass(pluginInfo, this, host); this.plugins.set(pluginInfo.name, instancePlugin); await instancePlugin.init(); - libPlugin.attachPluginMessages(this, instancePlugin); this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStarted}ms`); } @@ -528,7 +553,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); for (let [name, plugin] of this.plugins) { plugins[name] = plugin.info.version; } - libLink.messages.instanceInitialized.send(this, { instance_id: this.id, plugins }); + this.send(new libData.InstanceInitialisedEvent(plugins)); } /** @@ -598,13 +623,13 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); */ async syncMods() { const modPackId = this.config.get("factorio.mod_pack"); - let response; + let modPack; if (modPackId === null) { - response = await libLink.messages.getDefaultModPack.send(this); + modPack = await this.sendTo("controller", new libData.ModPackGetDefaultRequest()); } else { - response = await libLink.messages.getModPack.send(this, { id: modPackId }); + modPack = await this.sendTo("controller", new libData.ModPackGetRequest(modPackId)); } - this.activeModPack = new libData.ModPack(response.mod_pack); + this.activeModPack = modPack; // TODO validate factorioVersion @@ -698,21 +723,21 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); * * Creates a new save if no save is passed and patches it with modules. * - * @param {string|null} saveName - + * @param {string|undefined} saveName - * Save to prepare from the instance saves directory. Creates a new * save if null. * @returns {Promise} Name of the save prepared. */ async prepareSave(saveName) { // Use latest save if no save was specified - if (saveName === null) { + if (saveName === undefined) { saveName = await libFileOps.getNewestFile( this.path("saves"), (name) => !name.endsWith(".tmp.zip") ); } // Create save if no save was found. - if (saveName === null) { + if (saveName === undefined) { this.logger.info("Creating new save"); await this.server.create("world.zip"); saveName = "world.zip"; @@ -907,37 +932,36 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } } - async adminlistUpdateEventHandler(message) { + async handleInstanceAdminlistUpdateEvent(request) { if (!this.config.get("factorio.sync_adminlist")) { return; } - let { name, admin } = message.data; + let { name, admin } = request; let command = admin ? `/promote ${name}` : `/demote ${name}`; await this.sendRcon(command); } - async banlistUpdateEventHandler(message) { + async handleInstanceBanlistUpdateEvent(request) { if (!this.config.get("factorio.sync_banlist")) { return; } - let { name, banned, reason } = message.data; + let { name, banned, reason } = request; let command = banned ? `/ban ${name} ${reason}` : `/unban ${name}`; await this.sendRcon(command); } - async whitelistUpdateEventHandler(message) { + async handleInstanceWhitelistUpdateEvent(request) { if (!this.config.get("factorio.sync_whitelist")) { return; } - let { name, whitelisted } = message.data; + let { name, whitelisted } = request; let command = whitelisted ? `/whitelist add ${name}` : `/whiteliste remove ${name}`; await this.sendRcon(command); } - /** * Stop the instance */ @@ -962,15 +986,15 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); await this.server.kill(true); } - async controllerConnectionEventEventHandler(message) { - await libPlugin.invokeHook(this.plugins, "onControllerConnectionEvent", message.data.event); + async handleControllerConnectionEvent(event) { + await libPlugin.invokeHook(this.plugins, "onControllerConnectionEvent", event.event); } - async prepareControllerDisconnectRequestHandler() { + async handlePrepareControllerDisconnectRequest() { await libPlugin.invokeHook(this.plugins, "onPrepareControllerDisconnect"); } - async getMetricsRequestHandler() { + async handleInstanceMetricsRequest() { let results = []; if (!["stopped", "stopping"].includes(this._status)) { let pluginResults = await libPlugin.invokeHook(this.plugins, "onMetrics"); @@ -988,11 +1012,11 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); instanceFactorioMemoryUsage.labels(String(this.id)).set(stats.memory); } - return { results }; + return new libData.InstanceMetricsRequest.Response(results); } - async startInstanceRequestHandler(message) { - let saveName = message.data.save; + async handleInstanceStartRequest(request) { + let saveName = request.save; try { await this.prepare(); saveName = await this.prepareSave(saveName); @@ -1010,7 +1034,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } } - async loadScenarioRequestHandler(message) { + async handleInstanceLoadScenarioRequest(request) { if (this.config.get("factorio.enable_save_patching")) { this.notifyExit(); throw new libErrors.RequestError("Load scenario cannot be used with save patching enabled"); @@ -1024,22 +1048,20 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); throw err; } - let { scenario, seed, map_gen_settings, map_settings } = message.data; + let { scenario, seed, mapGenSettings, mapSettings } = request; try { - await this.startScenario(scenario, seed, map_gen_settings, map_settings); + await this.startScenario(scenario, seed, mapGenSettings, mapSettings); } catch (err) { await this.stop(); throw err; } } - async listSavesRequestHandler(message) { - return { - list: await Instance.listSaves(this.path("saves"), this._loadedSave), - }; + async handleInstanceListSavesRequest() { + return await Instance.listSaves(this.path("saves"), this._loadedSave); } - async createSaveRequestHandler(message) { + async handleInstanceCreateSaveRequest(request) { this.notifyStatus("creating_save"); try { this.logger.verbose("Writing server-settings.json"); @@ -1055,13 +1077,13 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } this.server.on("exit", () => this.notifyExit()); - let { name, seed, map_gen_settings, map_settings } = message.data; - await this.server.create(name, seed, map_gen_settings, map_settings); + let { name, seed, mapGenSettings, mapSettings } = request; + await this.server.create(name, seed, mapGenSettings, mapSettings); await this.sendSaveListUpdate(); this.logger.info("Successfully created save"); } - async exportDataRequestHandler() { + async handleInstanceExportDataRequest() { this.notifyStatus("exporting_data"); try { this.logger.verbose("Writing server-settings.json"); @@ -1093,17 +1115,16 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } } - async stopInstanceRequestHandler() { + async handleInstanceStopRequest() { await this.stop(); } - async killInstanceRequestHandler() { + async handleInstanceKillRequest() { await this.kill(); } - async sendRconRequestHandler(message) { - let result = await this.sendRcon(message.data.command); - return { result }; + async handleInstanceSendRconRequest(request) { + return await this.sendRcon(request.command); } /** diff --git a/packages/host/src/InstanceConnection.js b/packages/host/src/InstanceConnection.js index b7a94eb3..223863fa 100644 --- a/packages/host/src/InstanceConnection.js +++ b/packages/host/src/InstanceConnection.js @@ -1,4 +1,5 @@ "use strict"; +const libData = require("@clusterio/lib/data"); const libLink = require("@clusterio/lib/link"); const libPlugin = require("@clusterio/lib/plugin"); const libErrors = require("@clusterio/lib/errors"); @@ -6,57 +7,15 @@ const libErrors = require("@clusterio/lib/errors"); class InstanceConnection extends libLink.Link { constructor(connector, host, instanceId) { - super("host", "instance", connector); + super(connector); this.host = host; + this.router = this.host.router; this.instanceId = instanceId; this.plugins = new Map(); this.status = "stopped"; - libLink.attachAllMessages(this); - for (let pluginInfo of host.pluginInfos) { - libPlugin.attachPluginMessages(this, { info: pluginInfo }); - } - } - - async forwardRequestToController(message, request) { - return await request.send(this.host, message.data); - } - - async forwardRequestToInstance(message, request) { - let instanceId = message.data.instance_id; - let instanceConnection = this.host.instanceConnections.get(instanceId); - if (!instanceConnection) { - // Instance is probably on another host - return await this.forwardRequestToController(message, request); - } - - if (request.plugin && !instanceConnection.plugins.has(request.plugin)) { - throw new libErrors.RequestError(`Instance ID ${instanceId} does not have ${request.plugin} plugin loaded`); - } - - return await request.send(instanceConnection, message.data); - } - - - async forwardEventToInstance(message, event) { - let instanceId = message.data.instance_id; - let instanceConnection = this.host.instanceConnections.get(instanceId); - if (!instanceConnection) { - // Instance is probably on another host - await this.forwardEventToController(message, event); - return; - } - if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { return; } - - event.send(instanceConnection, message.data); - } - - async forwardEventToController(message, event) { - if (!this.host.connector.hasSession) { - return; - } - - event.send(this.host, message.data); + this.handle(libData.InstanceInitialisedEvent, this.handleInstanceInitialisedEvent.bind(this)); + this.snoopEvent(libData.InstanceStatusChangedEvent, this.snoopInstanceStatusChangedEvent.bind(this)); } async broadcastEventToInstance(message, event) { @@ -69,16 +28,15 @@ class InstanceConnection extends libLink.Link { } } - async instanceInitializedEventHandler(message, event) { - this.plugins = new Map(Object.entries(message.data.plugins)); + async handleInstanceInitialisedEvent(event) { + this.plugins = new Map(Object.entries(event.plugins)); } - async instanceStatusChangedEventHandler(message, event) { - this.status = message.data.status; + async snoopInstanceStatusChangedEvent(event) { + this.status = event.status; if (this.status === "stopped") { this.host.instanceConnections.delete(this.instanceId); } - this.forwardEventToController(message, event); } } diff --git a/packages/lib/PlayerStats.js b/packages/lib/PlayerStats.js index 19e36818..c13fa1cb 100644 --- a/packages/lib/PlayerStats.js +++ b/packages/lib/PlayerStats.js @@ -62,6 +62,10 @@ class PlayerStats { } } + static fromJSON(json) { + return new this(json); + } + toJSON() { let json = {}; if (this.joinCount) { diff --git a/packages/lib/command.js b/packages/lib/command.js index f66e70c7..59eea880 100644 --- a/packages/lib/command.js +++ b/packages/lib/command.js @@ -7,6 +7,7 @@ * @module lib/command */ "use strict"; +const libData = require("./data"); const libLink = require("./link"); const libErrors = require("./errors"); @@ -150,8 +151,8 @@ async function resolveHost(client, hostName) { if (/^-?\d+$/.test(hostName)) { hostId = parseInt(hostName, 10); } else { - let response = await libLink.messages.listHosts.send(client); - for (let host of response.list) { + let hosts = await client.sendTo("controller", new libData.HostListRequest()); + for (let host of hosts) { if (host.name === hostName) { hostId = host.id; break; @@ -182,8 +183,8 @@ async function resolveInstance(client, instanceName) { if (/^-?\d+$/.test(instanceName)) { instanceId = parseInt(instanceName, 10); } else { - let response = await libLink.messages.listInstances.send(client); - for (let instance of response.list) { + let instances = await client.sendTo("controller", new libData.InstanceDetailsListRequest()); + for (let instance of instances) { if (instance.name === instanceName) { instanceId = instance.id; break; @@ -215,10 +216,10 @@ async function resolveModPack(client, modPackName) { if (/^-?\d+/.test(modPackName)) { modPackId = parseInt(modPackName, 10); } else { - let response = await libLink.messages.listModPacks.send(client); - for (let mod_pack of response.list) { - if (mod_pack["name"] === modPackName) { - modPackId = mod_pack["id"]; + let modPacks = await client.sendTo("controller", new libData.ModPackListRequest()); + for (let modPack of modPacks) { + if (modPack.name === modPackName) { + modPackId = modPack.id; break; } } @@ -243,12 +244,12 @@ async function resolveModPack(client, modPackName) { * @static */ async function retrieveRole(client, roleName) { - let response = await libLink.messages.listRoles.send(client); + let roles = await client.sendTo("controller", new libData.RoleListRequest()); let resolvedRole; if (/^-?\d+$/.test(roleName)) { let roleId = parseInt(roleName, 10); - for (let role of response.list) { + for (let role of roles) { if (role.id === roleId) { resolvedRole = role; break; @@ -256,7 +257,7 @@ async function retrieveRole(client, roleName) { } } else { - for (let role of response.list) { + for (let role of roles) { if (role.name === roleName) { resolvedRole = role; break; diff --git a/packages/lib/data/ExportManifest.js b/packages/lib/data/ExportManifest.js index a07d663e..dcc226a2 100644 --- a/packages/lib/data/ExportManifest.js +++ b/packages/lib/data/ExportManifest.js @@ -22,6 +22,10 @@ class ExportManifest { */ assets = {}; + constructor(assets) { + if (assets) { this.assets = assets; } + } + static jsonSchema = { type: "object", additionalProperties: false, @@ -34,12 +38,8 @@ class ExportManifest { }, }; - constructor(json = {}) { - if (json.assets) { this.assets = json.assets; } - } - - toJSON() { - return { assets: this.assets }; + static fromJSON(json) { + return new this(json.assets); } } diff --git a/packages/lib/data/ModInfo.js b/packages/lib/data/ModInfo.js index e4953ae8..83cc175f 100644 --- a/packages/lib/data/ModInfo.js +++ b/packages/lib/data/ModInfo.js @@ -155,22 +155,26 @@ class ModInfo { static validate = libSchema.compile(this.jsonSchema); - constructor(json = {}) { + static fromJSON(json) { + const modInfo = new this(); + // info.json fields - if (json.name) { this.name = json.name; } - if (json.version) { this.version = json.version; } - if (json.title) { this.title = json.title; } - if (json.author) { this.author = json.author; } - if (json.contact) { this.contact = json.contact; } - if (json.homepage) { this.homepage = json.homepage; } - if (json.description) { this.description = json.description; } - if (json.factorio_version) { this.factorioVersion = json.factorio_version; } - if (json.dependencies) { this.dependencies = json.dependencies; } + if (json.name) { modInfo.name = json.name; } + if (json.version) { modInfo.version = json.version; } + if (json.title) { modInfo.title = json.title; } + if (json.author) { modInfo.author = json.author; } + if (json.contact) { modInfo.contact = json.contact; } + if (json.homepage) { modInfo.homepage = json.homepage; } + if (json.description) { modInfo.description = json.description; } + if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; } + if (json.dependencies) { modInfo.dependencies = json.dependencies; } // Additional data - if (json.size) { this.size = json.size; } - if (json.sha1) { this.sha1 = json.sha1; } - if (json.is_deleted) { this.isDeleted = json.is_deleted; } + if (json.size) { modInfo.size = json.size; } + if (json.sha1) { modInfo.sha1 = json.sha1; } + if (json.is_deleted) { modInfo.isDeleted = json.is_deleted; } + + return modInfo; } toJSON() { @@ -218,7 +222,7 @@ class ModInfo { throw new Error(`Mod's version (${modInfo.version}) is invalid`); } - return new this({ + return this.fromJSON({ ...modInfo, size: (await fs.stat(modPath)).size, diff --git a/packages/lib/data/ModPack.js b/packages/lib/data/ModPack.js index 8279cf03..f574b524 100644 --- a/packages/lib/data/ModPack.js +++ b/packages/lib/data/ModPack.js @@ -112,6 +112,19 @@ class ModPack { */ isDeleted = false; + shallowClone() { + const clone = new this.constructor(); + clone.id = this.id; + clone.name = this.name; + clone.description = this.description; + clone.factorioVersion = this.factorioVersion; + clone.mods = this.mods; + clone.settings = this.settings; + clone.exportManifest = this.exportManifest; + clone.isDeleted = this.isDeleted; + return clone; + } + static jsonSchema = { type: "object", additionalProperties: false, @@ -142,29 +155,33 @@ class ModPack { static validate = libSchema.compile(this.jsonSchema); - constructor(json = {}) { + static fromJSON(json) { + const modPack = new this(); + if (json.id) { - this.id = json.id; + modPack.id = json.id; } else { - this.id = Math.random() * 2**31 | 0; + modPack.id = Math.random() * 2**31 | 0; } - if (json.name) { this.name = json.name; } - if (json.description) { this.description = json.description; } - if (json.factorio_version) { this.factorioVersion = json.factorio_version; } - if (json.mods) { this.mods = new Map(json.mods.map(m => [m.name, m])); } + if (json.name) { modPack.name = json.name; } + if (json.description) { modPack.description = json.description; } + if (json.factorio_version) { modPack.factorioVersion = json.factorio_version; } + if (json.mods) { modPack.mods = new Map(json.mods.map(m => [m.name, m])); } if (json.settings) { - this.settings = { + modPack.settings = { "startup": new Map(Object.entries(json.settings["startup"])), "runtime-global": new Map(Object.entries(json.settings["runtime-global"])), "runtime-per-user": new Map(Object.entries(json.settings["runtime-per-user"])), }; } - if (json.export_manifest) { this.exportManifest = new ExportManifest(json.export_manifest); } - if (json.is_deleted) { this.isDeleted = json.is_deleted; } + if (json.export_manifest) { modPack.exportManifest = ExportManifest.fromJSON(json.export_manifest); } + if (json.is_deleted) { modPack.isDeleted = json.is_deleted; } - if (!this.mods.has("base")) { - this.mods.set("base", { name: "base", enabled: true, version: this.factorioVersion }); + if (!modPack.mods.has("base")) { + modPack.mods.set("base", { name: "base", enabled: true, version: modPack.factorioVersion }); } + + return modPack; } toJSON() { @@ -180,7 +197,7 @@ class ModPack { "runtime-per-user": Object.fromEntries(this.settings["runtime-per-user"]), }, }; - if (this.exportManifest) { json.export_manifest = this.exportManifest.toJSON(); } + if (this.exportManifest) { json.export_manifest = this.exportManifest; } if (this.isDeleted) { json.is_deleted = this.isDeleted; } return json; } @@ -217,7 +234,7 @@ class ModPack { throw new Error("Malformed mod pack string: Schema validation failed"); } - return new this(json); + return this.fromJSON(json); } toModSettingsDat() { diff --git a/packages/lib/data/composites.js b/packages/lib/data/composites.js new file mode 100644 index 00000000..de62d1c6 --- /dev/null +++ b/packages/lib/data/composites.js @@ -0,0 +1,32 @@ +"use strict"; + +function jsonArray(ItemClass) { + return { + jsonSchema: { + type: "array", + items: ItemClass.jsonSchema, + }, + + fromJSON(json) { + return json.map(i => ItemClass.fromJSON(i)); + }, + }; +} + +function jsonPrimitive(type) { + return { + jsonSchema: { type }, + fromJSON(json) { + return json; + }, + }; +} + +module.exports = { + JsonBoolean: jsonPrimitive("boolean"), + JsonNumber: jsonPrimitive("number"), + JsonString: jsonPrimitive("string"), + + jsonArray, + jsonPrimitive, +}; diff --git a/packages/lib/data/index.js b/packages/lib/data/index.js index f3856763..5522a49a 100644 --- a/packages/lib/data/index.js +++ b/packages/lib/data/index.js @@ -7,11 +7,25 @@ const ExportManifest = require("./ExportManifest"); const ModInfo = require("./ModInfo"); const ModPack = require("./ModPack"); +const composites = require("./composites"); +const messagesCore = require("./messages_core"); +const messagesController = require("./messages_controller"); +const messagesHost = require("./messages_host"); +const messagesInstance = require("./messages_instance"); +const messagesMod = require("./messages_mod"); +const messagesUser = require("./messages_user"); const version = require("./version"); module.exports = { ExportManifest, ModInfo, ModPack, + ...composites, + ...messagesCore, + ...messagesController, + ...messagesHost, + ...messagesInstance, + ...messagesMod, + ...messagesUser, ...version, }; diff --git a/packages/lib/data/messages_controller.js b/packages/lib/data/messages_controller.js new file mode 100644 index 00000000..0280320f --- /dev/null +++ b/packages/lib/data/messages_controller.js @@ -0,0 +1,384 @@ +"use strict"; +const { JsonString } = require("./composites"); + +// TODO: remove after config refactor +/** @memberof module:lib/data */ +class RawConfig { + /** @type {object} */ + serializedConfig; + + constructor(serializedConfig) { + this.serializedConfig = serializedConfig; + } + + static jsonSchema = { + type: "object", + required: ["serializedConfig"], + properties: { + "serializedConfig": { type: "object" }, + }, + }; + + static fromJSON(json) { + return new this(json.serializedConfig); + } +} + +/* @memberof module:lib/data */ +class ControllerConfigGetRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.controller.get_config"; + static Response = RawConfig; +} + +/** @memberof module:lib/data */ +class ControllerConfigSetFieldRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.controller.update_config"; + + /** @type {string} */ + field; + /** @type {string} */ + value; + + constructor(field, value) { + this.field = field; + this.value = value; + } + + static jsonSchema = { + type: "object", + required: ["field", "value"], + properties: { + "field": { type: "string" }, + "value": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.field, json.value); + } +} + +/* @memberof module:lib/data */ +class ControllerConfigSetPropRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.controller.update_config"; + + /** @type {string} */ + field; + /** @type {string} */ + prop; + /** @type {undefined} */ + value; + + constructor(field, prop, value) { + this.field = field; + this.prop = prop; + if (value) { this.value = value; } + } + + static jsonSchema = { + type: "object", + required: ["field", "prop"], + properties: { + field: { type: "string" }, + prop: { type: "string" }, + value: {}, + }, + }; + + static fromJSON(json) { + return new this(json.field, json.prop, json.value); + } +} + +/* @memberof module:lib/data */ +class HostGenerateTokenRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.host.generate_token"; + + /** @type {?number} */ + hostId; + + constructor(hostId) { + this.hostId = hostId; + } + + static jsonSchema = { + type: "object", + required: ["hostId"], + properties: { + hostId: { type: ["integer", "null"] }, + }, + }; + + static fromJSON(json) { + return new this(json.hostId); + } + + static Response = JsonString; +} + +/** @memberof module:lib/data */ +class HostConfigCreateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.host.create_config"; + + /** @type {?number} */ + id; + /** @type {?string} */ + name; + /** @type {boolean} */ + generateToken; + + constructor(id, name, generateToken) { + this.id = id; + this.name = name; + this.generateToken = generateToken; + } + + static jsonSchema = { + type: "object", + required: ["id", "name", "generateToken"], + properties: { + "id": { type: ["integer", "null"] }, + "name": { type: ["string", "null"] }, + "generateToken": { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.id, json.name, json.generateToken); + } + + static Response = RawConfig; +} + +/* @memberof module:lib/data */ +class LogSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.log.follow"; + + /** @type {boolean} */ + all; + /** @type {boolean} */ + controller; + /** @type {array} */ + hostIds; + /** @type {array} */ + instanceIds; + /** @type {?string} */ + maxLevel; + + constructor(all, controller, hostIds, instanceIds, maxLevel) { + this.all = all; + this.controller = controller; + this.hostIds = hostIds; + this.instanceIds = instanceIds; + this.maxLevel = maxLevel; + } + + static jsonSchema = { + type: "object", + required: ["all", "controller", "hostIds", "instanceIds", "maxLevel"], + properties: { + all: { type: "boolean" }, + controller: { type: "boolean" }, + hostIds: { + type: "array", + items: { type: "integer" }, + }, + instanceIds: { + type: "array", + items: { type: "integer" }, + }, + maxLevel: { type: ["string", "null"] }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.controller, json.hostIds, json.instanceIds, json.maxLevel); + } +} + +/* @memberof module:lib/data */ +class LogQueryRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.log.query"; + + /** @type {boolean} */ + all; + /** @type {boolean} */ + controller; + /** @type {Array} */ + hostIds; + /** @type {Array} */ + instanceIds; + /** @type {?string} */ + maxLevel; + /** @type {integer} */ + limit; + /** @type {undefined} */ + order; + + constructor(all, controller, hostIds, instanceIds, maxLevel, limit, order) { + this.all = all; + this.controller = controller; + this.hostIds = hostIds; + this.instanceIds = instanceIds; + this.maxLevel = maxLevel; + this.limit = limit; + this.order = order; + } + + static jsonSchema = { + type: "object", + required: ["all", "controller", "hostIds", "instanceIds", "maxLevel", "limit", "order"], + properties: { + all: { type: "boolean" }, + controller: { type: "boolean" }, + hostIds: { + type: "array", + items: { type: "integer" }, + }, + instanceIds: { + type: "array", + items: { type: "integer" }, + }, + maxLevel: { type: ["string", "null"] }, + limit: { type: "integer" }, + order: { + enum: ["asc", "desc"], + }, + }, + }; + + static fromJSON(json) { + return new this( + json.all, json.controller, json.hostIds, json.instanceIds, json.maxLevel, json.limit, json.order + ); + } +} +LogQueryRequest.Response = class Response { + /** @type {Array} */ + log; + + constructor(log) { + this.log = log; + } + + static jsonSchema = { + type: "object", + required: ["log"], + properties: { + log: { + type: "array", + items: { type: "object" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.log); + } +}; + +/** @memberof module:lib/data */ +class LogMessageEvent { + static type = "event"; + static src = ["host", "controller"]; + static dst = ["controller", "control"]; + + /** @type {object} */ + info; + + constructor(info) { + this.info = info; + } + + static jsonSchema = { + type: "object", + required: ["info"], + properties: { + "info": { + type: "object", + required: ["level", "message"], + properties: { + "level": { type: "string" }, + "message": { type: "string" }, + }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.info); + } +} + +/* @memberof module:lib/data */ +class DebugDumpWsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.debug.dump_ws"; +} + +/* @memberof module:lib/data */ +class DebugWsMessageEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {string} */ + direction; + /** @type {string} */ + content; + + constructor(direction, content) { + this.direction = direction; + this.content = content; + } + + static jsonSchema = { + type: "object", + required: ["direction", "content"], + properties: { + direction: { type: "string" }, + content: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.direction, json.content); + } +} + +module.exports = { + RawConfig, + ControllerConfigGetRequest, + ControllerConfigSetFieldRequest, + ControllerConfigSetPropRequest, + HostGenerateTokenRequest, + HostConfigCreateRequest, + LogSetSubscriptionsRequest, + LogQueryRequest, + LogMessageEvent, + DebugDumpWsRequest, + DebugWsMessageEvent, +}; diff --git a/packages/lib/data/messages_core.js b/packages/lib/data/messages_core.js new file mode 100644 index 00000000..2b74c125 --- /dev/null +++ b/packages/lib/data/messages_core.js @@ -0,0 +1,888 @@ +"use strict"; +const libSchema = require("../schema"); + + +/** @memberof module:lib/data */ +class Address { + /** Controller address */ + static controller = 0; + /** Instance address */ + static instance = 1; + /** Host address */ + static host = 2; + /** Control address */ + static control = 3; + /** Broadcast */ + static broadcast = 4; + + /** @type {number} */ + type; + + /** @type {string} */ + id; + + /** @type {string|undefined} */ + requestId; + + constructor(type, id, requestId) { + if (typeof type !== "number") { + throw Error(`Invalid type ${type}`); + } + this.type = type; + this.id = id; + this.requestId = requestId; + } + + /** + * Convert from convenient shorthand notation to address. + * + * Supported notations and their corresponding address: + * - `"controller"`: new Address(Address.controller, 0) + * - `{ hostId: id }`: new Address(Address.host, id) + * - `{ instanceId: id }`: new Address(Address.instance, id) + * - `{ controlId: id }`: new Address(Address.control, id) + * `"allHosts"`: new Address(Address.broadcast, Address.host) + * `"allInstances"`: new Address(Address.broadcast, Address.instance) + * `"allControls"`: new Address(Address.broadcast, Address.control) + * @param {module:lib/data.Address|string|object} shorthand - + * Shorthand to translate with. + * @returns {module:lib/data.Address} Translated address. + */ + static fromShorthand(shorthand) { + if (shorthand instanceof Address) { return shorthand; } + if (shorthand === "controller") { return new Address(Address.controller, 0); } + if (shorthand === "allHosts") { return new Address(Address.broadcast, Address.host); } + if (shorthand === "allInstances") { return new Address(Address.broadcast, Address.instance); } + if (shorthand === "allControls") { return new Address(Address.broadcast, Address.control); } + let entries = Object.entries(shorthand); + if (entries.length === 1) { + let [name, value] = entries[0]; + if (name === "hostId") { return new Address(Address.host, value); } + if (name === "instanceId") { return new Address(Address.instance, value); } + if (name === "controlId") { return new Address(Address.control, value); } + } + throw Error("Unrecognized shorthand"); + } + + static jsonSchema = { + type: "array", + minItems: 2, + maxItems: 3, + items: [ + { type: "integer", minimum: 0, maximum: 4 }, + { type: "integer" }, + { type: "integer" }, + ], + }; + + static fromJSON(json) { + return new this(json[0], json[1], json[2]); + } + + toJSON() { + if (this.requestId !== undefined) { + return [this.type, this.id, this.requestId]; + } + return [this.type, this.id]; + } + + toString() { + const typeMap = ["controller", "instance", "host", "control"]; + const type = typeMap[this.type] || this.type; + if (this.type === Address.broadcast) { + return `[Address ${type}:${typeMap[this.id] || this.id}]`; + } + if (this.requestId === undefined) { + return `[Address ${type}:${this.id}]`; + } + return `[Address ${type}:${this.id}:${this.requestId}]`; + } + + /** + * Returns a string suitable for indexing the address in a Map + * @returns {string} unique string for this address + */ + index() { + return `${this.type}:${this.id}:${this.requestId}`; + } + + /** + * Returns true if this address targets the given destination + * @param {module:lib/Data.Address} dst - Destination to check + * @returns {boolean} true if this address is addressed to the given destination + */ + addressedTo(dst) { + if (this.type !== Address.broadcast) { + return ( + this.type === dst.type + && this.id === dst.id + ); + } + return dst.type !== Address.broadcast && this.id === dst.type; + } + + equals(other) { + return ( + this.type === other.type + && this.id === other.id + && this.requestId === other.requestId + ); + } +} + +/** @memberof module:lib/data */ +class Message { + /** @type {string} */ + type; + + constructor(type) { + this.type = type; + } + + static jsonSchema; + static validate; + + static fromJSON(json) { + /* eslint-disable no-use-before-define */ + if (json.type === "hello") { return MessageHello.fromJSON(json); } + if (json.type === "registerHost") { return MessageRegisterHost.fromJSON(json); } + if (json.type === "registerControl") { return MessageRegisterControl.fromJSON(json); } + if (json.type === "ready") { return MessageReady.fromJSON(json); } + if (json.type === "resume") { return MessageResume.fromJSON(json); } + if (json.type === "continue") { return MessageContinue.fromJSON(json); } + if (json.type === "invalidate") { return MessageInvalidate.fromJSON(json); } + if (json.type === "heartbeat") { return MessageHeartbeat.fromJSON(json); } + if (json.type === "request") { return MessageRequest.fromJSON(json); } + if (json.type === "response") { return MessageResponse.fromJSON(json); } + if (json.type === "responseError") { return MessageResponseError.fromJSON(json); } + if (json.type === "event") { return MessageEvent.fromJSON(json); } + if (json.type === "disconnect") { return MessageDisconnect.fromJSON(json); } + /* eslint-enable no-use-before-define */ + throw new Error(`Unrecognized message type ${json.type}`); + } +} + +/** @memberof module:lib/data */ +class HelloData { + /** @type {string} */ + version; + /** @type {object} */ + plugins; + + constructor(version, plugins) { + this.version = version; + this.plugins = plugins; + } + + static jsonSchema = { + required: ["version", "plugins"], + properties: { + "version": { type: "string" }, + "plugins": { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.version, json.plugins); + } +} + +/** @memberof module:lib/data */ +class MessageHello extends Message { + /** @type {module:lib/data.HelloData} */ + data; + + constructor(data) { + super("hello"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "data"], + properties: { + "type": { const: "hello" }, + "data": HelloData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(HelloData.fromJSON(json.data)); + } +}; + +/** @memberof module:lib/data */ +class RegisterHostData { + /** @type {string} */ + token; + /** @type {string} */ + agent; + /** @type {string} */ + version; + /** @type {string} */ + name; + /** @type {string} */ + id; + /** @type {string|undefined} */ + publicAddress; + /** @type {Object} */ + plugins; + + constructor(token, agent, version, name, id, publicAddress, plugins) { + this.token = token; + this.agent = agent; + this.version = version; + this.name = name; + this.id = id; + this.publicAddress = publicAddress; + this.plugins = plugins; + } + + static jsonSchema = { + type: "object", + required: ["token", "agent", "version", "name", "id", "plugins"], + properties: { + "token": { type: "string" }, + "agent": { type: "string" }, + "version": { type: "string" }, + "name": { type: "string" }, + "id": { type: "integer" }, + "publicAddress": { type: "string" }, + "plugins": { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.token, json.agent, json.version, json.name, json.id, json.publicAddress, json.plugins); + } +} + +/** @memberof module:lib/data */ +class MessageRegisterHost extends Message { + /** @type {module:lib/data.RegisterHostData} */ + data; + + constructor(data) { + super("registerHost"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "data"], + properties: { + "type": { const: "registerHost" }, + "data": RegisterHostData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(RegisterHostData.fromJSON(json.data)); + } +}; + +/** @memberof module:lib/data */ +class RegisterControlData { + /** @type {string} */ + token; + /** @type {string} */ + agent; + /** @type {string} */ + version; + + constructor(token, agent, version) { + this.token = token; + this.agent = agent; + this.version = version; + } + + static jsonSchema = { + type: "object", + required: ["token", "agent", "version"], + properties: { + "token": { type: "string" }, + "agent": { type: "string" }, + "version": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.token, json.agent, json.version); + } +} + +/** @memberof module:lib/data */ +class MessageRegisterControl extends Message { + /** @type {module:lib/data.RegisterControlData} */ + data; + + constructor(data) { + super("registerControl"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["data"], + properties: { + "type": { const: "registerControl" }, + "data": RegisterControlData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(RegisterControlData.fromJSON(json.data)); + } +}; + +/** @memberof module:lib/data */ +class AccountDetails { + /** @type {string} */ + name; + /** @type {Array} */ + roles; + + constructor(name, roles) { + this.name = name; + this.roles = roles; + } + + static jsonSchema = { + additionalProperties: false, + required: ["name", "roles"], + properties: { + "name": { type: "string" }, + "roles": { + type: "array", + items: { + additionalProperties: false, + type: "object", + required: ["name", "id", "permissions"], + properties: { + "name": { type: "string" }, + "id": { type: "integer" }, + "permissions": { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.roles); + } +} + +/** @memberof module:lib/data */ +class ReadyData { + /** @type {module:lib/data.Address} */ + src; + /** @type {string} */ + sessionToken; + /** @type {number} */ + sessionTimeout; + /** @type {number} */ + heartbeatInterval; + /** @type {AccountDetails|undefined} */ + account; + + constructor(src, sessionToken, sessionTimeout, heartbeatInterval, account) { + this.src = src; + this.sessionToken = sessionToken; + this.sessionTimeout = sessionTimeout; + this.heartbeatInterval = heartbeatInterval; + if (account) { this.account = account; } + } + + static jsonSchema = { + additionalProperties: false, + required: ["src", "sessionToken", "sessionTimeout", "heartbeatInterval"], + properties: { + "src": Address.jsonSchema, + "sessionToken": { type: "string" }, + "sessionTimeout": { type: "number" }, + "heartbeatInterval": { type: "number" }, + "account": AccountDetails.jsonSchema, + }, + }; + + static fromJSON(json) { + let account; + if (json.account) { account = AccountDetails.fromJSON(json.account); } + return new this( + Address.fromJSON(json.src), + json.sessionToken, + json.sessionTimeout, + json.heartbeatInterval, + account + ); + } +} + +/** @memberof module:lib/data */ +class MessageReady extends Message { + /** @type {module:lib/data.ReadyData} */ + data; + + constructor(data) { + super("ready"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["data"], + properties: { + "type": { const: "ready" }, + "data": ReadyData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ReadyData.fromJSON(json.data)); + } +} + +/** @memberof module:lib/data */ +class ResumeData { + /** @type {string} */ + sessionToken; + /** @type {integer|undefined} */ + lastSeq; + + constructor(sessionToken, lastSeq) { + this.sessionToken = sessionToken; + if (lastSeq !== undefined) { this.lastSeq = lastSeq; } + } + + static jsonSchema = { + type: "object", + required: ["sessionToken"], + properties: { + "sessionToken": { type: "string" }, + "lastSeq": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.sessionToken, json.lastSeq); + } +} + + +/** @memberof module:lib/data */ +class MessageResume extends Message { + /** @type {module:lib/data.ResumeData} */ + data; + + constructor(data) { + super("resume"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["data"], + properties: { + "type": { const: "resume" }, + "data": ResumeData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ResumeData.fromJSON(json.data)); + } +} + +/** @memberof module:lib/data */ +class ContinueData { + /** @type {number} */ + sessionTimeout; + /** @type {number} */ + heartbeatInterval; + /** @type {integer|undefined} */ + lastSeq; + + constructor(sessionTimeout, heartbeatInterval, lastSeq) { + this.sessionTimeout = sessionTimeout; + this.heartbeatInterval = heartbeatInterval; + if (lastSeq !== undefined) { this.lastSeq = lastSeq; } + } + + static jsonSchema = { + required: ["sessionTimeout", "heartbeatInterval"], + properties: { + "sessionTimeout": { type: "number" }, + "heartbeatInterval": { type: "number" }, + "lastSeq": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.sessionTimeout, json.heartbeatInterval, json.lastSeq); + } +} + +/** @memberof module:lib/data */ +class MessageContinue extends Message { + /** @type {module:lib/data.ContinueData} */ + data; + + constructor(data) { + super("continue"); + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["data"], + properties: { + "type": { const: "continue" }, + "data": ContinueData.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ContinueData.fromJSON(json.data)); + } +} + +/** @memberof module:lib/data */ +class MessageInvalidate extends Message { + constructor() { + super("invalidate"); + } + + static jsonSchema = { + type: "object", + properties: { + "type": { const: "invalidate" }, + }, + }; + + static fromJSON(json) { + return new this(); + } +} + +/** @memberof module:lib/data */ +class MessageHeartbeat extends Message { + /** @type {integer|undefined} */ + seq; + + constructor(seq) { + super("heartbeat"); + this.seq = seq; + } + + static jsonSchema = { + type: "object", + required: ["type"], + properties: { + "type": { const: "heartbeat" }, + "seq": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.seq); + } +} + +/** @memberof module:lib/data */ +class MessageRequest extends Message { + /** @type {number} */ + seq; + /** @type {Address} */ + src; + /** @type {Address} */ + dst; + /** @type {string} */ + name; + /** @type {*} */ + data; + + constructor(seq, src, dst, name, data) { + super("request"); + this.seq = seq; + this.src = src; + this.dst = dst; + this.name = name; + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "seq", "src", "dst", "name"], + properties: { + "type": { const: "request" }, + "seq": { type: "integer" }, + "src": Address.jsonSchema, + "dst": Address.jsonSchema, + "name": { type: "string" }, + "data": {}, + }, + }; + + static fromJSON(json) { + return new this( + json.seq, + Address.fromJSON(json.src), + Address.fromJSON(json.dst), + json.name, + json.data, + ); + } +}; + +/** @memberof module:lib/data */ +class MessageResponse extends Message { + /** @type {number} */ + seq; + /** @type {Address} */ + src; + /** @type {Address} */ + dst; + /** @type {*} */ + data; + + constructor(seq, src, dst, data) { + super("response"); + this.seq = seq; + this.src = src; + this.dst = dst; + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "seq", "src", "dst"], + properties: { + "type": { const: "response" }, + "seq": { type: "integer" }, + "src": Address.jsonSchema, + "dst": Address.jsonSchema, + "data": {}, + }, + }; + + static fromJSON(json) { + return new this( + json.seq, + Address.fromJSON(json.src), + Address.fromJSON(json.dst), + json.data, + ); + } +}; + +/** @memberof module:lib/data */ +class ResponseError { + /** @type {string} */ + message; + /** @type {string|undefined} */ + code; + /** @type {string|undefined} */ + stack; + + constructor(message, code, stack) { + this.message = message; + if (code) { this.code = code; } + if (stack) { this.stack = stack; } + } + + static jsonSchema = { + required: ["message"], + properties: { + "message": { type: "string" }, + "code": { type: "string" }, + "stack": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.message, json.code, json.stack); + } +} + +/** @memberof module:lib/data */ +class MessageResponseError extends Message { + /** @type {number} */ + seq; + /** @type {Address} */ + src; + /** @type {Address} */ + dst; + /** @type {module:lib/data.ResponseError} */ + data; + + constructor(seq, src, dst, data) { + super("responseError"); + this.seq = seq; + this.src = src; + this.dst = dst; + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "seq", "src", "dst", "data"], + properties: { + "type": { const: "responseError" }, + "seq": { type: "integer" }, + "src": Address.jsonSchema, + "dst": Address.jsonSchema, + "data": ResponseError.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this( + json.seq, + Address.fromJSON(json.src), + Address.fromJSON(json.dst), + ResponseError.fromJSON(json.data), + ); + } +}; + +/** @memberof module:lib/data */ +class MessageEvent extends Message { + /** @type {number} */ + seq; + /** @type {Address} */ + src; + /** @type {Address} */ + dst; + /** @type {string} */ + name; + /** @type {*} */ + data; + + constructor(seq, src, dst, name, data) { + super("event"); + this.seq = seq; + this.src = src; + this.dst = dst; + this.name = name; + this.data = data; + } + + static jsonSchema = { + type: "object", + required: ["type", "seq", "src", "dst", "name"], + properties: { + "type": { const: "event" }, + "seq": { type: "integer" }, + "src": Address.jsonSchema, + "dst": Address.jsonSchema, + "name": { type: "string" }, + "data": {}, + }, + }; + + static fromJSON(json) { + return new this( + json.seq, + Address.fromJSON(json.src), + Address.fromJSON(json.dst), + json.name, + json.data, + ); + } +}; + +/** @memberof module:lib/data */ +class MessageDisconnect extends Message { + /** @type {string} */ + data; + + constructor(stage) { + super("disconnect"); + this.data = stage; + } + + static jsonSchema = { + type: "object", + required: ["type"], + properties: { + "type": { const: "disconnect" }, + "data": { enum: ["prepare", "ready"] }, + }, + }; + + static fromJSON(json) { + return new this(json.data); + } +}; + +Message.jsonSchema = { + type: "object", + required: ["type"], + properties: { + "type": { enum: [ + "hello", "registerHost", "registerControl", "ready", "resume", "continue", "invalidate", + "heartbeat", "request", "response", "responseError", "event", "disconnect", + ] }, + }, + oneOf: [ + MessageHello.jsonSchema, + MessageRegisterHost.jsonSchema, + MessageRegisterControl.jsonSchema, + MessageReady.jsonSchema, + MessageResume.jsonSchema, + MessageContinue.jsonSchema, + MessageInvalidate.jsonSchema, + MessageHeartbeat.jsonSchema, + MessageRequest.jsonSchema, + MessageResponse.jsonSchema, + MessageResponseError.jsonSchema, + MessageEvent.jsonSchema, + MessageDisconnect.jsonSchema, + ], +}; +Message.validate = libSchema.compile(Message.jsonSchema); + +/** @memberof module:lib/data */ +class PingRequest { + static type = "request"; + static src = ["controller", "host", "control"]; + static dst = ["controller", "host", "control"]; + static permission = null; +} + +module.exports = { + Message, + HelloData, + MessageHello, + RegisterHostData, + MessageRegisterHost, + RegisterControlData, + MessageRegisterControl, + AccountDetails, + ReadyData, + MessageReady, + ResumeData, + MessageResume, + ContinueData, + MessageContinue, + MessageInvalidate, + MessageHeartbeat, + Address, + MessageRequest, + MessageResponse, + ResponseError, + MessageResponseError, + MessageEvent, + MessageDisconnect, + PingRequest, +}; diff --git a/packages/lib/data/messages_host.js b/packages/lib/data/messages_host.js new file mode 100644 index 00000000..40b0b855 --- /dev/null +++ b/packages/lib/data/messages_host.js @@ -0,0 +1,240 @@ +"use strict"; +const { jsonArray } = require("./composites"); + + +/** @memberof module:lib/data */ +class HostDetails { + /** @type {string} */ + agent; + /** @type {string} */ + version; + /** @type {string} */ + name; + /** @type {number} */ + id; + /** @type {boolean} */ + connected; + /** @type {string|undefined} */ + publicAddress; + + constructor(agent, version, name, id, connected, publicAddress) { + this.agent = agent; + this.version = version; + this.name = name; + this.id = id; + this.connected = connected; + if (publicAddress !== undefined) { this.publicAddress = publicAddress; } + } + + static jsonSchema = { + required: ["agent", "version", "name", "id", "connected"], + properties: { + "agent": { type: "string" }, + "version": { type: "string" }, + "name": { type: "string" }, + "id": { type: "integer" }, + "connected": { type: "boolean" }, + "publicAddress": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.agent, json.version, json.name, json.id, json.connected, json.publicAddress); + } +} + +/** @memberof module:lib/data */ +class HostListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.host.list"; + static Response = jsonArray(HostDetails); +} + +/* @memberof module:lib/data */ +class HostSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.host.subscribe"; + + /** @type {boolean} */ + all; + /** @type {Array} */ + hostIds; + + constructor(all, hostIds) { + this.all = all; + this.hostIds = hostIds; + } + + static jsonSchema = { + type: "object", + required: ["all", "hostIds"], + properties: { + all: { type: "boolean" }, + hostIds: { + type: "array", + items: { type: "integer" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.hostIds); + } +} + + +/* @memberof module:lib/data */ +class HostUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {module:lib/data.HostDetails} */ + update; + + constructor(update) { + this.update = update; + } + + static jsonSchema = { + type: "object", + required: ["update"], + properties: { + update: HostDetails.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(HostDetails.fromJSON(json.update)); + } +} + +/** @memberof module:lib/data */ +class HostMetricsRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; +} +HostMetricsRequest.Response = class Response { // TODO: Use JSON class pattern in Prometheus + /** @type {array} */ + results; + + constructor(results) { + this.results = results; + } + + static jsonSchema = { + type: "object", + required: ["results"], + properties: { + "results": { + type: "array", + }, + }, + }; + + static fromJSON(json) { + return new this(json.results); + } +}; + +/** @memberof module:lib/data */ +class ControllerConnectionEvent { + static type = "event"; + static src = "host"; + static dst = "instance"; + + /** @type {string} */ + event; + + constructor(event) { + this.event = event; + } + + static jsonSchema = { + required: ["event"], + properties: { + "event": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.event); + } +} + +/* @memberof module:lib/data */ +class PrepareControllerDisconnectRequest { + static type = "request"; + static src = "host"; + static dst = "instance"; +} + +/** @memberof module:lib/data */ +class SyncUserListsEvent { + static type = "event"; + static src = "controller"; + static dst = "host"; + + /** @type {Set} */ + adminlist; + /** @type {Map} */ + banlist; + /** @type {Set} */ + whitelist; + + constructor(adminlist, banlist, whitelist) { + this.adminlist = adminlist; + this.banlist = banlist; + this.whitelist = whitelist; + } + + static jsonSchema = { + required: ["adminlist", "banlist", "whitelist"], + properties: { + "adminlist": { + type: "array", + items: { type: "string" }, + }, + "banlist": { + type: "array", + items: { + type: "array", + additionalItems: false, + items: [{ type: "string" }, { type: "string" }], + }, + }, + "whitelist": { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(new Set(json.adminlist), new Set(json.banlist), new Set(json.whitelist)); + } + + static toJSON() { + return { + adminlist: [...this.adminlist], + banlist: [...this.banlist], + whitelist: [...this.whitelist], + }; + } +} + +module.exports = { + HostDetails, + HostListRequest, + HostSetSubscriptionsRequest, + HostUpdateEvent, + HostMetricsRequest, + ControllerConnectionEvent, + PrepareControllerDisconnectRequest, + SyncUserListsEvent, +}; diff --git a/packages/lib/data/messages_instance.js b/packages/lib/data/messages_instance.js new file mode 100644 index 00000000..967f972a --- /dev/null +++ b/packages/lib/data/messages_instance.js @@ -0,0 +1,1327 @@ +"use strict"; +const PlayerStats = require("../PlayerStats"); +const { JsonString, jsonArray } = require("./composites"); + +/** @memberof module:lib/data */ +class InstanceDetails { + /** @type {string} */ + name; + /** @type {number} */ + id; + /** @type {?integer} */ + assignedHost; + /** @type {?integer} */ + gamePort; + /** @type {string} */ + status; + + constructor(name, id, assignedHost, gamePort, status) { + this.name = name; + this.id = id; + this.assignedHost = assignedHost; + this.gamePort = gamePort; + this.status = status; + } + + static jsonSchema = { + type: "object", + required: ["name", "id", "assignedHost", "gamePort", "status"], + properties: { + "name": { type: "string" }, + "id": { type: "integer" }, + "assignedHost": { type: ["null", "integer"] }, + "gamePort": { type: ["null", "integer"] }, + "status": { enum: [ + "unknown", "unassigned", "stopped", "starting", "running", "stopping", + "creating_save", "exporting_data", "deleted", + ]}, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.id, json.assignedHost, json.gamePort, json.status); + } +} + +/** @memberof module:lib/data */ +class InstanceDetailsGetRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.get"; + + /** @type {number} */ + instanceId; + + constructor(instanceId) { + this.instanceId = instanceId; + } + + static jsonSchema = { + type: "object", + required: ["instanceId"], + properties: { + "instanceId": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId); + } + + static Response = InstanceDetails; +} + +/** @memberof module:lib/data */ +class InstanceDetailsListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.list"; + static Response = jsonArray(InstanceDetails); +}; + +/** @memberof module:lib/data */ +class InstanceDetailsSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.subscribe"; + + /** @type {boolean} */ + all; + + /** @type {Array} */ + instanceIds; + + constructor(all, instanceIds) { + this.all = all || false; + this.instanceIds = instanceIds || []; + } + + static jsonSchema = { + type: "object", + properties: { + "all": { type: "boolean" }, + "instanceIds": { + type: "array", + items: { type: "integer" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.instanceIds); + } +}; + +/** @memberof module:lib/data */ +class InstanceDetailsUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {module:lib/data.InstanceDetails} */ + details; + + constructor(details) { + this.details = details; + } + + static jsonSchema = InstanceDetails.jsonSchema; + + toJSON() { + return this.details; + } + + static fromJSON(json) { + return new this(InstanceDetails.fromJSON(json)); + } +}; + +/** @memberof module:lib/data */ +class InstanceCreateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.create"; + + /** @type {object} */ + config; + + constructor(config) { + this.config = config; + } + + static jsonSchema = { + type: "object", + required: ["config"], + properties: { + "config": { type: "object" }, + }, + }; + + static fromJSON(json) { + // TODO deserialise config here after config refactor + return new this(json.config); + } +} + +/** @memberof module:lib/data */ +class InstanceConfigGetRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.get_config"; + + /** @type {number} */ + instanceId; + + constructor(instanceId) { + this.instanceId = instanceId; + } + + static jsonSchema = { + required: ["instanceId"], + properties: { + "instanceId": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId); + } +} +// TODO replace with InstanceConfig after config refactor +InstanceConfigGetRequest.Response = class Response { + /** @type {object} */ + config; + + constructor(config) { + this.config = config; + } + + static jsonSchema = { + type: "object", + required: ["config"], + properties: { + "config": { type: "object" }, + }, + }; + + static fromJSON(json) { + return new this(json.config); + } +}; + +/** @memberof module:lib/data */ +class InstanceConfigSetFieldRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.update_config"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + field; + /** @type {string} */ + value; + + constructor(instanceId, field, value) { + this.instanceId = instanceId; + this.field = field; + this.value = value; + } + + static jsonSchema = { + required: ["instanceId", "field", "value"], + properties: { + "instanceId": { type: "integer" }, + "field": { type: "string" }, + "value": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.field, json.value); + } +} + +/** @memberof module:lib/data */ +class InstanceConfigSetPropRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.update_config"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + field; + /** @type {string} */ + prop; + /** @type {*} */ + value; + + constructor(instanceId, field, prop, value) { + this.instanceId = instanceId; + this.field = field; + this.prop = prop; + if (value !== undefined) { + this.value = value; + } + } + + static jsonSchema = { + required: ["instanceId", "field", "prop"], + properties: { + "instanceId": { type: "integer" }, + "field": { type: "string" }, + "prop": { type: "string" }, + "value": {}, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.field, json.prop, json.value); + } +} + +/** @memberof module:lib/data */ +class InstanceAssignRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.assign"; + + /** @type {number} */ + instanceId; + /** @type {?number} */ + hostId; + + constructor(instanceId, hostId) { + this.instanceId = instanceId; + this.hostId = hostId; + } + + static jsonSchema = { + required: ["instanceId", "hostId"], + properties: { + "instanceId": { type: "number" }, + "hostId": { type: ["number", "null"] }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.hostId); + } +} + +/** @memberof module:lib/data */ +class InstanceMetricsRequest { + static type = "request"; + static src = "host"; + static dst = "instance"; +} +InstanceMetricsRequest.Response = class Response { // TODO: Use JSON class pattern in Prometheus + /** @type {array} */ + results; + + constructor(results) { + this.results = results; + } + + static jsonSchema = { + type: "object", + required: ["results"], + properties: { + "results": { + type: "array", + }, + }, + }; + + static fromJSON(json) { + return new this(json.results); + } +}; + +/** @memberof module:lib/data */ +class InstanceStartRequest { + static type = "request"; + static src = ["control", "controller"]; + static dst = "instance"; + static permission = "core.instance.start"; + + /** @type {string|undefined} */ + save; + + constructor(save) { + if (save !== undefined) { this.save = save; }; + } + + static jsonSchema = { + type: "object", + properties: { + "save": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.save); + } +} + +/** @memberof module:lib/data */ +class SaveDetails { + /** @type {string} */ + type; + /** @type {string} */ + name; + /** @type {number} */ + size; + /** @type {number} */ + mtimeMs; + /** @type {boolean} */ + loaded; + /** @type {boolean} */ + loadByDefault; + + constructor(type, name, size, mtimeMs, loaded, loadByDefault) { + this.type = type; + this.name = name; + this.size = size; + this.mtimeMs = mtimeMs; + this.loaded = loaded; + this.loadByDefault = loadByDefault; + } + + static jsonSchema = { + type: "object", + required: ["type", "name", "size", "mtimeMs", "loaded"], + properties: { + "type": { enum: ["file", "directory", "special"] }, + "name": { type: "string" }, + "size": { type: "integer" }, + "mtimeMs": { type: "number" }, + "loaded": { type: "boolean" }, + "loadByDefault": { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.type, json.name, json.size, json.mtimeMs, json.loaded, json.loadByDefault); + } +} + +/** @memberof module:lib/data */ +class InstanceListSavesRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.save.list"; + static Response = jsonArray(SaveDetails); +} + +/** @memberof module:lib/data */ +class InstanceSetSaveListSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.save.list_subscribe"; + + /** @type {boolean} */ + all; + + /** @type {Array} */ + instanceIds; + + constructor(all, instanceIds) { + this.all = all || false; + this.instanceIds = instanceIds || []; + } + + static jsonSchema = { + type: "object", + properties: { + "all": { type: "boolean" }, + "instanceIds": { + type: "array", + items: { type: "integer" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.instanceIds); + } +}; + +/** @memberof module:lib/data */ +class InstanceSaveListUpdateEvent { + static type = "event"; + static src = ["instance", "host", "controller"]; + static dst = ["controller", "control"]; + + /** @type {number} */ + instanceId; + /** @type {module:lib/data.SaveDetails} */ + saves; + + constructor(instanceId, saves) { + this.instanceId = instanceId; + this.saves = saves; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "saves"], + properties: { + "instanceId": { type: "integer" }, + "saves": { + type: "array", + items: SaveDetails.jsonSchema, + }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.saves.map(i => SaveDetails.fromJSON(i))); + } +} + +/** @memberof module:lib/data */ +class InstanceCreateSaveRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.save.create"; + + /** @type {string} */ + name; + /** @type {?number} */ + seed; + /** @type {?object} */ + mapGenSettings; + /** @type {?object} */ + mapSettings; + + constructor(name, seed, mapGenSettings, mapSettings) { + this.name = name; + if (seed) { this.seed = seed; } + if (mapGenSettings) { this.mapGenSettings = mapGenSettings; } + if (mapSettings) { this.mapSettings = mapSettings; } + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + "name": { type: "string" }, + "seed": { type: "integer" }, + "mapGenSettings": { type: "object" }, + "mapSettings": { type: "object" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.seed, json.mapGenSettings, json.mapSettings); + } +} + +/** @memberof module:lib/data */ +class InstanceRenameSaveRequest { + static type = "request"; + static src = ["control", "controller"]; + static dst = ["controller", "host"]; + static permission = "core.instance.save.rename"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + oldName; + /** @type {string} */ + newName; + + constructor(instanceId, oldName, newName) { + this.instanceId = instanceId; + this.oldName = oldName; + this.newName = newName; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "oldName", "newName"], + properties: { + "instanceId": { type: "integer" }, + "oldName": { type: "string" }, + "newname": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.oldName, json.newName); + } +} + +/** @memberof module:lib/data */ +class InstanceCopySaveRequest { + static type = "request"; + static src = ["control", "controller"]; + static dst = ["controller", "host"]; + static permission = "core.instance.save.copy"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + source; + /** @type {string} */ + destination; + + constructor(instanceId, source, destination) { + this.instanceId = instanceId; + this.source = source; + this.destination = destination; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "source", "destination"], + properties: { + "instanceId": { type: "integer" }, + "source": { type: "string" }, + "destination": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.source, json.destination); + } +} + +/** @memberof module:lib/data */ +class InstanceDeleteSaveRequest { + static type = "request"; + static src = ["control", "controller"]; + static dst = ["controller", "host"]; + static permission = "core.instance.save.delete"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + name; + + constructor(instanceId, name) { + this.instanceId = instanceId; + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "name"], + properties: { + "instanceId": { type: "integer" }, + "name": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.name); + } +} + +/** @memberof module:lib/data */ +class InstanceDownloadSaveRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.save.download"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + name; + + constructor(instanceId, name) { + this.instanceId = instanceId; + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + "instanceId": { type: "integer" }, + "name": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.name); + } + + static Response = JsonString; +} + +/** @memberof module:lib/data */ +class InstanceTransferSaveRequest { + static type = "request"; + static src = ["control", "controller"]; + static dst = ["controller", "host"]; + static permission(user, message) { + user.checkPermission("core.instance.save.transfer"); + if (message.data.copy) { + user.checkPermission("core.instance.save.copy"); + } else if (message.data.sourceName !== message.data.targetName) { + user.checkPermission("core.instance.save.rename"); + } + } + + + /** @type {number} */ + sourceInstanceId; + /** @type {string} */ + sourceName; + /** @type {number} */ + targetInstanceId; + /** @type {string} */ + targetName; + /** @type {boolean} */ + copy; + + constructor(sourceInstanceId, sourceName, targetInstanceId, targetName, copy) { + this.sourceInstanceId = sourceInstanceId; + this.sourceName = sourceName; + this.targetInstanceId = targetInstanceId; + this.targetName = targetName; + this.copy = copy; + } + + static jsonSchema = { + type: "object", + required: ["sourceInstanceId", "sourceName", "targetInstanceId", "targetName", "copy"], + properties: { + "sourceInstanceId": { type: "number" }, + "sourceName": { type: "string" }, + "targetInstanceId": { type: "number" }, + "targetName": { type: "string" }, + "copy": { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this( + json.sourceInstanceId, json.sourceName, json.targetInstanceId, json.targetName, json.copy + ); + } + + static Response = JsonString; +} + + +/** @memberof module:lib/data */ +class InstancePullSaveRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + streamId; + /** @type {string} */ + name; + + constructor(instanceId, streamId, name) { + this.instanceId = instanceId; + this.streamId = streamId; + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "streamId", "name"], + properties: { + "instanceId": { type: "integer" }, + "streamId": { type: "string" }, + "name": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.streamId, json.name); + } + + static Response = JsonString; +} + +/** @memberof module:lib/data */ +class InstancePushSaveRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; + + /** @type {number} */ + instanceId; + /** @type {string} */ + streamId; + /** @type {string} */ + name; + + constructor(instanceId, streamId, name) { + this.instanceId = instanceId; + this.streamId = streamId; + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "streamId", "name"], + properties: { + "instanceId": { type: "integer" }, + "streamId": { type: "string" }, + "name": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.streamId, json.name); + } +} + +/** @memberof module:lib/data */ +class InstanceLoadScenarioRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.load_scenario"; + + /** @type {string} */ + scenario; + /** @type {?number} */ + seed; + /** @type {?object} */ + mapGenSettings; + /** @type {?object} */ + mapSettings; + + constructor(scenario, seed, mapGenSettings, mapSettings) { + this.scenario = scenario; + if (seed) { this.seed = seed; } + if (mapGenSettings) { this.mapGenSettings = mapGenSettings; } + if (mapSettings) { this.mapSettings = mapSettings; } + } + + static jsonSchema = { + type: "object", + required: ["scenario"], + properties: { + "scenario": { type: "string" }, + "seed": { type: "integer" }, + "mapGenSettings": { type: "object" }, + "mapSettings": { type: "object" }, + }, + }; + + static fromJSON(json) { + return new this(json.scenario, json.seed, json.mapGenSettings, json.mapSettings); + } +} + +/** @memberof module:lib/data */ +class InstanceExportDataRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.export_data"; +} + +/** @memberof module:lib/data */ +class InstanceExtractPlayersRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.extract_players"; +} + +/** @memberof module:lib/data */ +class InstanceStopRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.stop"; +} + +/** @memberof module:lib/data */ +class InstanceKillRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.kill"; +} + +/** @memberof module:lib/data */ +class InstanceDeleteRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.instance.delete"; + + /** @type {number} */ + instanceId; + + constructor(instanceId) { + this.instanceId = instanceId; + } + + static jsonSchema = { + type: "object", + required: ["instanceId"], + properties: { + instanceId: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId); + } +} + +/** @memberof module:lib/data */ +class InstanceDeleteInternalRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; + + /** @type {number} */ + instanceId; + + constructor(instanceId) { + this.instanceId = instanceId; + } + + static jsonSchema = { + type: "object", + required: ["instanceId"], + properties: { + instanceId: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId); + } +} + +/** @memberof module:lib/data */ +class InstanceSendRconRequest { + static type = "request"; + static src = "control"; + static dst = "instance"; + static permission = "core.instance.send_rcon"; + + /** @type {string} */ + command; + + constructor(command) { + this.command = command; + } + + static jsonSchema = { + type: "object", + required: ["command"], + properties: { + "command": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.command); + } + + static Response = JsonString; +} + +// TODO remove this after config refactor +/** @memberof module:lib/data */ +class RawInstanceInfo { + /** @type {object} */ + config; + + /** @type {string} */ + status; + + constructor(config, status) { + this.config = config; + this.status = status; + } + + static jsonSchema = { + type: "object", + required: ["config", "status"], + properties: { + "config": { type: "object" }, + "status": { enum: [ + "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", + ]}, + }, + }; + + static fromJSON(json) { + return new this(json.config, json.status); + } +} + +/** @memberof module:lib/data */ +class InstancesUpdateRequest { + static type = "request"; + static src = "host"; + static dst = "controller"; + + /** @type {Array} */ + instances; + + constructor(instances) { + this.instances = instances; + } + + static jsonSchema = { + type: "array", + items: RawInstanceInfo.jsonSchema, + }; + + toJSON() { + return this.instances; + } + + static fromJSON(json) { + return new this(json.map(i => RawInstanceInfo.fromJSON(i))); + } +} + +/** @memberof module:lib/data */ +class InstanceAssignInternalRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; + + /** @type {number} */ + instanceId; + /** @type {object} */ + config; + + constructor(instanceId, config) { + this.instanceId = instanceId; + this.config = config; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "config"], + properties: { + "instanceId": { type: "integer" }, + "config": { type: "object" }, + }, + }; + + static fromJSON(json) { + // TODO deserialise config here after config refactor + return new this(json.instanceId, json.config); + } +} + +/** @memberof module:lib/data */ +class InstanceUnassignInternalRequest { + static type = "request"; + static src = "controller"; + static dst = "host"; + + /** @type {number} */ + instanceId; + + constructor(instanceId) { + this.instanceId = instanceId; + } + + static jsonSchema = { + type: "object", + required: ["instanceId"], + properties: { + "instanceId": { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId); + } +} + +/** @memberof module:lib/data */ +class InstanceInitialisedEvent { + static type = "event"; + static src = "instance"; + static dst = "host"; + + /** @type {Object} */ + plugins; + + constructor(plugins) { + this.plugins = plugins; + } + + static jsonSchema = { + type: "object", + required: ["plugins"], + properties: { + "plugins": { + type: "object", + additionalProperties: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.plugins); + } +} + +/** @memberof module:lib/data */ +class InstanceStatusChangedEvent { + static type = "event"; + static src = ["instance", "host"]; + static dst = "controller"; + + /** @type {integer} */ + instanceId; + /** @type {string} */ + status; + /** @type {?number} */ + gamePort; + + constructor(instanceId, status, gamePort) { + this.instanceId = instanceId; + this.status = status; + this.gamePort = gamePort; + } + + static jsonSchema = { + type: "object", + required: ["instanceId", "status", "gamePort"], + properties: { + "instanceId": { type: "integer" }, + "status": { + type: "string", + enum: [ + "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", + ], + }, + "gamePort": { type: ["null", "integer"] }, + }, + }; + + static fromJSON(json) { + return new this(json.instanceId, json.status, json.gamePort); + } +} + +/** @memberof module:lib/data */ +class InstanceDetailsChangedEvent { + static type = "event"; + static src = "instance"; + static dst = "controller"; + + /** @type {module:lib/data.InstanceDetails} */ + details; + + constructor(details) { + this.details = details; + } + + static jsonSchema = InstanceDetails.jsonSchema; + + static fromJSON(json) { + return new this(InstanceDetails.fromJSON(json)); + } +} + +/** @memberof module:lib/data */ +class InstanceBanlistUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "instance"; + + /** @type {string} */ + name; + + /** @type {boolean} */ + banned; + + /** @type {string} */ + reason; + + constructor(name, banned, reason) { + this.name = name; + this.banned = banned; + this.reason = reason; + } + + static jsonSchema = { + required: ["name", "banned", "reason"], + properties: { + "name": { type: "string" }, + "banned": { type: "boolean" }, + "reason": { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.banned, json.reason); + } +} + +/** @memberof module:lib/data */ +class InstanceAdminlistUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "instance"; + + /** @type {string} */ + name; + + /** @type {boolean} */ + admin; + + constructor(name, admin) { + this.name = name; + this.admin = admin; + } + + static jsonSchema = { + required: ["name", "admin"], + properties: { + "name": { type: "string" }, + "admin": { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.admin); + } +} + +/** @memberof module:lib/data */ +class InstanceWhitelistUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "instance"; + + /** @type {string} */ + name; + + /** @type {boolean} */ + whitelisted; + + constructor(name, whitelisted) { + this.name = name; + this.whitelisted = whitelisted; + } + + static jsonSchema = { + required: ["name", "whitelisted"], + properties: { + "name": { type: "string" }, + "whitelisted": { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.whitelisted); + } +} + +/** @memberof module:lib/data */ +class InstancePlayerUpdateEvent { + static type = "event"; + static src = "instance"; + static dst = "controller"; + + /** @type {string} */ + type; + + /** @type {string} */ + name; + + /** @type {?boolean} */ + reason; + + /** @type {?string} */ + stats; + + constructor(type, name, reason, stats) { + this.type = type; + this.name = name; + if (reason !== undefined) { this.reason = reason; } + if (stats !== undefined) { this.stats = stats; } + } + + static jsonSchema = { + required: ["type", "name"], + properties: { + "type": { type: "string", enum: ["join", "leave", "import"] }, + "name": { type: "string" }, + "reason": { type: "string" }, + "stats": PlayerStats.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.whitelisted); + } +} + +module.exports = { + InstanceDetails, + InstanceDetailsGetRequest, + InstanceDetailsListRequest, + InstanceDetailsSetSubscriptionsRequest, + InstanceDetailsUpdateEvent, + InstanceCreateRequest, + InstanceConfigGetRequest, + InstanceConfigSetFieldRequest, + InstanceConfigSetPropRequest, + InstanceAssignRequest, + InstanceMetricsRequest, + InstanceStartRequest, + SaveDetails, + InstanceListSavesRequest, + InstanceSetSaveListSubscriptionsRequest, + InstanceSaveListUpdateEvent, + InstanceCreateSaveRequest, + InstanceRenameSaveRequest, + InstanceCopySaveRequest, + InstanceDeleteSaveRequest, + InstanceDownloadSaveRequest, + InstanceTransferSaveRequest, + InstancePullSaveRequest, + InstancePushSaveRequest, + InstanceLoadScenarioRequest, + InstanceExportDataRequest, + InstanceExtractPlayersRequest, + InstanceStopRequest, + InstanceKillRequest, + InstanceDeleteRequest, + InstanceDeleteInternalRequest, + InstanceSendRconRequest, + RawInstanceInfo, + InstancesUpdateRequest, + InstanceAssignInternalRequest, + InstanceUnassignInternalRequest, + InstanceInitialisedEvent, + InstanceStatusChangedEvent, + InstanceDetailsChangedEvent, + InstanceBanlistUpdateEvent, + InstanceAdminlistUpdateEvent, + InstanceWhitelistUpdateEvent, + InstancePlayerUpdateEvent, +}; diff --git a/packages/lib/data/messages_mod.js b/packages/lib/data/messages_mod.js new file mode 100644 index 00000000..da311226 --- /dev/null +++ b/packages/lib/data/messages_mod.js @@ -0,0 +1,474 @@ +"use strict"; +const ModPack = require("./ModPack"); +const ModInfo = require("./ModInfo"); +const { jsonArray, JsonString } = require("./composites"); + + +/* @memberof module:lib/data */ +class ModPackGetRequest { + static type = "request"; + static src = ["instance", "control"]; + static dst = "controller"; + static permission = "core.mod_pack.get"; + + /** @type {number} */ + id; + + constructor(id) { + this.id = id; + } + + static jsonSchema = { + type: "object", + required: ["id"], + properties: { + id: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.id); + } + + static Response = ModPack; +} + +/* @memberof module:lib/data */ +class ModPackGetDefaultRequest { + static type = "request"; + static src = ["instance", "control"]; + static dst = "controller"; + static permission = "core.mod_pack.get"; + static Response = ModPack; +} + +/* @memberof module:lib/data */ +class ModPackListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod_pack.list"; + static Response = jsonArray(ModPack); +} + +/** @memberof module:lib/data */ +class ModPackCreateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod_pack.create"; + + /** @type {module:lib/data.ModPack} */ + modPack; + + constructor(modPack) { + this.modPack = modPack; + } + + static jsonSchema = { + type: "object", + required: ["modPack"], + properties: { + "modPack": ModPack.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ModPack.fromJSON(json.modPack)); + } +} + +/* @memberof module:lib/data */ +class ModPackUpdateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod_pack.update"; + + /** @type {module:lib/data.ModPack} */ + modPack; + + constructor(modPack) { + this.modPack = modPack; + } + + static jsonSchema = { + type: "object", + required: ["modPack"], + properties: { + "modPack": ModPack.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ModPack.fromJSON(json.modPack)); + } +} + +/* @memberof module:lib/data */ +class ModPackDeleteRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod_pack.delete"; + + /** @type {number} */ + id; + + constructor(id) { + this.id = id; + } + + static jsonSchema = { + type: "object", + required: ["id"], + properties: { + id: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.id); + } +} + +/* @memberof module:lib/data */ +class ModPackSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod_pack.subscribe"; + + /** @type {boolean} */ + all; + /** @type {Array} */ + modPackIds; + + constructor(all, modPackIds) { + this.all = all; + this.modPackIds = modPackIds; + } + + static jsonSchema = { + type: "object", + required: ["all", "modPackIds"], + properties: { + all: { type: "boolean" }, + modPackIds: { + type: "array", + items: { type: "integer" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.modPackIds); + } +} + +/* @memberof module:lib/data */ +class ModGetRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.get"; + + /** @type {string} */ + name; + /** @type {string} */ + version; + + constructor(name, version) { + this.name = name; + this.version = version; + } + + static jsonSchema = { + type: "object", + required: ["name", "version"], + properties: { + name: { type: "string" }, + version: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.version); + } + + static Response = ModInfo; +} + +/* @memberof module:lib/data */ +class ModListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.list"; + static Response = jsonArray(ModInfo); +} + +/* @memberof module:lib/data */ +class ModSearchRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.search"; + + /** @type {string} */ + query; + /** @type {string} */ + factorioVersion; + /** @type {number} */ + page; + /** @type {number|undefined} */ + pageSize; + /** @type {string|undefined} */ + sort; + /** @type {string|undefined} */ + sortOrder; + + constructor(query, factorioVersion, page, pageSize, sort, sortOrder) { + this.query = query; + this.factorioVersion = factorioVersion; + this.page = page; + if (pageSize) { this.pageSize = pageSize; } + if (sort) { this.sort = sort; } + if (sortOrder) { this.sortOrder = sortOrder; } + } + + static jsonSchema = { + type: "object", + required: ["query", "factorioVersion", "page"], + properties: { + query: { type: "string" }, + factorioVersion: { type: "string" }, + pageSize: { type: "integer" }, + page: { type: "integer" }, + sort: { type: "string" }, + sortOrder: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.query, json.factorioVersion, json.page, json.pageSize, json.sort, json.sortOrder); + } +} +ModSearchRequest.Response = class Response { + /** @type {Array} */ + queryIssues; + /** @type {number} */ + pageCount; + /** @type {number} */ + resultCount; + /** @type {Array} */ + results; + + constructor(queryIssues, pageCount, resultCount, results) { + this.queryIssues = queryIssues; + this.pageCount = pageCount; + this.resultCount = resultCount; + this.results = results; + } + + static jsonSchema = { + type: "object", + required: ["queryIssues", "pageCount", "resultCount", "results"], + properties: { + queryIssues: { + type: "array", + items: { type: "string" }, + }, + pageCount: { type: "integer" }, + resultCount: { type: "integer" }, + results: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + versions: { + type: "array", + items: ModInfo.jsonSchema, + }, + }, + }, + }, + }, + }; + + static fromJSON(json) { + let results = json.results.map( + ({ name, versions }) => ({ name, versions: versions.map(mod => ModInfo.fromJSON(mod)) }) + ); + return new this(json.queryIssues, json.pageCount, json.resultCount, results); + } +}; + +/* @memberof module:lib/data */ +class ModSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.subscribe"; + + /** @type {boolean} */ + all; + /** @type {Array} */ + modNames; + + constructor(all, modNames) { + this.all = all; + this.modNames = modNames; + } + + static jsonSchema = { + type: "object", + required: ["all", "modNames"], + properties: { + all: { type: "boolean" }, + modNames: { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.modNames); + } +} + +/* @memberof module:lib/data */ +class ModDownloadRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.download"; + + /** @type {string} */ + name; + /** @type {string} */ + version; + + constructor(name, version) { + this.name = name; + this.version = version; + } + + static jsonSchema = { + type: "object", + required: ["name", "version"], + properties: { + name: { type: "string" }, + version: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.version); + } + + static Response = JsonString; +} + +/* @memberof module:lib/data */ +class ModDeleteRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.mod.delete"; + + /** @type {string} */ + name; + /** @type {string} */ + version; + + constructor(name, version) { + this.name = name; + this.version = version; + } + + static jsonSchema = { + type: "object", + required: ["name", "version"], + properties: { + name: { type: "string" }, + version: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.version); + } +} + +/* @memberof module:lib/data */ +class ModPackUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {module:lib/data.ModPack} */ + modPack; + + constructor(modPack) { + this.modPack = modPack; + } + + static jsonSchema = { + type: "object", + required: ["modPack"], + properties: { + modPack: ModPack.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ModPack.fromJSON(json.modPack)); + } +} + +/* @memberof module:lib/data */ +class ModUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {module:lib/data.ModInfo} */ + mod; + + constructor(mod) { + this.mod = mod; + } + + static jsonSchema = { + type: "object", + required: ["mod"], + properties: { + mod: ModInfo.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(ModInfo.fromJSON(json.mod)); + } +} + +module.exports = { + ModPackGetRequest, + ModPackGetDefaultRequest, + ModPackListRequest, + ModPackCreateRequest, + ModPackUpdateRequest, + ModPackDeleteRequest, + ModPackSetSubscriptionsRequest, + ModGetRequest, + ModListRequest, + ModSearchRequest, + ModSetSubscriptionsRequest, + ModDownloadRequest, + ModDeleteRequest, + ModPackUpdateEvent, + ModUpdateEvent, +}; diff --git a/packages/lib/data/messages_user.js b/packages/lib/data/messages_user.js new file mode 100644 index 00000000..e45e1b4d --- /dev/null +++ b/packages/lib/data/messages_user.js @@ -0,0 +1,706 @@ +"use strict"; +const PlayerStats = require("../PlayerStats"); +const { jsonArray, JsonNumber } = require("./composites"); + +/* @memberof module:lib/data */ +class RawPermission { // TODO refactor into lib/user.Permission + /** @type {string} */ + name; + /** @type {string} */ + title; + /** @type {string} */ + description; + + constructor(name, title, description) { + this.name = name; + this.title = title; + this.description = description; + } + + static jsonSchema = { + required: ["name", "title", "description"], + properties: { + name: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.title, json.description); + } +} + +/* @memberof module:lib/data */ +class PermissionListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.permission.list"; + static Response = jsonArray(RawPermission); +} + +class RawRole { // TODO refactor into lib/user.Role + /** @type {number} */ + id; + /** @type {string} */ + name; + /** @type {string} */ + description; + /** @type {Array} */ + permissions; + + constructor(id, name, description, permissions) { + this.id = id; + this.name = name; + this.description = description; + this.permissions = permissions; + } + + static jsonSchema = { + type: "object", + required: ["id", "name", "description", "permissions"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + description: { type: "string" }, + permissions: { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.id, json.name, json.description, json.permissions); + } +} + +/* @memberof module:lib/data */ +class RoleListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.role.list"; + static Response = jsonArray(RawRole); +} + +/* @memberof module:lib/data */ +class RoleCreateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.role.create"; + + /** @type {string} */ + name; + /** @type {string} */ + description; + /** @type {Array} */ + permissions; + + constructor(name, description, permissions) { + this.name = name; + this.description = description; + this.permissions = permissions; + } + + static jsonSchema = { + type: "object", + required: ["name", "description", "permissions"], + properties: { + name: { type: "string" }, + description: { type: "string" }, + permissions: { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.description, json.permissions); + } + + static Response = JsonNumber; +} + +/* @memberof module:lib/data */ +class RoleUpdateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.role.update"; + + /** @type {number} */ + id; + /** @type {string} */ + name; + /** @type {string} */ + description; + /** @type {Array} */ + permissions; + + constructor(id, name, description, permissions) { + this.id = id; + this.name = name; + this.description = description; + this.permissions = permissions; + } + + static jsonSchema = { + type: "object", + required: ["id", "name", "description", "permissions"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + description: { type: "string" }, + permissions: { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.id, json.name, json.description, json.permissions); + } +} + +/* @memberof module:lib/data */ +class RoleGrantDefaultPermissionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.role.update"; + + /** @type {number} */ + id; + + constructor(id) { + this.id = id; + } + + static jsonSchema = { + type: "object", + required: ["id"], + properties: { + id: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.id); + } +} + +/* @memberof module:lib/data */ +class RoleDeleteRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.role.delete"; + + /** @type {number} */ + id; + + constructor(id) { + this.id = id; + } + + static jsonSchema = { + type: "object", + required: ["id"], + properties: { + id: { type: "integer" }, + }, + }; + + static fromJSON(json) { + return new this(json.id); + } +} + +/* @memberof module:lib/data */ +class RawUser { // TODO refactor into lib/user.User + /** @type {string} */ + name; + /** @type {Array} */ + roles; + /** @type {Array} */ + instances; + /** @type {boolean|undefined} */ + isAdmin; + /** @type {boolean|undefined} */ + isBanned; + /** @type {boolean|undefined} */ + isWhitelisted; + /** @type {string|undefined} */ + banReason; + /** @type {boolean|undefined} */ + isDeleted; + /** @type {module:lib/PlayerStats} */ + playerStats; + /** @type {Map|undefined} */ + instanceStats; + + constructor( + name, roles, instances, isAdmin, isBanned, isWhitelisted, banReason, + isDeleted, playerStats, instanceStats + ) { + this.name = name; + this.roles = roles; + this.instances = instances; + if (isAdmin !== undefined) { this.isAdmin = isAdmin; } + if (isBanned !== undefined) { this.isBanned = isBanned; } + if (isWhitelisted !== undefined) { this.isWhitelisted = isWhitelisted; } + if (banReason !== undefined) { this.banReason = banReason; } + if (isDeleted !== undefined) { this.isDeleted = isDeleted; } + if (playerStats !== undefined) { this.playerStats = playerStats; } + if (instanceStats !== undefined) { this.instanceStats = instanceStats; } + } + + static jsonSchema = { + type: "object", + required: ["name", "roles", "instances"], + properties: { + name: { type: "string" }, + roles: { type: "array", items: { type: "integer" } }, + instances: { type: "array", items: { type: "integer" } }, + isAdmin: { type: "boolean" }, + isBanned: { type: "boolean" }, + isWhitelisted: { type: "boolean" }, + banReason: { type: "string" }, + isDeleted: { type: "boolean" }, + playerStats: PlayerStats.jsonSchema, + instanceStats: { + type: "array", + items: { + type: "array", + items: [{ type: "integer" }, PlayerStats.jsonSchema], + }, + }, + }, + }; + + static fromJSON(json) { + let playerStats; + if (json.playerStats) { + playerStats = PlayerStats.fromJSON(json.playerStats); + } + let instanceStats; + if (json.instanceStats) { + instanceStats = new Map( + json.instanceStats.map(([id, stats]) => [id, PlayerStats.fromJSON(stats)]) + ); + } + return new this( + json.name, json.roles, json.instances, json.isAdmin, json.isBanned, json.isWhitelisted, json.banReason, + json.isDeleted, playerStats, instanceStats + ); + } + + toJSON() { + const json = { + name: this.name, + roles: this.roles, + instances: this.instances, + isAdmin: this.isAdmin, + isBanned: this.isBanned, + isWhitelisted: this.isWhitelisted, + banReason: this.banReason, + isDeleted: this.isDeleted, + playerStats: this.playerStats, + }; + if (this.instanceStats) { + json.instanceStats = [...this.instanceStats]; + } + return json; + } +} + +/* @memberof module:lib/data */ +class UserGetRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.get"; + + /** @type {string} */ + name; + + constructor(name) { + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name); + } + + static Response = RawUser; +} + +/* @memberof module:lib/data */ +class UserListRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.list"; + static Response = jsonArray(RawUser); +} + +/* @memberof module:lib/data */ +class UserSetSubscriptionsRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.subscribe"; + + /** @type {boolean} */ + all; + /** @type {Array} */ + names; + + constructor(all, names) { + this.all = all; + this.names = names; + } + + static jsonSchema = { + type: "object", + required: ["all", "names"], + properties: { + all: { type: "boolean" }, + names: { + type: "array", + items: { type: "string" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.all, json.names); + } +} + +/* @memberof module:lib/data */ +class UserCreateRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.create"; + + /** @type {string} */ + name; + + constructor(name) { + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name); + } +} + +/* @memberof module:lib/data */ +class UserRevokeTokenRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.revoke_token"; + + /** @type {string} */ + name; + + constructor(name) { + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name); + } +} + +/* @memberof module:lib/data */ +class UserUpdateRolesRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.update_roles"; + + /** @type {string} */ + name; + /** @type {Array} */ + roles; + + constructor(name, roles) { + this.name = name; + this.roles = roles; + } + + static jsonSchema = { + type: "object", + required: ["name", "roles"], + properties: { + name: { type: "string" }, + roles: { + type: "array", + items: { type: "integer" }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.roles); + } +} + +/* @memberof module:lib/data */ +class UserSetAdminRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.set_admin"; + + /** @type {string} */ + name; + /** @type {boolean} */ + create; + /** @type {boolean} */ + admin; + + constructor(name, create, admin) { + this.name = name; + this.create = create; + this.admin = admin; + } + + static jsonSchema = { + type: "object", + required: ["name", "create", "admin"], + properties: { + name: { type: "string" }, + create: { type: "boolean" }, + admin: { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.create, json.admin); + } +} + +/* @memberof module:lib/data */ +class UserSetWhitelistedRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.set_whitelisted"; + + /** @type {string} */ + name; + /** @type {boolean} */ + create; + /** @type {boolean} */ + whitelisted; + + constructor(name, create, whitelisted) { + this.name = name; + this.create = create; + this.whitelisted = whitelisted; + } + + static jsonSchema = { + type: "object", + required: ["name", "create", "whitelisted"], + properties: { + name: { type: "string" }, + create: { type: "boolean" }, + whitelisted: { type: "boolean" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.create, json.whitelisted); + } +} + +/* @memberof module:lib/data */ +class UserSetBannedRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.set_banned"; + + /** @type {string} */ + name; + /** @type {boolean} */ + create; + /** @type {boolean} */ + banned; + /** @type {string} */ + reason; + + constructor(name, create, banned, reason) { + this.name = name; + this.create = create; + this.banned = banned; + this.reason = reason; + } + + static jsonSchema = { + type: "object", + required: ["name", "create", "banned", "reason"], + properties: { + name: { type: "string" }, + create: { type: "boolean" }, + banned: { type: "boolean" }, + reason: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name, json.create, json.banned, json.reason); + } +} + +/* @memberof module:lib/data */ +class UserDeleteRequest { + static type = "request"; + static src = "control"; + static dst = "controller"; + static permission = "core.user.delete"; + + /** @type {string} */ + name; + + constructor(name) { + this.name = name; + } + + static jsonSchema = { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }; + + static fromJSON(json) { + return new this(json.name); + } +} + + +/* @memberof module:lib/data */ +class AccountUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {Array|undefined} */ + roles; + + constructor(roles) { + if (roles) { this.roles = roles; } + } + + static jsonSchema = { + type: "object", + properties: { + roles: { + type: "array", + items: { + type: "object", + required: ["name", "id", "permissions"], + properties: { + name: { type: "string" }, + id: { type: "integer" }, + permissions: { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, + }, + }; + + static fromJSON(json) { + return new this(json.roles); + } +} + +/* @memberof module:lib/data */ +class UserUpdateEvent { + static type = "event"; + static src = "controller"; + static dst = "control"; + + /** @type {module:lib/data.RawUser} */ + user; + + constructor(user) { + this.user = user; + } + + static jsonSchema = { + type: "object", + required: ["user"], + properties: { + user: RawUser.jsonSchema, + }, + }; + + static fromJSON(json) { + return new this(RawUser.fromJSON(json.user)); + } +} + +module.exports = { + RawPermission, + PermissionListRequest, + RawRole, + RoleListRequest, + RoleCreateRequest, + RoleUpdateRequest, + RoleGrantDefaultPermissionsRequest, + RoleDeleteRequest, + RawUser, + UserGetRequest, + UserListRequest, + UserSetSubscriptionsRequest, + UserCreateRequest, + UserRevokeTokenRequest, + UserUpdateRolesRequest, + UserSetAdminRequest, + UserSetWhitelistedRequest, + UserSetBannedRequest, + UserDeleteRequest, + AccountUpdateEvent, + UserUpdateEvent, +}; diff --git a/packages/lib/errors.js b/packages/lib/errors.js index 9e1d2f1c..e7de55df 100644 --- a/packages/lib/errors.js +++ b/packages/lib/errors.js @@ -14,7 +14,13 @@ class CommandError extends Error { } * Thrown from requests sent when an error occured handling it * @static */ -class RequestError extends Error { } +class RequestError extends Error { + constructor(message, code, stack) { + super(message); + if (code) { this.code = code; } + if (stack) { this.stack = stack; } + } +} /** * Thrown when a permission check fails. diff --git a/packages/lib/file_ops.js b/packages/lib/file_ops.js index 2d869e41..3a19ba2f 100644 --- a/packages/lib/file_ops.js +++ b/packages/lib/file_ops.js @@ -13,13 +13,13 @@ const crypto = require("crypto"); // needed for getTempFile * @param {function(string): boolean} filter - * Optional function to filter out files to ignore. Should return true * if the file is to be considered. - * @returns {Promise} + * @returns {Promise} * Name of the file with the newest timestamp or null if the * directory contains no files. */ async function getNewestFile(directory, filter = (name) => true) { let newestTime = new Date(0); - let newestFile = null; + let newestFile; for (let entry of await fs.readdir(directory, { withFileTypes: true })) { if (entry.isFile()) { let stat = await fs.stat(path.join(directory, entry.name)); diff --git a/packages/lib/link/connectors.js b/packages/lib/link/connectors.js index 68f8653d..a083e2c8 100644 --- a/packages/lib/link/connectors.js +++ b/packages/lib/link/connectors.js @@ -1,9 +1,10 @@ // Connection adapters for Links "use strict"; +const assert = require("assert").strict; const events = require("events"); const WebSocket = require("isomorphic-ws"); -const libSchema = require("../schema"); +const libData = require("../data"); const libErrors = require("../errors"); const { logger } = require("../logging"); const ExponentialBackoff = require("../ExponentialBackoff"); @@ -15,34 +16,105 @@ const ExponentialBackoff = require("../ExponentialBackoff"); * @extends events.EventEmitter * @memberof module:lib/link */ -class WebSocketBaseConnector extends events.EventEmitter { - constructor(sessionTimeout) { +class BaseConnector extends events.EventEmitter { + constructor(src, dst) { super(); + this._seq = 1; + this.src = src; + this.dst = dst; + } - this._sessionTimeout = sessionTimeout; + _reset() { + this._seq = 1; + } + + _invalidate() { + this._seq = 1; + } + + sendRequest(request, requestId, dst) { + let seq = this._seq; + this._seq += 1; + let src = new libData.Address(this.src.type, this.src.id, requestId); + let Request = request.constructor; + let name = Request.plugin ? `${Request.plugin}:${Request.name}` : Request.name; + let hasData = Boolean(Request.jsonSchema); + const message = new libData.MessageRequest(seq, src, dst, name, hasData ? request : undefined); + this.send(message); + return seq; + } + + sendResponse(response, dst, src = this.src) { + let seq = this._seq; + this._seq += 1; + const message = new libData.MessageResponse(seq, src, dst, response); + this.send(message); + return seq; + } + + sendResponseError(error, dst, src = this.src) { + let seq = this._seq; + this._seq += 1; + const message = new libData.MessageResponseError(seq, src, dst, error); + this.send(message); + return seq; + } + + sendEvent(event, dst) { + let seq = this._seq; + this._seq += 1; + let Event = event.constructor; + let name = Event.plugin ? `${Event.plugin}:${Event.name}` : Event.name; + let hasData = Boolean(Event.jsonSchema); + const message = new libData.MessageEvent(seq, this.src, dst, name, hasData ? event : undefined); + this.send(message); + return seq; + } +} + +/** + * Base connector for links + * + * @extends module:lib/link.BaseConnector + * @memberof module:lib/link + */ +class WebSocketBaseConnector extends BaseConnector { + constructor(src, dst) { + super(src, dst); // One of closed, connecting (client only), connected and resuming. this._state = "closed"; this._closing = false; this._socket = null; - this._seq = 1; this._lastHeartbeat = null; this._heartbeatId = null; this._heartbeatInterval = null; - this._lastReceivedSeq = null; + this._lastReceivedSeq = undefined; this._sendBuffer = []; } _reset() { this._state = "closed"; this._closing = false; - this._socket = null; - this._seq = 1; + assert(this._socket === null); this._lastHeartbeat = null; - this._heartbeatId = null; + assert(this._heartbeatId === null); this._heartbeatInterval = null; - this._lastReceivedSeq = null; + this._lastReceivedSeq = undefined; this._sendBuffer.length = 0; + super._reset(); + } + + _invalidate() { + this._state = "connecting"; + assert(this._closing === false); + assert(this._socket); + this._lastHeartbeat = null; + assert(this._heartbeatId === null); + this._heartbeatInterval = null; + this._lastReceivedSeq = undefined; + this._sendBuffer.length = 0; + super._invalidate(); } _check(...expectedStates) { @@ -54,7 +126,7 @@ class WebSocketBaseConnector extends events.EventEmitter { } _dropSendBufferSeq(seq) { - if (seq === null) { + if (seq === undefined) { return; } @@ -69,6 +141,45 @@ class WebSocketBaseConnector extends events.EventEmitter { this._sendBuffer.splice(0, dropCount); } + _parseMessage(text) { + let json; + try { + json = JSON.parse(text); + } catch (err) { + this.close(4000, "Malformed JSON"); + return undefined; + } + if (!libData.Message.validate(json)) { + logger.error(`Received malformed message: ${text}`); + // logger.error(JSON.stringify(libData.Message.validate.errors, null, 4)); + this.close(4000, "Malformed message"); + return undefined; + } + return libData.Message.fromJSON(json); + } + + _processMessage(message) { + if (message.type === "heartbeat") { + this._processHeartbeat(message); + + } else if (message.type === "disconnect") { + if (message.data === "ready") { + this.emit("disconnectReady"); + + } else if (message.data === "prepare") { + this.setClosing(); + this.emit("disconnectPrepare"); + } + + } else { + if (message.seq !== undefined) { + this._lastReceivedSeq = message.seq; + } + + this.emit("message", message); + } + } + _doHeartbeat() { this._check("connected"); if (Date.now() - this._lastHeartbeat > 2000 * this._heartbeatInterval) { @@ -81,23 +192,13 @@ class WebSocketBaseConnector extends events.EventEmitter { } } else { - this._socket.send(JSON.stringify({ - seq: null, - type: "heartbeat", - data: { seq: this._lastReceivedSeq }, - })); + this._sendInternal(new libData.MessageHeartbeat(this._lastReceivedSeq)); } } _processHeartbeat(message) { - if (!libSchema.heartbeat(message)) { - logger.warn("Connector | closing after received invalid heartbeat"); - this._socket.close(1002, "Invalid heartbeat"); - return; - } - this._lastHeartbeat = Date.now(); - this._dropSendBufferSeq(message.data.seq); + this._dropSendBufferSeq(message.seq); } /** @@ -120,29 +221,30 @@ class WebSocketBaseConnector extends events.EventEmitter { if (this._heartbeatId) { clearInterval(this._heartbeatId); this._heartbeatId = null; - this._lastHeartbeat = null; } } /** * Send a message over the socket * - * @param {string} type - Message type to send. - * @param {Object} data - Data to send with message. - * @returns {number} the sequence number of the message sent + * This is a low level method that should only be used for implementing + * links. See sendTo for sending requests and events. + * + * @param {module:lib/data.Message} message - Message to send. */ - send(type, data) { + send(message) { if (!["connected", "resuming"].includes(this._state)) { throw new libErrors.SessionLost("No session"); } - let seq = this._seq; - this._seq += 1; - let message = { seq, type, data }; + this._sendBuffer.push(message); if (this._state === "connected") { - this._socket.send(JSON.stringify(message)); + this._sendInternal(message); } - return seq; + } + + _sendInternal(message) { + this._socket.send(JSON.stringify(message)); } /** @@ -155,11 +257,40 @@ class WebSocketBaseConnector extends events.EventEmitter { throw new Error("Abstract function"); } + /** + * Gracefully disconnect + * + * Sets closing flag an initiates the disconnect sequence. + */ + async disconnect() { + if (this._state !== "connected") { + await this.close(1000, "Disconnect"); + return; + } + + this.setClosing(); + this.send(new libData.MessageDisconnect("prepare")); + + let timeout; + let waitTimeout = new Promise(resolve => { timeout = setTimeout(resolve, 10000); }); // Make Configurable? + try { + await Promise.race([ + waitTimeout, + events.once(this, "close"), + events.once(this, "disconnectReady"), + ]); + } finally { + clearTimeout(timeout); + } + + await this.close(1000, "Disconnect"); + } + /** * True if the connection is established and active */ get connected() { - return this._state === "connected"; + return !this._closing && this._state === "connected"; } /** @@ -186,7 +317,7 @@ class WebSocketBaseConnector extends events.EventEmitter { */ class WebSocketClientConnector extends WebSocketBaseConnector { constructor(url, maxReconnectDelay, tlsCa = null) { - super(null); + super(undefined, new libData.Address(libData.Address.controller, 0)); this._url = url; this._backoff = new ExponentialBackoff({ max: maxReconnectDelay }); @@ -199,32 +330,40 @@ class WebSocketClientConnector extends WebSocketBaseConnector { // connected: Connection is online this._reconnectId = null; this._sessionToken = null; + this._sessionTimeout = null; this._startedResuming = null; } _reset() { clearTimeout(this._reconnectId); this._reconnectId = null; - this._lastReceivedSeq = null; this._sessionToken = null; this._sessionTimeout = null; - this._sendBuffer.length = 0; this._startedResuming = null; super._reset(); + this.src = undefined; + } + + _invalidate() { + assert(this._reconnectId === null); + this._sessionToken = null; + this._sessionTimeout = null; + this._startedResuming = null; + super._invalidate(); + this.src = undefined; } /** * Send a handshake message over the socket * - * Used in the register function in order to send the register handshake - * message over the WebSocket. + * Used in the register function in order to send the handshake messages + * over the WebSocket. * - * @param {string} type - Message type to send. - * @param {Object} data - Data to send with message. + * @param {module:lib/data.Message} message - Message to send. */ - sendHandshake(type, data) { + sendHandshake(message) { this._check("connecting", "resuming"); - this._socket.send(JSON.stringify({ seq: null, type, data })); + this._sendInternal(message); } /** @@ -329,8 +468,13 @@ class WebSocketClientConnector extends WebSocketBaseConnector { ) { logger.error("Connector | Session timed out trying to resume"); this._reset(); - this._state = "connecting"; - this.emit("invalidate"); + if (this._disconnecting) { + this._reset(); + this.emit("close"); + } else { + this._state = "connecting"; + this.emit("invalidate"); + } } logger.verbose( `Connector | waiting ${(Math.round(delay / 10) / 100)} seconds for reconnect` @@ -416,21 +560,15 @@ class WebSocketClientConnector extends WebSocketBaseConnector { // Handle messages this._socket.onmessage = event => { - let message = JSON.parse(event.data); + let message = this._parseMessage(event.data); + if (!message) { + return; + } if (["connecting", "resuming"].includes(this._state)) { this._processHandshake(message); } else if (this._state === "connected") { - if (message.seq !== null) { - this._lastReceivedSeq = message.seq; - } - - if (message.type === "heartbeat") { - this._processHeartbeat(message); - - } else { - this.emit("message", message); - } + this._processMessage(message); } else { throw new Error(`Received message in unexpected state ${this._state}`); @@ -439,11 +577,6 @@ class WebSocketClientConnector extends WebSocketBaseConnector { } _processHandshake(message) { - if (!libSchema.serverHandshake(message)) { - logger.warn("Connector | closing after received invalid handshake"); - this._socket.close(1002, "Invalid handshake"); - return; - } let { type, data } = message; if (type === "hello") { @@ -451,10 +584,9 @@ class WebSocketClientConnector extends WebSocketBaseConnector { this.emit("hello", data); if (this._sessionToken) { logger.verbose("Connector | Attempting resume"); - this.sendHandshake("resume", { - session_token: this._sessionToken, - last_seq: this._lastReceivedSeq, - }); + this.sendHandshake(new libData.MessageResume( + new libData.ResumeData(this._sessionToken, this._lastReceivedSeq) + )); } else { this.register(); } @@ -462,39 +594,39 @@ class WebSocketClientConnector extends WebSocketBaseConnector { } else if (type === "ready") { logger.verbose("Connector | received ready from controller"); this._state = "connected"; - this._sessionToken = data.session_token; - this._sessionTimeout = data.session_timeout; - this._heartbeatInterval = data.heartbeat_interval; + this.src = data.src; + this._sessionToken = data.sessionToken; + this._sessionTimeout = data.sessionTimeout; + this._heartbeatInterval = data.heartbeatInterval; this.startHeartbeat(); for (let bufferedMessage of this._sendBuffer) { - this._socket.send(JSON.stringify(bufferedMessage)); + this._sendInternal(bufferedMessage); } this.emit("connect", data); } else if (type === "continue") { logger.info("Connector | resuming existing session"); this._state = "connected"; - this._heartbeatInterval = data.heartbeat_interval; - this._sessionTimeout = data.session_timeout; + this._heartbeatInterval = data.heartbeatInterval; + this._sessionTimeout = data.sessionTimeout; this.startHeartbeat(); - this._dropSendBufferSeq(data.last_seq); + this._dropSendBufferSeq(data.lastSeq); for (let bufferedMessage of this._sendBuffer) { - this._socket.send(JSON.stringify(bufferedMessage)); + this._sendInternal(bufferedMessage); } this._startedResuming = null; this.emit("resume"); } else if (type === "invalidate") { logger.warn("Connector | session invalidated by controller"); - this._state = "connecting"; - this._seq = 1; - this._lastReceivedSeq = null; - this._sessionToken = null; - this._sessionTimeout = null; - this._sendBuffer.length = 0; - this._startedResuming = null; - this.emit("invalidate"); - this.register(); + if (this._disconnecting) { + this._reset(); + this.emit("close"); + } else { + this._invalidate(); + this.emit("invalidate"); + this.register(); + } } } @@ -515,12 +647,11 @@ class WebSocketClientConnector extends WebSocketBaseConnector { * @extends events.EventEmitter * @memberof module:lib/link */ -class VirtualConnector extends events.EventEmitter { - constructor() { - super(); +class VirtualConnector extends BaseConnector { + constructor(src, dst) { + super(src, dst); this.other = this; - this._seq = 1; } /** @@ -529,32 +660,30 @@ class VirtualConnector extends events.EventEmitter { * Creates two virtual connectors that are liked to each othes such that * a message sent on one is received by the other. * + * @param {module:lib/data.Address} src - Source for obverse connector + * @param {module:lib/data.Address} dst - destination for obverse connector * @returns {Array} two virtuarl connectors. */ - static makePair() { - let first = new this(); - let second = new this(); - first.other = second; - second.other = first; - return [first, second]; + static makePair(src, dst) { + let obverse = new this(src, dst); + let reverse = new this(dst, src); + obverse.other = reverse; + reverse.other = obverse; + return [obverse, reverse]; } /** * Send a message to the other end of the connector * - * @param {string} type - Message type to send. - * @param {Object} data - Data to send with message. - * @returns {number} the sequence number of the message sent + * @param {module:lib/data.Message} message - Message type to send. */ - send(type, data) { - let seq = this._seq; - this._seq += 1; - this.other.emit("message", { seq, type, data }); - return seq; + send(message) { + this.other.emit("message", message); } } module.exports = { + BaseConnector, WebSocketBaseConnector, WebSocketClientConnector, VirtualConnector, diff --git a/packages/lib/link/index.js b/packages/lib/link/index.js index a156979c..132e6150 100644 --- a/packages/lib/link/index.js +++ b/packages/lib/link/index.js @@ -5,11 +5,13 @@ */ "use strict"; const link = require("./link"); -const messages = require("./messages"); const connectors = require("./connectors"); module.exports = { ...link, - ...messages, ...connectors, + + // migrate: Allow info for plugins from before link refactor to load. + Event: class Event {}, + Request: class Request {}, }; diff --git a/packages/lib/link/link.js b/packages/lib/link/link.js index cb292889..54c33afe 100644 --- a/packages/lib/link/link.js +++ b/packages/lib/link/link.js @@ -1,9 +1,11 @@ // Implementation of Link class "use strict"; -const libSchema = require("../schema"); +const libData = require("../data"); const libErrors = require("../errors"); const { logger } = require("../logging"); +const libSchema = require("../schema"); +const { dataClasses } = require("./messages"); // Some definitions for the terminology used here: // link: Either side of a controller - client connection @@ -11,8 +13,6 @@ const { logger } = require("../logging"); // connection: the controller side of a link // client: the side that is not the controller of a link // message: the complete object sent using the 'message' event -// data: the data property of a message, essentially the payload. -// type: the type property of a message, identifies what to expect in data. /** * Common interface for server and client connections @@ -20,222 +20,334 @@ const { logger } = require("../logging"); * @memberof module:lib/link */ class Link { - constructor(source, target, connector) { - this.source = source; - this.target = target; + constructor(connector) { this.connector = connector; + this.router = undefined; + this.validateSent = true; - this._waiters = new Map(); - this._handlers = new Map(); - this._validators = new Map(); + this._requestHandlers = new Map(); + this._requestFallbacks = new Map(); + this._eventHandlers = new Map(); + this._eventSnoopers = new Map(); + this._pendingRequests = new Map(); + this._forwardedRequests = new Map(); + this._nextRequestId = 1; + + this.handle(libData.PingRequest, () => {}); // Process messages received by the connector connector.on("message", payload => { try { - this.processMessage(payload); + this._processMessage(payload); } catch (err) { if (err instanceof libErrors.InvalidMessage) { - logger.error(`Invalid message on ${this.source}-${this.target} link: ${err.message}`); + logger.error(err.message); if (err.errors) { logger.error(JSON.stringify(err.errors, null, 4)); } - // Send back an error response if this was a request. - if ( - typeof payload.type === "string" - && payload.type.endsWith("_request") - && Number.isInteger(payload.seq) - ) { - this.connector.send( - `${payload.type.slice(0, -8)}_response`, - { seq: payload.seq, error: err.message } - ); - } } else { + // Unexpected error, bubble back to connector this.connector.emit("error", err); } } }); - connector.on("invalidate", () => { - for (let waiterType of this._waiters.values()) { - for (let waiter of waiterType) { - waiter.reject(new libErrors.SessionLost("Session Lost")); + connector.on("disconnectPrepare", () => { + this.prepareDisconnect().catch( + err => { + logger.error(`Unexpected error preparing disconnect:\n${err.stack}`); } - } + ).finally( + () => { + this.connector.send(new libData.MessageDisconnect("ready")); + }, + ); + }); - this._waiters.clear(); + connector.on("invalidate", () => { + this._clearPendingRequests(new libErrors.SessionLost("Session Lost")); }); connector.on("close", () => { - for (let waiterType of this._waiters.values()) { - for (let waiter of waiterType) { - waiter.reject(new libErrors.SessionLost("Session Closed")); - } - } - - this._waiters.clear(); + this._clearPendingRequests(new libErrors.SessionLost("Session Closed")); }); } + _clearPendingRequests(err) { + for (let pending of this._pendingRequests.values()) { + pending.reject(err); + } + for (let pending of this._forwardedRequests.values()) { + if (pending.origin.connector.hasSession) { + pending.origin.connector.sendResponseError( + new libData.ResponseError(err.message, err.code), pending.src, pending.dst + ); + } + } + this._pendingRequests.clear(); + this._forwardedRequests.clear(); + } + /** * Process a received message on the link * * Validates and invokes the handler and/or waiters for a message that has * been received. An unhandled message is considered to be an error. * - * @param {Object} message - Message to process. - * @throws {module:lib/errors.InvalidMessage} - * if validation failed, is missing, the message is invalid, or no - * handlers/waiters were invoked to handle the message. + * @param {module:lib/data.Message} message - Message to process. + * @throws {module:lib/errors.InvalidMessage} if the message is invalid or not handled. */ - processMessage(message) { - if (!libSchema.message(message)) { - throw new libErrors.InvalidMessage("Malformed", libSchema.message.errors); + _processMessage(message) { + if (!["request", "response", "responseError", "event"].includes(message.type)) { + throw new libErrors.InvalidMessage(`Unhandled message type ${message.type}`); } - let validator = this._validators.get(message.type); - if (!validator) { - throw new libErrors.InvalidMessage(`No validator for ${message.type} on ${this.source}-${this.target}`); + let entry = this._validateMessage(message); + if (entry && this.connector.dst.type === libData.Address.control) { + try { + this.validatePermission(message, entry); // Somewhat hacky, defined in ControlConnection + } catch (err) { + if (err instanceof libErrors.PermissionError) { + return; + } + throw err; + } } - if (!validator(message)) { - throw new libErrors.InvalidMessage(`Validation failed for ${message.type}`, validator.errors); + if (message.type === "event" && this._eventSnoopers.has(entry.Event)) { + let handler = this._eventSnoopers.get(entry.Event); + handler(entry.eventFromJSON(message.data), message.src, message.dst).catch(err => { + logger.error(`Unexpected error snooping ${message.name}:\n${err.stack}`); + }); } - let hadHandlers = this._processHandler(message); - let hadWaiters = this._processWaiters(message); + if (!message.dst.addressedTo(this.connector.src)) { + if (message.type === "response" || message.type === "responseError") { + this._forwardedRequests.delete(message.dst.index()); + } + this._routeMessage(message, entry); + return; + } - if (!hadWaiters && !hadHandlers) { - // XXX console.error(`Unhandled message: ${JSON.stringify(message, null, 4)}`); - throw new libErrors.InvalidMessage(`Unhandled message ${message.type}`); + if (message.type === "request") { + this._processRequest(message, entry, this._requestHandlers.get(entry.Request)); + + } else if (message.type === "response") { + this._processResponse(message); + + } else if (message.type === "responseError") { + this._processResponseError(message); + + } else if (message.type === "event") { + this._processEvent(message, entry); } } /** - * Invoke the handler for this message if it exists + * Ingress message validation * - * @param {Object} message - Message to invoke handlers on. - * @returns {Boolean} true if a handler was invoked. + * Should be overridden by sub-classes to validate messages received. + * + * @param {module:lib/data.Message} message - Message to check. + * @throws {module:lib/errors.InvalidMessage} if the message is invalid. */ - _processHandler(message) { - let handler = this._handlers.get(message.type); - if (handler) { - handler(message); - return true; - } - return false; - } + validateIngress(message) { } - /** - * Invokes the waiters for this message if any - * - * @param {Object} message - Message to invoke waiters on. - * @returns {Boolean} true if a waiter was triggered. - */ - _processWaiters(message) { - let { type, data } = message; - let waiters = this._waiters.get(type); - if (!waiters || !waiters.length) { - return false; + _validateMessage(message) { + try { + if (message.src.type === libData.Address.broadcast) { + throw new libErrors.InvalidMessage("Message src may not be broadcast"); + } + this.validateIngress(message); + } catch (err) { + if (message.type === "request") { + this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src); + } + throw err; } - let matched = []; - waitersLoop: for (let index = 0; index < waiters.length; index++) { - let waiter = waiters[index]; - for (let [key, value] of Object.entries(waiter.data)) { - if (data[key] !== value) { - continue waitersLoop; + if (message.type === "request") { + let entry = this.constructor._requestsByName.get(message.name); + if (!entry) { + let err = new libErrors.InvalidMessage(`Unrecognized request ${message.name}`); + this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src); + throw err; + } + if (!entry.allowedSrcTypes.has(message.src.type)) { + let err = new libErrors.InvalidMessage(`Source ${message.src} is not allowed for ${message.name}`); + this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src); + throw err; + } + if (!entry.allowedDstTypes.has(message.dst.type)) { + let err = new libErrors.InvalidMessage(`Destination ${message.dst} is not allowed for ${message.name}`); + this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src); + throw err; + } + return entry; + + } else if (message.type === "response") { + return undefined; + + } else if (message.type === "responseError") { + return undefined; + + } else if (message.type === "event") { + let entry = this.constructor._eventsByName.get(message.name); + if (!entry) { + throw new libErrors.InvalidMessage(`Unrecognized event ${message.name}`); + } + if (!entry.allowedSrcTypes.has(message.src.type)) { + throw new libErrors.InvalidMessage(`Source ${message.src} is not allowed for ${message.name}`); + } + if (message.dst.type === libData.Address.broadcast) { + if (!entry.allowedDstTypes.has(message.dst.id)) { + throw new libErrors.InvalidMessage(`Destination ${message.dst} is not allowed for ${message.name}`); + } + } else { + // eslint-disable-next-line no-lonely-if + if (!entry.allowedDstTypes.has(message.dst.type)) { + throw new libErrors.InvalidMessage(`Destination ${message.dst} is not allowed for ${message.name}`); } } - - waiter.resolve(message); - matched.push(index); + return entry; } - matched.reverse(); - for (let index of matched) { - waiters.splice(index, 1); - } - - return matched.length > 0; + throw new Error("Should be unreachable"); } /** - * Set handler for a message type + * Message permission validation * - * Set function called upon receiving a message of the given type. + * Called when an request or event is received by a control connector. + * Should be overridden by sub-classes to validate messages received. * - * @param {string} type - Type of message to listen to - * @param {function} handler - - * Callack to invoke on messages of the given type. - * @param {function} validator - - * Function validating the message have the correct format. + * @param {module:lib/data.Message} message - Message to check. + * @param {object} entry - Request or Event entry for this Message. + * @throws {module:lib/errors.PermissionError} if unauthorized. */ - setHandler(type, handler, validator) { - if (this._handlers.has(type)) { - throw new Error(`${type} already has a handler`); - } + validatePermission(message, entry) { } - if (!validator) { - throw new Error("validator is required"); - } - - this.setValidator(type, validator); - this._handlers.set(type, handler); - } - - /** - * Set validator for a message type - * - * Set function called upon receiving a message of the given type in - * order to validate the message. The function should return true if - * the message is valid and false otherwise. - * - * @param {string} type - Type of message to validate - * @param {function} validator - Validation function. - */ - setValidator(type, validator) { - if (this._validators.has(type)) { - throw new Error(`${type} already has a validator`); - } - - this._validators.set(type, validator); - } - - /** - * Wait for a message matching given type and data - * - * Waits for a message over the link that matches the type given as well - * as any properties specified in data. For example specifying { foo: - * 21 } as the data will cause it to wait until a message with the right - * type and a foo property value of 21 in the data payload. - * - * @param {string} type - Message type to wait for. - * @param {Object} data - Properties to match in the message data. - */ - async waitFor(type, data) { - if (!this._validators.has(type)) { - throw new Error(`No validator for ${type} on ${this.source}-${this.target}`); - } - - let waiter = { data }; - waiter.promise = new Promise((resolve, reject) => { - waiter.resolve = resolve; - waiter.reject = reject; - if (!this._waiters.has(type)) { - this._waiters.set(type, [waiter]); - } else { - this._waiters.get(type).push(waiter); + _routeMessage(message, entry) { + if (!this.router) { + let err = new libErrors.InvalidMessage( + `Received message addressed to ${message.dst} but this link does not route messages` + ); + if (message.type === "request") { + this.connector.sendResponseError( + new libData.ResponseError(err.message, err.code), message.src + ); } - }); - return waiter.promise; + throw err; + } + + let fallback = message.type === "request" ? this._requestFallbacks.get(entry.Request) : undefined; + if (this.router.forwardMessage(this, message, Boolean(fallback))) { + return; + } + if (!fallback) { + throw new Error("Router requested fallback handling when fallback is unavailable"); + } + if (message.type !== "request") { + throw new Error(`Router requested fallback handling of unsupported message type ${message.type}`); + } + this._processRequest(message, entry, fallback, message.dst); } - /** - * Handle ping requests - */ - async pingRequestHandler() { } + _processRequest(message, entry, handler, spoofedSrc) { + if (!handler) { + this.connector.sendResponseError( + new libData.ResponseError(`No handler for ${entry.Request.name}`), message.src + ); + return; + } + + let response; + try { + response = handler(entry.requestFromJSON(message.data), message.src, message.dst); + } catch (err) { + if (err.errors) { + logger.error(JSON.stringify(err.errors, null, 4)); + } + this.connector.sendResponseError( + new libData.ResponseError(err.message, err.code, err.stack), message.src + ); + return; + } + + if (!(response instanceof Promise)) { + response = Promise.resolve(response); + } + + response.then( + result => { + if (this.validateSent) { + if (entry.Response) { + entry.responseFromJSON(JSON.parse(JSON.stringify(result))); + } else if (result !== undefined) { + throw new Error(`Expected empty response from ${entry.Request.name} handler`); + } + } + this.connector.sendResponse(result, message.src, spoofedSrc); + } + ).catch( + err => { + if (err instanceof libErrors.InvalidMessage) { + logger.error(err.message); + if (err.errors) { + logger.error(JSON.stringify(err.errors, null, 4)); + } + } else if (!(err instanceof libErrors.RequestError)) { + logger.error(`Unexpected error responding to ${message.name}:\n${err.stack}`); + } + this.connector.sendResponseError( + new libData.ResponseError(err.message, err.code, err.stack), message.src, spoofedSrc + ); + } + ); + } + + _processResponse(message) { + let pending = this._pendingRequests.get(message.dst.requestId); + if (!pending) { + throw new libErrors.InvalidMessage( + `Received response ${message.dst.requestId} without a pending request` + ); + } + + if (!pending.dst.equals(message.src)) { + throw new libErrors.InvalidMessage(`Received reply from ${message.src} for message sent to ${pending.dst}`); + } + + try { + pending.resolve(pending.request.responseFromJSON(message.data)); + } catch (err) { + // An invalid response object was likely received + pending.reject(err); + } + } + + _processResponseError(message) { + let pending = this._pendingRequests.get(message.dst.requestId); + if (!pending) { + throw new libErrors.InvalidMessage( + `Received error response ${message.dst.requestId} without a pending request` + ); + } + + pending.reject(new libErrors.RequestError(message.data.message, message.data.code, message.data.stack)); + } + + _processEvent(message, entry) { + let handler = this._eventHandlers.get(entry.Event); + if (!handler) { + throw new libErrors.InvalidMessage(`Unhandled event ${message.name}`); + } + + handler(entry.eventFromJSON(message.data), message.src, message.dst).catch(err => { + logger.error(`Unexpected error handling ${message.name}:\n${err.stack}`); + }); + } /** * Prepare connection for disconnect @@ -245,19 +357,330 @@ class Link { * additional requests to be sent out before calling the super class * method. */ - async prepareDisconnectRequestHandler() { + async prepareDisconnect() { let promises = []; - for (let waiterType of this._waiters.values()) { - for (let waiter of waiterType) { - promises.push(waiter.promise.then(() => {}, () => {})); - } + for (let pending of this._pendingRequests.values()) { + promises.push(pending.promise.then(() => {}, () => {})); } await Promise.all(promises); } + + /** + * Send a request or event to the other side of this link + * @param {*} requestOrEvent - Request or event to send + * @returns {Promise<*>|undefined} + * Promise that resolves to the response if a request was sent or + * undefined if it was an event. + */ + send(requestOrEvent) { + return this.sendTo(this.connector.dst, requestOrEvent); + } + + /** + * Send a request or event to the given address + * + * @param {*|module:lib/data.Address} destination - Where to send it + * @param {*} requestOrEvent - Request or event to send + * @returns {Promise<*>|undefined} + * Promise that resolves to the response if a request was sent or + * undefined if it was an event. + */ + sendTo(destination, requestOrEvent) { + let dst = libData.Address.fromShorthand(destination); + + if (requestOrEvent.constructor.type === "request") { + return this.sendRequest(requestOrEvent, dst); + } + if (requestOrEvent.constructor.type === "event") { + return this.sendEvent(requestOrEvent, dst); + } + throw Error(`Expected request or event but got type ${requestOrEvent.constructor.type}`); + } + + sendRequest(request, dst) { + let entry = this.constructor._requestsByClass.get(request.constructor); + if (!entry) { + throw new Error(`Attempt to send unregistered Request ${request.constructor.name}`); + } + if (this.validateSent) { + entry.requestFromJSON(JSON.parse(JSON.stringify(request))); + } + + let pending = { + request: entry, + dst, + }; + pending.promise = new Promise((resolve, reject) => { + pending.resolve = resolve; + pending.reject = reject; + }); + let requestId = this._nextRequestId; + this._nextRequestId += 1; + this._pendingRequests.set(requestId, pending); + this.connector.sendRequest(request, requestId, dst); + return pending.promise; + } + + forwardRequest(message, origin) { + if (this.validateSent) { + let entry = this.constructor._requestsByName.get(message.name); + if (!entry) { + throw new Error(`Attempt to forward unregistered Request ${message.name}`); + } + entry.requestFromJSON(message.data); + } + + let pending = { + origin, + src: message.src, + dst: message.dst, + }; + this._forwardedRequests.set(message.src.index(), pending); + this.connector.send(message); + } + + sendEvent(event, dst) { + let entry = this.constructor._eventsByClass.get(event.constructor); + if (!entry) { + throw new Error(`Attempt to send unregistered Event ${event.constructor.name}`); + } + if (this.validateSent) { + entry.eventFromJSON(JSON.parse(JSON.stringify(event))); + } + + this.connector.sendEvent(event, dst); + } + + handle(Class, handler) { + if (Class.type === "request") { + this.handleRequest(Class, handler); + } else if (Class.type === "event") { + this.handleEvent(Class, handler); + } else { + throw new Error(`Class ${Class.name} has unrecognized type ${Class.type}`); + } + } + + handleRequest(Request, handler) { + let entry = this.constructor._requestsByClass.get(Request); + if (!entry) { + throw new Error(`Unregistered Request class ${Request.name}`); + } + if (this._requestHandlers.has(Request)) { + throw new Error(`Request ${entry.name} is already registered`); + } + this._requestHandlers.set(Request, handler); + } + + fallbackRequest(Request, handler) { + let entry = this.constructor._requestsByClass.get(Request); + if (!entry) { + throw new Error(`Unregistered Request class ${Request.name}`); + } + if (this._requestFallbacks.has(Request)) { + throw new Error(`Request ${entry.name} is already fallbacked`); + } + this._requestFallbacks.set(Request, handler); + } + + handleEvent(Event, handler) { + let entry = this.constructor._eventsByClass.get(Event); + if (!entry) { + throw new Error(`Unregistered Event class ${Event.name}`); + } + if (this._eventHandlers.has(Event)) { + throw new Error(`Event ${entry.name} is already registered`); + } + this._eventHandlers.set(Event, handler); + } + + snoopEvent(Event, handler) { + let entry = this.constructor._eventsByClass.get(Event); + if (!entry) { + throw new Error(`Unregistered Event class ${Event.name}`); + } + if (this._eventSnoopers.has(Event)) { + throw new Error(`Event ${entry.name} is already snooped`); + } + this._eventSnoopers.set(Event, handler); + } + + static register(Class) { + if (Class.type === "request") { + this.registerRequest(Class); + } else if (Class.type === "event") { + this.registerEvent(Class); + } else { + throw new Error(`Data class ${Class.name} has unknown type ${Class.type}`); + } + } + + static requestFromJSON(Request, name) { + if (Request.fromJSON) { + if (!Request.jsonSchema) { + throw new Error(`Request ${name} has static fromJSON but is missing static jsonSchema`); + } + let validate = libSchema.compile(Request.jsonSchema); + return (json) => { + if (!validate(json)) { + throw new libErrors.InvalidMessage(`Request ${name} failed validation`, validate.errors); + } + return Request.fromJSON(json); + }; + } + if (Request.jsonSchema) { + throw new Error(`Request ${name} has static jsonSchema but is missing static fromJSON`); + } + return () => new Request(); + } + + static responseFromJSON(Response, name) { + if (!Response.jsonSchema) { + throw new Error(`Response for Request ${name} is missing static jsonSchema`); + } + if (!Response.fromJSON) { + throw new Error(`Response for Request ${name} is missing static fromJSON`); + } + + let validate = libSchema.compile(Response.jsonSchema); + return (json) => { + if (!validate(json)) { + throw new libErrors.InvalidMessage( + `Response for request ${name} failed validation`, validate.errors + ); + } + return Response.fromJSON(json); + }; + } + + static allowedTypes(types, name, side) { + if (types === undefined) { + throw new Error(`Missing ${side} specification in ${name}`); + } + + if (!(types instanceof Array)) { + types = [types]; + } + + let allowed = new Set(); + for (let type of types) { + let id = libData.Address[type]; + if (typeof id !== "number") { + throw new Error(`Invalid type ${type} in ${side} specification of ${name}`); + } + if (id === libData.Address.broadcast) { + throw new Error(`${side} specification may not use the broadcast type in ${name}`); + } + allowed.add(id); + } + return allowed; + } + + static _requestsByName = new Map(); + static _requestsByClass = new Map(); + + static registerRequest(Request) { + const name = Request.plugin ? `${Request.plugin}:${Request.name}` : Request.name; + if (this._requestsByName.has(name)) { + throw new Error(`Request ${name} is already registered`); + } + + let entry = { + Request, + name, + requestFromJSON: this.requestFromJSON(Request, name), + allowedSrcTypes: this.allowedTypes(Request.src, name, "src"), + allowedDstTypes: this.allowedTypes(Request.dst, name, "dst"), + }; + + if ( + entry.allowedSrcTypes.has(libData.Address.control) + && !(Request.permission === null || ["function", "string"].includes(typeof Request.permission)) + ) { + throw new Error(`Invalid permission specification ${typeof Request.permission} on ${name}`); + } + + let Response = Request.Response; + if (Response) { + entry.Response = Response; + entry.responseFromJSON = this.responseFromJSON(Response, name); + } else { + entry.responseFromJSON = () => undefined; + } + + this._requestsByName.set(name, entry); + this._requestsByClass.set(Request, entry); + } + + static eventFromJSON(Event, name) { + if (Event.fromJSON) { + if (!Event.jsonSchema) { + throw new Error(`Event ${name} has static fromJSON but is missing static jsonSchema`); + } + let validate = libSchema.compile(Event.jsonSchema); + return (json) => { + if (!validate(json)) { + throw new libErrors.InvalidMessage(`Event ${name} failed validation`, validate.errors); + } + return Event.fromJSON(json); + }; + } + if (Event.jsonSchema) { + throw new Error(`Event ${name} has static jsonSchema but is missing static fromJSON`); + } + return () => new Event(); + } + + static _eventsByName = new Map(); + static _eventsByClass = new Map(); + + static registerEvent(Event) { + const name = Event.plugin ? `${Event.plugin}:${Event.name}` : Event.name; + if (this._eventsByName.has(name)) { + throw new Error(`Event ${name} is already registered`); + } + + let entry = { + Event, + name, + eventFromJSON: this.eventFromJSON(Event, name), + allowedSrcTypes: this.allowedTypes(Event.src, name, "src"), + allowedDstTypes: this.allowedTypes(Event.dst, name, "dst"), + }; + + if ( + entry.allowedSrcTypes.has(libData.Address.control) + && !(Event.permission === null || ["function", "string"].includes(typeof Event.permission)) + ) { + throw new Error(`Invalid permission specification ${typeof Event.permission} on ${name}`); + } + + this._eventsByName.set(name, entry); + this._eventsByClass.set(Event, entry); + } +} + +for (let Class of dataClasses) { + Link.register(Class); +} + +function registerPluginMessages(pluginInfos) { + for (let pluginInfo of pluginInfos) { + for (let Class of pluginInfo.messages || []) { + if (Class.plugin !== pluginInfo.name) { + throw new Error( + `Expected message ${Class.name} from ${pluginInfo.name} to have a static ` + + `plugin property set to "${pluginInfo.name}"` + ); + } + Link.register(Class); + } + } } module.exports = { Link, + registerPluginMessages, }; diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index d4fbc7c5..6a6acf5c 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -1,1583 +1,111 @@ -// Message definitions for links "use strict"; -const libData = require("../data"); -const libErrors = require("../errors"); -const { logger } = require("../logging"); -const libSchema = require("../schema"); -const PlayerStats = require("../PlayerStats"); - -class MissingLinkHandlerError extends Error { - constructor(type, source, target) { - super(); - this.code = "MISSING_LINK_HANDLER"; - this.type = type; - this.source = source; - this.target = target; - this.handler = null; - this.plugin = null; - } - - get message() { - let handler = this.handler ? `${this.handler}` : "handler"; - let plugin = this.plugin ? ` on plugin ${this.plugin}` : ""; - - return `Missing ${handler}${plugin} for ${this.type} on ${this.source}-${this.target} link`; - } -} - -/** - * Represents a message that can be sent over the link - * - * @memberof module:lib/link - */ -class Message { - /** - * Name of the plugin this message belongs to, or null. - * @returns {?string} - plugin name or null if not from a plugin. - */ - get plugin() { - if (this._plugin === undefined) { - let index = this.type.indexOf(":"); - this._plugin = index === -1 ? null : this.type.slice(0, index); - } - - return this._plugin; - } -} - -/** - * Represents a request sent over the link - * - * A request-response message that can be innitated by either party of the - * link. - * - * @extends module:lib/link.Message - * @memberof module:lib/link - */ -class Request extends Message { - - /** - * Creates a request - * - * @param {string} type - - * message type used. Will have suffix '_request' for the request - * message and suffix '_response' for the response message. - * @param {Array} links - - * Links this request is valid on. Takes an array of strings - * containing "-" specifications. - * @param {?string} permission - - * Permission required to send this request. Only applies to - * requests sent from control to controller. - * @param {?string} forwardTo - - * Optional target to forward this request to. 'instance' add - * instance_id into the requestProperties and forward to the given - * instance. 'controller' forwards it to the controller. - * @param {?Array} requestRequired - - * List of properties that are required to be present in the data - * payload of this request. Defaults to all. - * @param {Object} requestProperties - - * Mapping of property values to JSON schema specifications for the - * properties that are valid in the data payload of this requst. - * @param {?Array} responseRequired - - * List of properties that are required to be present in the data - * payload of the repsonse to this request. Defaults to all. - * @param {Object} responseProperties - - * Mapping of property values to JSON schema specifications for the - * properties that are valid in the data payload of the response to - * this requst. - */ - constructor({ - type, permission, links, forwardTo = null, - requestRequired = null, requestProperties = {}, - responseRequired = null, responseProperties = {}, - }) { - super(); - this.type = type; - this.links = links; - this.permission = permission || null; - this.forwardTo = forwardTo; - this.handlerSuffix = "RequestHandler"; - - this.requestType = `${type}_request`; - this.responseType = `${type}_response`; - - if (permission === undefined && links.includes("control-controller")) { - throw new Error(`permission is required for ${this.type} request over control-controller links`); - } - if (permission && !links.includes("control-controller")) { - throw new Error( - `permission is not allowed on ${this.type} request as it is not over control-controller link` - ); - } - - if (!requestRequired) { - requestRequired = Object.keys(requestProperties); - } - - if (forwardTo === "instance") { - requestRequired = ["instance_id", ...requestRequired]; - requestProperties = { - "instance_id": { type: "integer" }, - ...requestProperties, - }; - - } else if (forwardTo !== "controller" && forwardTo !== null) { - throw new Error(`Invalid forwardTo value ${forwardTo}`); - } - - this._requestValidator = libSchema.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - properties: { - "type": { const: this.requestType }, - "data": { - additionalProperties: false, - required: requestRequired, - properties: requestProperties, - }, - }, - }); - - if (!responseRequired) { - responseRequired = Object.keys(responseProperties); - } - - this._responseValidator = libSchema.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - properties: { - "type": { const: this.responseType }, - "data": { - anyOf: [ - { - additionalProperties: false, - required: ["seq", ...responseRequired], - properties: { - "seq": { type: "integer" }, - ...responseProperties, - }, - }, - { - additionalProperties: false, - required: ["seq", "error"], - properties: { - "seq": { type: "integer" }, - "error": { type: "string" }, - }, - }, - ], - }, - }, - }); - } - - /** - * Attach to a link - * - * Set a the validator for the response on the given link if it's a - * source and sets the handler for the request if it is a target. - * - * If forwardTo was set to 'instance' the `forwardRequestToInstance` - * method on the link is used as the default handler. - * - * If forwardTo was set to 'controller' the `forwardRequesToController` method - * on the link is used as the default handler. - * - * Does nothing if the link is neither a source nor a target for this - * request. - * - * @param {Link} link - The link to attach to. - * @param {Function} handler - - * Async function to invoke with link set as this to handle the - * request request. Only used on the target side. - * @throws {Error} if the handler is needed and not defined. - */ - attach(link, handler) { - // Source side. - if (this.links.includes(`${link.source}-${link.target}`)) { - link.setValidator(this.responseType, this._responseValidator); - } - - // Target side. Note: Source and target is reversed here. - if (this.links.includes(`${link.target}-${link.source}`)) { - - // Use forwarder if handler is not present. - if (!handler) { - if (this.forwardTo === "instance") { - handler = link.forwardRequestToInstance; - } else if (this.forwardTo === "controller") { - handler = link.forwardRequestToController; - } - } - - if (!handler) { - throw new MissingLinkHandlerError(this.requestType, link.source, link.target); - } - - // Check permission if this is a handler for a control connection on the controller - if (this.permission !== null && `${link.source}-${link.target}` === "controller-control") { - let origHandler = handler; - handler = async function(message, request) { - // Abuse this binding to get a hold of the ControlConnection instance - // eslint-disable-next-line no-invalid-this, consistent-this - let controlConnection = this; - controlConnection.user.checkPermission(request.permission); - return await origHandler.call(controlConnection, message, request); - }; - } - - link.setHandler(this.requestType, message => { - handler.call(link, message, this).then(response => { - if (response === undefined) { - // XXX Should we allow implicit responses like this? - response = {}; - } - - let data = { ...response, seq: message.seq }; - if (!this._responseValidator({ seq: 0, type: this.responseType, data })) { - logger.error(JSON.stringify(this._responseValidator.errors, null, 4)); - throw new Error(`Validation failed responding to ${this.requestType}`); - } - - link.connector.send(this.responseType, data); - - }).catch(err => { - if (!(err instanceof libErrors.RequestError)) { - logger.error(`Unexpected error while responding to ${this.requestType}:\n${err.stack}`); - } - link.connector.send(this.responseType, { seq: message.seq, error: err.message }); - }); - }, this._requestValidator); - } - } - - /** - * Send request over the given link - * - * Sends the given data over the link as the request payload and waits - * for the response. - * - * @param {module:lib/link.Link} link - Link to send request over. - * @param {Object} data - Data to send with the request. - * @returns {Promise} response data - */ - async send(link, data = {}) { - // XXX validate link target/source? - if (!this._requestValidator({ seq: 0, type: this.requestType, data })) { - logger.error(JSON.stringify(this._requestValidator.errors, null, 4)); - throw new Error(`Validation failed sending ${this.requestType}`); - } - let seq = link.connector.send(this.requestType, data); - let responseMessage = await link.waitFor(this.responseType, { seq }); - if (responseMessage.data.error) { - throw new libErrors.RequestError(responseMessage.data.error); - } - return responseMessage.data; - } -} - - -let messages = {}; - -// Connection requests -let wsLinks = ["controller-control", "control-controller", "controller-host", "host-controller"]; -messages.prepareDisconnect = new Request({ - type: "prepare_disconnect", - links: wsLinks, - permission: null, -}); - -messages.prepareControllerDisconnect = new Request({ - type: "prepare_controller_disconnect", - links: ["host-instance"], -}); - -messages.ping = new Request({ - type: "ping", - links: wsLinks, - permission: null, -}); - -messages.debugDumpWs = new Request({ - type: "debug_dump_ws", - links: ["control-controller"], - permission: "core.debug.dump_ws", -}); - - -// Management requests -messages.getControllerConfig = new Request({ - type: "get_controller_config", - links: ["control-controller"], - permission: "core.controller.get_config", - responseProperties: { - "serialized_config": { type: "object" }, - }, -}); - -messages.setControllerConfigField = new Request({ - type: "set_controller_config_field", - links: ["control-controller"], - permission: "core.controller.update_config", - requestProperties: { - "field": { type: "string" }, - "value": { type: "string" }, - }, -}); - -messages.setControllerConfigProp = new Request({ - type: "set_controller_config_prop", - links: ["control-controller"], - permission: "core.controller.update_config", - requestRequired: ["field", "prop"], - requestProperties: { - "field": { type: "string" }, - "prop": { type: "string" }, - "value": {}, - }, -}); - -let hostProperties = { - "agent": { type: "string" }, - "version": { type: "string" }, - "name": { type: "string" }, - "id": { type: "integer" }, - "connected": { type: "boolean" }, - "public_address": { type: ["null", "string"] }, -}; - -messages.listHosts = new Request({ - type: "list_hosts", - links: ["control-controller"], - permission: "core.host.list", - responseProperties: { - "list": { - type: "array", - items: { - additionalProperties: false, - required: ["agent", "version", "name", "id", "connected"], - properties: hostProperties, - }, - }, - }, -}); - -messages.setHostSubscriptions = new Request({ - type: "set_host_subscriptions", - links: ["control-controller"], - permission: "core.host.subscribe", - requestProperties: { - "all": { type: "boolean" }, - "host_ids": { - type: "array", - items: { type: "integer" }, - }, - }, -}); - -let instanceProperties = { - "name": { type: "string" }, - "id": { type: "integer" }, - "assigned_host": { type: ["null", "integer"] }, - "game_port": { type: ["null", "integer"] }, - "status": { enum: [ - "unknown", "unassigned", "stopped", "starting", "running", "stopping", - "creating_save", "exporting_data", "deleted", - ]}, -}; - -messages.getInstance = new Request({ - type: "get_instance", - links: ["control-controller"], - permission: "core.instance.get", - requestProperties: { - "id": { type: "integer" }, - }, - responseProperties: instanceProperties, -}); - -messages.listInstances = new Request({ - type: "list_instances", - links: ["control-controller"], - permission: "core.instance.list", - responseProperties: { - "list": { - type: "array", - items: { - additionalProperties: false, - required: ["name", "id", "assigned_host", "status"], - properties: instanceProperties, - }, - }, - }, -}); - -messages.setInstanceSubscriptions = new Request({ - type: "set_instance_subscriptions", - links: ["control-controller"], - permission: "core.instance.subscribe", - requestProperties: { - "all": { type: "boolean" }, - "instance_ids": { - type: "array", - items: { type: "integer" }, - }, - }, -}); - -messages.generateHostToken = new Request({ - type: "generate_host_token", - links: ["control-controller"], - permission: "core.host.generate_token", - requestProperties: { - "host_id": { type: ["integer", "null"] }, - }, - responseProperties: { - "token": { type: "string" }, - }, -}); - -messages.createHostConfig = new Request({ - type: "create_host_config", - links: ["control-controller"], - permission: "core.host.create_config", - requestProperties: { - "id": { type: ["integer", "null"] }, - "name": { type: ["string", "null"] }, - "generate_token": { type: "boolean" }, - }, - responseProperties: { - "serialized_config": { type: "object" }, - }, -}); - -messages.createInstance = new Request({ - type: "create_instance", - links: ["control-controller"], - permission: "core.instance.create", - requestProperties: { - "serialized_config": { type: "object" }, - }, -}); - -messages.getInstanceConfig = new Request({ - type: "get_instance_config", - links: ["control-controller"], - permission: "core.instance.get_config", - requestProperties: { - "instance_id": { type: "integer" }, - }, - responseProperties: { - "serialized_config": { type: "object" }, - }, -}); - -messages.setInstanceConfigField = new Request({ - type: "set_instance_config_field", - links: ["control-controller"], - permission: "core.instance.update_config", - requestProperties: { - "instance_id": { type: "integer" }, - "field": { type: "string" }, - "value": { type: "string" }, - }, -}); - -messages.setInstanceConfigProp = new Request({ - type: "set_instance_config_prop", - links: ["control-controller"], - permission: "core.instance.update_config", - requestRequired: ["instance_id", "field", "prop"], - requestProperties: { - "instance_id": { type: "integer" }, - "field": { type: "string" }, - "prop": { type: "string" }, - "value": {}, - }, -}); - -messages.assignInstanceCommand = new Request({ - type: "assign_instance_command", - links: ["control-controller"], - permission: "core.instance.assign", - requestProperties: { - "instance_id": { type: "integer" }, - "host_id": { type: ["integer", "null"] }, - }, -}); - -messages.startInstance = new Request({ - type: "start_instance", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.start", - requestProperties: { - "save": { type: ["string", "null"] }, - }, - forwardTo: "instance", -}); - -let saveList = { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["type", "name", "size", "mtime_ms", "loaded"], - properties: { - "type": { enum: ["file", "directory", "special"] }, - "name": { type: "string" }, - "size": { type: "integer" }, - "mtime_ms": { type: "number" }, - "loaded": { type: "boolean" }, - "default": { type: "boolean" }, - }, - }, -}; - -messages.listSaves = new Request({ - type: "list_saves", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.save.list", - forwardTo: "instance", - responseProperties: { - "list": saveList, - }, -}); - -messages.createSave = new Request({ - type: "create_save", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.save.create", - forwardTo: "instance", - requestProperties: { - "name": { type: "string" }, - "seed": { type: ["integer", "null"] }, - "map_gen_settings": { type: ["object", "null"] }, - "map_settings": { type: ["object", "null"] }, - }, -}); - -messages.renameSave = new Request({ - type: "rename_save", - links: ["control-controller", "controller-host"], - permission: "core.instance.save.rename", - forwardTo: "instance", - requestProperties: { - "old_name": { type: "string" }, - "new_name": { type: "string" }, - }, -}); - -messages.copySave = new Request({ - type: "copy_save", - links: ["control-controller", "controller-host"], - permission: "core.instance.save.copy", - forwardTo: "instance", - requestProperties: { - "source": { type: "string" }, - "destination": { type: "string" }, - }, -}); - -messages.deleteSave = new Request({ - type: "delete_save", - links: ["control-controller", "controller-host"], - permission: "core.instance.save.delete", - forwardTo: "instance", - requestProperties: { - "save": { type: "string" }, - }, -}); - -messages.downloadSave = new Request({ - type: "download_save", - links: ["control-controller"], - permission: "core.instance.save.download", - requestProperties: { - "instance_id": { type: "integer" }, - "save": { type: "string" }, - }, - responseProperties: { - "stream_id": { type: "string" }, - }, -}); - -messages.transferSave = new Request({ - type: "transfer_save", - links: ["control-controller", "controller-host"], - permission: "core.instance.save.transfer", - requestProperties: { - "instance_id": { type: "integer" }, - "source_save": { type: "string" }, - "target_instance_id": { type: "integer" }, - "target_save": { type: "string" }, - "copy": { type: "boolean" }, - }, - responseProperties: { - "save": { type: "string" }, - }, -}); - -messages.pullSave = new Request({ - type: "pull_save", - links: ["controller-host"], - requestProperties: { - "instance_id": { type: "integer" }, - "stream_id": { type: "string" }, - "filename": { type: "string" }, - }, - responseProperties: { - "save": { type: "string" }, - }, -}); - -messages.pushSave = new Request({ - type: "push_save", - links: ["controller-host"], - requestProperties: { - "instance_id": { type: "integer" }, - "stream_id": { type: "string" }, - "save": { type: "string" }, - }, -}); - -messages.setSaveListSubscriptions = new Request({ - type: "set_save_list_subscriptions", - links: ["control-controller"], - permission: "core.instance.save.list_subscribe", - requestProperties: { - "all": { type: "boolean" }, - "instance_ids": { - type: "array", - items: { type: "integer" }, - }, - }, -}); - -messages.loadScenario = new Request({ - type: "load_scenario", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.load_scenario", - forwardTo: "instance", - requestProperties: { - "scenario": { type: "string" }, - "seed": { type: ["integer", "null"] }, - "map_gen_settings": { type: ["object", "null"] }, - "map_settings": { type: ["object", "null"] }, - }, -}); - -messages.exportData = new Request({ - type: "export_data", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.export_data", - forwardTo: "instance", -}); - -messages.extractPlayers = new Request({ - type: "extract_players", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.extract_players", - forwardTo: "instance", -}); - -messages.stopInstance = new Request({ - type: "stop_instance", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.stop", - forwardTo: "instance", -}); - -messages.killInstance = new Request({ - type: "kill_instance", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.kill", - forwardTo: "instance", -}); - -messages.deleteInstance = new Request({ - type: "delete_instance", - links: ["control-controller", "controller-host"], - permission: "core.instance.delete", - forwardTo: "instance", -}); - -messages.sendRcon = new Request({ - type: "send_rcon", - links: ["control-controller", "controller-host", "host-instance"], - permission: "core.instance.send_rcon", - forwardTo: "instance", - requestProperties: { - "command": { type: "string" }, - }, - responseProperties: { - "result": { type: "string" }, - }, -}); - -messages.getModPack = new Request({ - type: "get_mod_pack", - links: ["control-controller", "instance-host", "host-controller"], - permission: "core.mod_pack.get", - forwardTo: "controller", - requestProperties: { - "id": { type: "integer" }, - }, - responseProperties: { - "mod_pack": libData.ModPack.jsonSchema, - }, -}); - -messages.getDefaultModPack = new Request({ - type: "get_default_mod_pack", - links: ["control-controller", "instance-host", "host-controller"], - permission: "core.mod_pack.get", - forwardTo: "controller", - responseProperties: { - "mod_pack": libData.ModPack.jsonSchema, - }, -}); - -messages.listModPacks = new Request({ - type: "list_mod_packs", - links: ["control-controller"], - permission: "core.mod_pack.list", - responseProperties: { - "list": { - type: "array", - items: libData.ModPack.jsonSchema, - }, - }, -}); - -messages.createModPack = new Request({ - type: "create_mod_pack", - links: ["control-controller"], - permission: "core.mod_pack.create", - requestProperties: { - "mod_pack": libData.ModPack.jsonSchema, - }, -}); - -messages.updateModPack = new Request({ - type: "update_mod_pack", - links: ["control-controller"], - permission: "core.mod_pack.update", - requestProperties: { - "mod_pack": libData.ModPack.jsonSchema, - }, -}); - -messages.deleteModPack = new Request({ - type: "delete_mod_pack", - links: ["control-controller"], - permission: "core.mod_pack.delete", - requestProperties: { - "id": { type: "integer" }, - }, -}); - -messages.setModPackSubscriptions = new Request({ - type: "set_mod_pack_subscriptions", - links: ["control-controller"], - permission: "core.mod_pack.subscribe", - requestProperties: { - "all": { type: "boolean" }, - "mod_pack_ids": { - type: "array", - items: { type: "integer" }, - }, - }, -}); - -messages.getMod = new Request({ - type: "get_mod", - links: ["control-controller"], - permission: "core.mod.get", - requestProperties: { - "name": { type: "string" }, - "version": { type: "string" }, - }, - responseProperties: { - "mod": libData.ModInfo.jsonSchema, - }, -}); - -messages.listMods = new Request({ - type: "list_mods", - links: ["control-controller"], - permission: "core.mod.list", - responseProperties: { - "list": { - type: "array", - items: libData.ModInfo.jsonSchema, - }, - }, -}); - -messages.searchMods = new Request({ - type: "search_mods", - links: ["control-controller"], - permission: "core.mod.search", - requestRequired: ["query", "factorio_version", "page"], - requestProperties: { - "query": { type: "string" }, - "factorio_version": { type: "string" }, - "page_size": { type: "integer" }, - "page": { type: "integer" }, - "sort": { type: "string" }, - "sort_order": { type: "string" }, - }, - responseProperties: { - "query_issues": { - type: "array", - items: { type: "string" }, - }, - "page_count": { type: "integer" }, - "result_count": { type: "integer" }, - "results": { - type: "array", - items: { - type: "object", - properties: { - "name": { type: "string" }, - "versions": { - type: "array", - items: libData.ModInfo.jsonSchema, - }, - }, - }, - }, - }, -}); - -messages.setModSubscriptions = new Request({ - type: "set_mod_subscriptions", - links: ["control-controller"], - permission: "core.mod.subscribe", - requestProperties: { - "all": { type: "boolean" }, - "mod_names": { - type: "array", - items: { type: "string" }, - }, - }, -}); - -messages.downloadMod = new Request({ - type: "download_mod", - links: ["control-controller"], - permission: "core.mod.download", - requestProperties: { - "name": { type: "string" }, - "version": { type: "string" }, - }, - responseProperties: { - "stream_id": { type: "string" }, - }, -}); - -messages.deleteMod = new Request({ - type: "delete_mod", - links: ["control-controller"], - permission: "core.mod.delete", - requestProperties: { - "name": { type: "string" }, - "version": { type: "string" }, - }, -}); - -messages.listPermissions = new Request({ - type: "list_permissions", - links: ["control-controller"], - permission: "core.permission.list", - responseProperties: { - "list": { - type: "array", - items: { - additionalProperties: false, - required: ["name", "title", "description"], - properties: { - "name": { type: "string" }, - "title": { type: "string" }, - "description": { type: "string" }, - }, - }, - }, - }, -}); - -messages.listRoles = new Request({ - type: "list_roles", - links: ["control-controller"], - permission: "core.role.list", - responseProperties: { - "list": { - type: "array", - items: { - additionalProperties: false, - required: ["id", "name", "description", "permissions"], - properties: { - "id": { type: "integer" }, - "name": { type: "string" }, - "description": { type: "string" }, - "permissions": { type: "array", items: { type: "string" }}, - }, - }, - }, - }, -}); - -messages.createRole = new Request({ - type: "create_role", - links: ["control-controller"], - permission: "core.role.create", - requestProperties: { - "name": { type: "string" }, - "description": { type: "string" }, - "permissions": { type: "array", items: { type: "string" }}, - }, - responseProperties: { - "id": { type: "integer" }, - }, -}); - -messages.updateRole = new Request({ - type: "update_role", - links: ["control-controller"], - permission: "core.role.update", - requestProperties: { - "id": { type: "integer" }, - "name": { type: "string" }, - "description": { type: "string" }, - "permissions": { type: "array", items: { type: "string" }}, - }, -}); - -messages.grantDefaultRolePermissions = new Request({ - type: "grant_default_role_permissions", - links: ["control-controller"], - permission: "core.role.update", - requestProperties: { - "id": { type: "integer" }, - }, -}); - -messages.deleteRole = new Request({ - type: "delete_role", - links: ["control-controller"], - permission: "core.role.delete", - requestProperties: { - "id": { type: "integer" }, - }, -}); - -const userRequired = ["name", "roles", "instances"]; -const userProperties = { - "name": { type: "string" }, - "roles": { type: "array", items: { type: "integer" }}, - "is_admin": { type: "boolean" }, - "is_banned": { type: "boolean" }, - "is_whitelisted": { type: "boolean" }, - "ban_reason": { type: "string" }, - "instances": { type: "array", items: { type: "integer" }}, - "is_deleted": { type: "boolean" }, - "player_stats": PlayerStats.jsonSchema, - "instance_stats": { - type: "array", - items: { - type: "array", - items: [{ type: "integer" }, PlayerStats.jsonSchema], - }, - }, -}; - -messages.getUser = new Request({ - type: "get_user", - links: ["control-controller"], - permission: "core.user.get", - requestProperties: { - "name": { type: "string" }, - }, - responseRequired: userRequired, - responseProperties: userProperties, -}); - -messages.listUsers = new Request({ - type: "list_users", - links: ["control-controller"], - permission: "core.user.list", - responseProperties: { - "list": { - type: "array", - items: { - additionalProperties: false, - required: userRequired, - properties: userProperties, - }, - }, - }, -}); - -messages.setUserSubscriptions = new Request({ - type: "set_user_subscriptions", - links: ["control-controller"], - permission: "core.user.subscribe", - requestProperties: { - "all": { type: "boolean" }, - "names": { - type: "array", - items: { type: "string" }, - }, - }, -}); - -messages.createUser = new Request({ - type: "create_user", - links: ["control-controller"], - permission: "core.user.create", - requestProperties: { - "name": { type: "string" }, - }, -}); - -messages.revokeUserToken = new Request({ - type: "revoke_user_token", - links: ["control-controller"], - permission: "core.user.revoke_token", - requestProperties: { - "name": { type: "string" }, - }, -}); - -messages.updateUserRoles = new Request({ - type: "update_user_roles", - links: ["control-controller"], - permission: "core.user.update_roles", - requestProperties: { - "name": { type: "string" }, - "roles": { type: "array", items: { type: "integer" }}, - }, -}); - -messages.setUserAdmin = new Request({ - type: "set_user_admin", - links: ["control-controller"], - permission: "core.user.set_admin", - requestProperties: { - "name": { type: "string" }, - "create": { type: "boolean" }, - "admin": { type: "boolean" }, - }, -}); - -messages.setUserWhitelisted = new Request({ - type: "set_user_whitelisted", - links: ["control-controller"], - permission: "core.user.set_whitelisted", - requestProperties: { - "name": { type: "string" }, - "create": { type: "boolean" }, - "whitelisted": { type: "boolean" }, - }, -}); - -messages.setUserBanned = new Request({ - type: "set_user_banned", - links: ["control-controller"], - permission: "core.user.set_banned", - requestProperties: { - "name": { type: "string" }, - "create": { type: "boolean" }, - "banned": { type: "boolean" }, - "reason": { type: "string" }, - }, -}); - -messages.deleteUser = new Request({ - type: "delete_user", - links: ["control-controller"], - permission: "core.user.delete", - requestProperties: { - "name": { type: "string" }, - }, -}); - -messages.setLogSubscriptions = new Request({ - type: "set_log_subscriptions", - links: ["control-controller"], - permission: "core.log.follow", - requestProperties: { - "all": { type: "boolean" }, - "controller": { type: "boolean" }, - "host_ids": { - type: "array", - items: { type: "integer" }, - }, - "instance_ids": { - type: "array", - items: { type: "integer" }, - }, - "max_level": { type: ["string", "null"] }, - }, -}); - -messages.queryLog = new Request({ - type: "query_log", - links: ["control-controller"], - permission: "core.log.query", - requestProperties: { - "all": { type: "boolean" }, - "controller": { type: "boolean" }, - "host_ids": { - type: "array", - items: { type: "integer" }, - }, - "instance_ids": { - type: "array", - items: { type: "integer" }, - }, - "max_level": { type: ["string", "null"] }, - "limit": { type: "integer" }, - "order": { enum: ["asc", "desc"] }, - }, - responseProperties: { - "log": { - type: "array", - items: { type: "object" }, - }, - }, -}); - - -// Internal requests -messages.updateInstances = new Request({ - type: "update_instances", - links: ["host-controller"], - requestProperties: { - "instances": { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["serialized_config", "status"], - properties: { - "serialized_config": { type: "object" }, - "status": { enum: [ - "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", - ]}, - }, - }, - }, - }, -}); - -messages.assignInstance = new Request({ - type: "assign_instance", - links: ["controller-host"], - requestProperties: { - "serialized_config": { type: "object" }, - }, - forwardTo: "instance", -}); - -messages.unassignInstance = new Request({ - type: "unassigne_instance", - links: ["controller-host"], - forwardTo: "instance", -}); - -messages.getMetrics = new Request({ - type: "get_metrics", - links: ["controller-host", "host-instance"], - responseProperties: { - "results": { type: "array" }, - }, -}); - - -/** - * Represents an event sent over the link - * - * A one way message without any response or recipt confirmation that - * can be innitiated by either party of the link. - * - * @extends module:lib/link.Message - * @memberof module:lib/link - */ -class Event extends Message { - - /** - * Creates an event - * - * @param {string} type - - * message type used. Will have suffix '_event' appended to it for - * the messages sent through this event. - * @param {Array} links - - * Links this event is valid on. Takes an array of strings - * containing "-" specifications. - * @param {?string} forwardTo - - * Optional target to forward this request to. Supported values are - * 'controller' and 'instance'. If set to 'instance' an instance_id - * property is implicity added to eventProperties. - * @param {?string} broadcastTo - - * Optional target to broadcast this event to. Only 'instance' is - * currently supported. An event will only be broadcast towards - * links that are downstream, so sending an event set to instance - * broadcast that's sent to a host will only be broadcast to that - * host's instances. - * @param {?Array} eventRequired - - * List of properties required to be present in the event payload. - * Defaults to all. - * @param {Object} eventProperties - - * Mapping of property values to JSON schema specifications for the - * properties that are valid in the data payload of this event. - */ - constructor({ type, links, forwardTo = null, broadcastTo = null, eventRequired = null, eventProperties = {} }) { - super(); - this.type = type; - this.links = links; - this.forwardTo = forwardTo; - this.broadcastTo = broadcastTo; - this.handlerSuffix = "EventHandler"; - - if (!eventRequired) { - eventRequired = Object.keys(eventProperties); - } - - if (forwardTo === "instance") { - eventRequired = ["instance_id", ...eventRequired]; - eventProperties = { - "instance_id": { type: "integer" }, - ...eventProperties, - }; - - } else if (forwardTo !== "controller" && forwardTo !== null) { - throw new Error(`Invalid forwardTo value ${forwardTo}`); - } - - if (broadcastTo !== "instance" && broadcastTo !== null) { - throw new Error(`Invalid broadcastTo value ${broadcastTo}`); - } - - this.eventType = `${type}_event`; - this._eventValidator = libSchema.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - properties: { - "type": { const: this.eventType }, - "data": { - additionalProperties: false, - required: eventRequired, - properties: eventProperties, - }, - }, - }); - } - - /** - * Attach to a link - * - * Set a the handler for the event on the given link if it's a target - * for the event. - * - * If forwardTo was set to 'controller' the `forwardEventToController` method on - * the link is used as the default handler. If it was set to 'instance' - * the `forwardEventToInstance` method on the link is used by default. - * - * If broadcastTo was set to 'instance' the `broadcastEventToInstance` - * method on the link is invoked before the handler. - * - * Does nothing if the link is not a target for this event. - * - * @param {Link} link - The link to attach to. - * @param {Function} handler - - * Async function to invoke with link set as this to handle the - * event. Only used on the target side. - * @throws {Error} if the handler is needed and not defined. - */ - attach(link, handler) { - // Target side. Note: Source and target is reversed here. - if (this.links.includes(`${link.target}-${link.source}`)) { - // Use forwarder if handler is not present. - if (!handler) { - if (this.forwardTo === "instance") { - handler = link.forwardEventToInstance; - } else if (this.forwardTo === "controller") { - handler = link.forwardEventToController; - } - } - - if ( - this.broadcastTo === "instance" - && [ - "instance-host", "host-controller", "control-controller", "controller-host", - ].includes(`${link.target}-${link.source}`) - ) { - if (!handler) { - handler = link.broadcastEventToInstance; - } else { - let originalHandler = handler; - handler = async (message, event) => { - await link.broadcastEventToInstance(message, event); - await originalHandler.call(link, message, event); - }; - } - } - - if (!handler) { - throw new MissingLinkHandlerError(this.eventType, link.source, link.target); - } - - link.setHandler(this.eventType, message => { - // XXX Should event handlers even be allowed to be async? - handler.call(link, message, this).catch(err => { - logger.error(`Unexpected error while handling ${this.eventType}:\n${err.stack}`); - }); - }, this._eventValidator); - } - } - - /** - * Send event over the given link - * - * Sends the given event data over the link. - * - * @param {module:lib/link.Link} link - Link to send event over. - * @param {Object} data - Data to send with the event. - */ - send(link, data = {}) { - if (!this._eventValidator({ seq: 0, type: this.eventType, data })) { - logger.error(JSON.stringify(this._eventValidator.errors, null, 4)); - throw new Error(`Validation failed sending ${this.eventType}`); - } - - link.connector.send(this.eventType, data); - } -} - -messages.debugWsMessage = new Event({ - type: "debug_ws_message", - links: ["controller-control"], - eventProperties: { - "direction": { type: "string" }, - "content": { type: "string" }, - }, -}); - -messages.accountUpdate = new Event({ - type: "account_update", - links: ["controller-control"], - eventRequired: [], - eventProperties: { - "roles": { - type: "array", - items: { - additionalProperties: false, - type: "object", - required: ["name", "id", "permissions"], - properties: { - "name": { type: "string" }, - "id": { type: "integer" }, - "permissions": { - type: "array", - items: { type: "string" }, - }, - }, - }, - }, - }, -}); - -messages.logMessage = new Event({ - type: "log_message", - links: ["host-controller", "controller-control"], - eventProperties: { - "info": { - type: "object", - required: ["level", "message"], - properties: { - "level": { type: "string" }, - "message": { type: "string" }, - }, - }, - }, -}); - -messages.hostUpdate = new Event({ - type: "host_update", - links: ["controller-control"], - eventProperties: hostProperties, -}); - - -messages.instanceInitialized = new Event({ - type: "instance_initialized", - links: ["instance-host"], - eventProperties: { - "instance_id": { type: "integer" }, - "plugins": { - type: "object", - additionalProperties: { type: "string" }, - }, - }, -}); -messages.instanceStatusChanged = new Event({ - type: "instance_status_changed", - links: ["instance-host", "host-controller"], - eventRequired: ["instance_id", "status"], - eventProperties: { - "instance_id": { type: "integer" }, - "status": { enum: [ - "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", - ]}, - "game_port": { type: ["null", "integer"] }, - }, -}); - -messages.instanceUpdate = new Event({ - type: "instance_update", - links: ["controller-control"], - eventProperties: instanceProperties, -}); - -messages.saveListUpdate = new Event({ - type: "save_list_update", - links: ["instance-host", "host-controller", "controller-control"], - forwardTo: "controller", - eventProperties: { - "instance_id": { type: "integer" }, - "list": saveList, - }, -}); - -messages.modPackUpdate = new Event({ - type: "mod_pack_update", - links: ["controller-control"], - eventProperties: { - "mod_pack": libData.ModPack.jsonSchema, - }, -}); - -messages.modUpdate = new Event({ - type: "mod_update", - links: ["controller-control"], - eventProperties: { - "mod": libData.ModInfo.jsonSchema, - }, -}); - -messages.userUpdate = new Event({ - type: "user_update", - links: ["controller-control"], - eventRequired: userRequired, - eventProperties: userProperties, -}); - -messages.controllerConnectionEvent = new Event({ - type: "controller_connection_event", - links: ["host-instance"], - eventProperties: { - "event": { type: "string" }, - }, -}); - -messages.syncUserLists = new Event({ - type: "sync_user_lists", - links: ["controller-host"], - eventProperties: { - "adminlist": { - type: "array", - items: { type: "string" }, - }, - "banlist": { - type: "array", - items: { - type: "array", - additionalItems: false, - items: [{ type: "string" }, { type: "string" }], - }, - }, - "whitelist": { - type: "array", - items: { type: "string" }, - }, - }, -}); - -messages.banlistUpdate = new Event({ - type: "banlist_update", - links: ["controller-host", "host-instance"], - broadcastTo: "instance", - eventProperties: { - "name": { type: "string" }, - "banned": { type: "boolean" }, - "reason": { type: "string" }, - }, -}); - -messages.adminlistUpdate = new Event({ - type: "adminlist_update", - links: ["controller-host", "host-instance"], - eventProperties: { - "name": { type: "string" }, - "admin": { type: "boolean" }, - }, - broadcastTo: "instance", -}); - -messages.whitelistUpdate = new Event({ - type: "whitelist_update", - links: ["controller-host", "host-instance"], - eventProperties: { - "name": { type: "string" }, - "whitelisted": { type: "boolean" }, - }, - broadcastTo: "instance", -}); - -messages.playerEvent = new Event({ - type: "player_event", - links: ["instance-host", "host-controller"], - forwardTo: "controller", - eventRequired: ["instance_id", "type", "name"], - eventProperties: { - "instance_id": { type: "integer" }, - "type": { type: "string", enum: ["join", "leave", "import"] }, - "name": { type: "string" }, - "reason": { type: "string" }, - "stats": PlayerStats.jsonSchema, - }, -}); - -/** - * Attaches all requests and events to the given link - * - * Loops through all builtin messages defined and attaches all of them - * to the link. The handler used for the messageis looked up on the - * link instance as the concatenation of the name of the message, the - * name of message class, and 'Handler'. - * - * @param {module:lib/link.Link} link - Link to attach messages to. - * @memberof module:lib/link - */ -function attachAllMessages(link) { - for (let [name, message] of Object.entries(messages)) { - let handler = `${name}${message.handlerSuffix}`; - try { - message.attach(link, link[handler]); - } catch (err) { - if (err.code === "MISSING_LINK_HANDLER") { - err.handler = handler; - } - throw err; - } - } -} - - -module.exports = { - Message, - Request, - Event, - - attachAllMessages, - - messages, -}; +const core = require("../data/messages_core"); +const controller = require("../data/messages_controller"); +const host = require("../data/messages_host"); +const instance = require("../data/messages_instance"); +const mod = require("../data/messages_mod"); +const user = require("../data/messages_user"); + +// Event and Request payloads registered with the Link class. Used to decode +// the data payload of incomming requsts and event messages into instances of +// their proper classes. +const dataClasses = [ + core.PingRequest, + + controller.ControllerConfigGetRequest, + controller.ControllerConfigSetFieldRequest, + controller.ControllerConfigSetPropRequest, + controller.HostGenerateTokenRequest, + controller.HostConfigCreateRequest, + controller.LogSetSubscriptionsRequest, + controller.LogQueryRequest, + controller.LogMessageEvent, + controller.DebugDumpWsRequest, + controller.DebugWsMessageEvent, + + host.HostListRequest, + host.HostSetSubscriptionsRequest, + host.HostUpdateEvent, + host.HostMetricsRequest, + host.ControllerConnectionEvent, + host.PrepareControllerDisconnectRequest, + host.SyncUserListsEvent, + + instance.InstanceDetailsGetRequest, + instance.InstanceDetailsListRequest, + instance.InstanceDetailsSetSubscriptionsRequest, + instance.InstanceDetailsUpdateEvent, + instance.InstanceCreateRequest, + instance.InstanceConfigGetRequest, + instance.InstanceConfigSetFieldRequest, + instance.InstanceConfigSetPropRequest, + instance.InstanceAssignRequest, + instance.InstanceMetricsRequest, + instance.InstanceStartRequest, + instance.InstanceListSavesRequest, + instance.InstanceSetSaveListSubscriptionsRequest, + instance.InstanceSaveListUpdateEvent, + instance.InstanceCreateSaveRequest, + instance.InstanceRenameSaveRequest, + instance.InstanceCopySaveRequest, + instance.InstanceDeleteSaveRequest, + instance.InstanceDownloadSaveRequest, + instance.InstanceTransferSaveRequest, + instance.InstancePullSaveRequest, + instance.InstancePushSaveRequest, + instance.InstanceLoadScenarioRequest, + instance.InstanceExportDataRequest, + instance.InstanceExtractPlayersRequest, + instance.InstanceStopRequest, + instance.InstanceKillRequest, + instance.InstanceDeleteRequest, + instance.InstanceDeleteInternalRequest, + instance.InstanceSendRconRequest, + instance.InstancesUpdateRequest, + instance.InstanceAssignInternalRequest, + instance.InstanceUnassignInternalRequest, + instance.InstanceInitialisedEvent, + instance.InstanceStatusChangedEvent, + instance.InstanceDetailsChangedEvent, + instance.InstanceBanlistUpdateEvent, + instance.InstanceAdminlistUpdateEvent, + instance.InstanceWhitelistUpdateEvent, + instance.InstancePlayerUpdateEvent, + + mod.ModPackGetRequest, + mod.ModPackGetDefaultRequest, + mod.ModPackListRequest, + mod.ModPackCreateRequest, + mod.ModPackUpdateRequest, + mod.ModPackDeleteRequest, + mod.ModPackSetSubscriptionsRequest, + mod.ModGetRequest, + mod.ModListRequest, + mod.ModSearchRequest, + mod.ModSetSubscriptionsRequest, + mod.ModDownloadRequest, + mod.ModDeleteRequest, + mod.ModPackUpdateEvent, + mod.ModUpdateEvent, + + user.PermissionListRequest, + user.RoleListRequest, + user.RoleCreateRequest, + user.RoleUpdateRequest, + user.RoleGrantDefaultPermissionsRequest, + user.RoleDeleteRequest, + user.UserGetRequest, + user.UserListRequest, + user.UserSetSubscriptionsRequest, + user.UserCreateRequest, + user.UserRevokeTokenRequest, + user.UserUpdateRolesRequest, + user.UserSetAdminRequest, + user.UserSetWhitelistedRequest, + user.UserSetBannedRequest, + user.UserDeleteRequest, + user.AccountUpdateEvent, + user.UserUpdateEvent, +]; + +module.exports = { dataClasses }; diff --git a/packages/lib/logging.js b/packages/lib/logging.js index 94135133..56c9a729 100644 --- a/packages/lib/logging.js +++ b/packages/lib/logging.js @@ -85,16 +85,16 @@ class WebConsoleFormat { /** * Filter object for logs * @typedef {Object} module:lib/logging~LogFilter - * @property {string} [max_level] - + * @property {string} [maxLevel] - * Maximum log level to include. Higher levels are more verbose. * @property {boolean} [all] - * Include log entries from controller, all hosts and all instances. * @property {boolean} [controller] - * Include log entries from the controller. - * @property {Array} [host_ids] - + * @property {Array} [hostIds] - * Include log entries for the given hosts and instances of those * hosts by id. - * @property {Array} [instance_ids] - + * @property {Array} [instanceIds] - * Include log entries for the given instances by id. */ @@ -107,10 +107,10 @@ class WebConsoleFormat { * filter returning true for log entries that match it. * @static */ -function logFilter({ all, controller, host_ids, instance_ids, max_level }) { +function logFilter({ all, controller, hostIds, instanceIds, maxLevel }) { return info => { // Note: reversed to filter out undefined levels - if (max_level && !(levels[info.level] <= levels[max_level])) { + if (maxLevel && !(levels[info.level] <= levels[maxLevel])) { return false; } @@ -121,14 +121,14 @@ function logFilter({ all, controller, host_ids, instance_ids, max_level }) { return true; } if ( - host_ids + hostIds && info.host_id !== undefined && info.instance_id === undefined - && host_ids.includes(info.host_id) + && hostIds.includes(info.host_id) ) { return true; } - if (instance_ids && info.instance_id !== undefined && instance_ids.includes(info.instance_id)) { + if (instanceIds && info.instance_id !== undefined && instanceIds.includes(info.instance_id)) { return true; } return false; diff --git a/packages/lib/logging_utils.js b/packages/lib/logging_utils.js index be604fd6..dea8baee 100644 --- a/packages/lib/logging_utils.js +++ b/packages/lib/logging_utils.js @@ -15,7 +15,7 @@ const Transport = require("winston-transport"); const { LEVEL, MESSAGE } = require("triple-beam"); const chalk = require("chalk"); -const libLink = require("./link"); +const libData = require("./data"); const libErrors = require("./errors"); const libFileOps = require("./file_ops"); const { levels, logFilter, logger } = require("./logging"); @@ -128,7 +128,7 @@ class LinkTransport extends Transport { } try { - libLink.messages.logMessage.send(this.link, { info }); + this.link.send(new libData.LogMessageEvent(info)); } catch (err) { // Ignore session lost errors. if (!(err instanceof libErrors.SessionLost)) { @@ -140,7 +140,7 @@ class LinkTransport extends Transport { } const logFileGlob = /^[a-z]+-(\d{4}-\d{2}-\d{2})\.log$/; -const logIndexVersion = 1; +const logIndexVersion = 2; /** * Keeps an index over a log directory to speed up queries to it @@ -159,8 +159,8 @@ class LogIndex { this.index.set(file, { levels: new Set(serializedEntry.levels), controller: serializedEntry.controller, - host_ids: new Set(serializedEntry.host_ids), - instance_ids: new Set(serializedEntry.instance_ids), + hostIds: new Set(serializedEntry.hostIds), + instanceIds: new Set(serializedEntry.instanceIds), }); } } @@ -175,8 +175,8 @@ class LogIndex { serialized.files[file] = { levels: [...entry.levels], controller: entry.controller, - host_ids: [...entry.host_ids], - instance_ids: [...entry.instance_ids], + hostIds: [...entry.hostIds], + instanceIds: [...entry.instanceIds], }; } @@ -226,8 +226,8 @@ class LogIndex { let entry = { levels: new Set(), controller: false, - host_ids: new Set(), - instance_ids: new Set(), + hostIds: new Set(), + instanceIds: new Set(), }; for await (let line of lineStream) { let info; @@ -244,10 +244,10 @@ class LogIndex { if (info.host_id === undefined) { entry.controller = true; } else if (info.instance_id === undefined) { - entry.host_ids.add(info.host_id); + entry.hostIds.add(info.host_id); } if (info.instance_id !== undefined) { - entry.instance_ids.add(info.instance_id); + entry.instanceIds.add(info.instance_id); } } return entry; @@ -280,18 +280,18 @@ class LogIndex { * @returns {boolean} * true if the file may contain entries included by the filter. */ - filterIncludesFile(file, { max_level, all, controller, instance_ids, host_ids }) { + filterIncludesFile(file, { maxLevel, all, controller, instanceIds, hostIds }) { let entry = this.index.get(file); if (!entry) { return true; } const hasOwnProperty = Object.prototype.hasOwnProperty; - if (max_level && hasOwnProperty.call(levels, max_level)) { + if (maxLevel && hasOwnProperty.call(levels, maxLevel)) { let fileLevels = [...entry.levels].map( fileLevel => (hasOwnProperty.call(levels, fileLevel) ? levels[fileLevel] : levels.verbose) ); - if (fileLevels.every(level => level > levels[max_level])) { + if (fileLevels.every(level => level > levels[maxLevel])) { return false; } } @@ -302,10 +302,10 @@ class LogIndex { if (controller && entry.controller) { return true; } - if (host_ids && host_ids.some(id => entry.host_ids.has(id))) { + if (hostIds && hostIds.some(id => entry.hostIds.has(id))) { return true; } - if (instance_ids && instance_ids.some(id => entry.instance_ids.has(id))) { + if (instanceIds && instanceIds.some(id => entry.instanceIds.has(id))) { return true; } return false; @@ -319,16 +319,16 @@ class LogIndex { * @property {string} [order="asc"] - * Return entries in ascending ("asc") date order or desceding ("desc") * date order. - * @property {string} [max_level] - + * @property {string} [maxLevel] - * Maximum log level to include. Higher levels are more verbose. * @property {boolean} [all] - * Include log entries from controller, all hosts and all instances. * @property {boolean} [controller] - * Include log entries from the controller. - * @property {Array} [host_ids] - + * @property {Array} [hostIds] - * Include log entries for the given hosts and instances of those * hosts by id. - * @property {Array} [instance_ids] - + * @property {Array} [instanceIds] - * Include log entries for the given instances by id. */ diff --git a/packages/lib/plugin.js b/packages/lib/plugin.js index fe627a86..eebfd072 100644 --- a/packages/lib/plugin.js +++ b/packages/lib/plugin.js @@ -545,10 +545,14 @@ class BaseControllerPlugin { * This does not include hosts that are in the process of closing the * connection, which typically happens when they are shutting down. * - * @param {module:lib/link.Event} event - Event to send + * @param {*} event - Event to send * @param {Object} data - Data ta pass with the event. */ broadcastEventToHosts(event, data={}) { + if (event.type !== "event") { + this.logger.warn(`Ignoring broadcast of ${event.type}`); + return; + } for (let hostConnection of this.controller.wsServer.hostConnections.values()) { if ( !hostConnection.connector.closing @@ -771,43 +775,6 @@ class BaseWebPlugin { onControllerConnectionEvent(event) { } } -/** - * Attach plugin messages - * - * Attaches all messages defined in the `.info.message` property of the - * `plugin` passed with handlers taken from the `pluin`. - * - * @param {module:lib/link.Link} link - Link to attach handlers to. - * @param {module:lib/plugin.BasePlugin} plugin - - * The instance of the plugin attach messages and handlers from. - */ -function attachPluginMessages(link, plugin) { - let messageDefinitions = plugin.info.messages || []; - for (let [name, messageFormat] of Object.entries(messageDefinitions)) { - if (messageFormat.plugin !== plugin.info.name) { - throw new Error(`Type of ${name} message must start with "${plugin.info.name}:"`); - } - - let handler = name + messageFormat.handlerSuffix; - try { - if (plugin === null || !plugin[handler]) { - messageFormat.attach(link); - - } else { - messageFormat.attach(link, async (message, format) => await plugin[handler](message, format, link)); - } - - } catch (err) { - if (err.code === "MISSING_LINK_HANDLER") { - err.plugin = plugin.info.name; - err.handler = handler; - } - - throw err; - } - } -} - /** * Invokes the given hook on all plugins * @@ -847,6 +814,5 @@ module.exports = { BaseControlPlugin, BaseWebPlugin, - attachPluginMessages, invokeHook, }; diff --git a/packages/lib/plugin_loader.js b/packages/lib/plugin_loader.js index e1f03ca0..e831e5db 100644 --- a/packages/lib/plugin_loader.js +++ b/packages/lib/plugin_loader.js @@ -7,6 +7,7 @@ const libErrors = require("./errors"); const libPlugin = require("./plugin"); +const { logger } = require("./logging"); const path = require("path"); @@ -42,6 +43,12 @@ async function loadPluginInfos(pluginList) { ); } + // migrate: ignore incompatible old plugins + if (pluginInfo.messages && !(pluginInfo.messages instanceof Array)) { + logger.warn(`Ignoring incompatible pre alpha.14 plugin ${pluginName}`); + continue; + } + pluginInfo.requirePath = pluginPath; pluginInfo.version = pluginPackage.version; plugins.push(pluginInfo); diff --git a/packages/lib/schema.js b/packages/lib/schema.js index 572b7846..a9e362c7 100644 --- a/packages/lib/schema.js +++ b/packages/lib/schema.js @@ -8,216 +8,10 @@ const ajv = new Ajv({ verbose: true, format: "full", extendRefs: "fail", - strictDefaluts: true, + strictDefaults: true, strictKeywords: true, }); - -/** - * The base format for any message sent over the link - */ -const messageSchema = { - $schema: "http://json-schema.org/draft-07/schema#", - title: "Message", - description: "Format for messages on a link", - type: "object", - additionalProperties: false, - required: ["seq", "type", "data"], - properties: { - "seq": { type: ["integer", "null"] }, - "type": { type: "string" }, - "data": { type: "object" }, - }, -}; - -/** - * Validator for message schemas - * @function - * @param {Object} obj - Object to validate - * @returns {boolean} True if object passed validation. - */ -const message = ajv.compile(messageSchema); - -/** - * Validator for heartbeat messages - * @function - * @param {Object} obj - Object to validate - * @returns {boolean} True if object passed validation. - */ -const heartbeat = ajv.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - title: "Heartbeat", - description: "Heartbeat sent over link", - type: "object", - additionalProperties: false, - required: ["seq", "type", "data"], - properties: { - "seq": { const: null }, - "type": { const: "heartbeat" }, - "data": { - type: "object", - additionalProperties: false, - required: ["seq"], - properties: { - "seq": { type: ["integer", "null"] }, - }, - }, - }, -}); - -/** - * Validator for server sent part of handshake - * @function - * @param {Object} obj - Object to validate - * @returns {boolean} True if object passed validation. - */ -const serverHandshake = ajv.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - title: "Server Handshake", - description: "Message sent from the server to a client during the handshake", - allOf: [messageSchema], - anyOf: [ - { - properties: { - "type": { const: "hello" }, - "data": { - additionalProperties: false, - required: ["version", "plugins"], - properties: { - "version": { type: "string" }, - "plugins": { - type: "object", - additionalProperties: { type: "string" }, - }, - }, - }, - }, - }, - { - properties: { - "type": { const: "ready" }, - "data": { - additionalProperties: false, - required: ["session_token", "session_timeout", "heartbeat_interval"], - properties: { - "session_token": { type: "string" }, - "session_timeout": { type: "number" }, - "heartbeat_interval": { type: "number" }, - "account": { - type: "object", - additionalProperties: false, - required: ["name", "roles"], - properties: { - "name": { type: "string" }, - "roles": { - type: "array", - items: { - additionalProperties: false, - type: "object", - required: ["name", "id", "permissions"], - properties: { - "name": { type: "string" }, - "id": { type: "integer" }, - "permissions": { - type: "array", - items: { type: "string" }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - { - properties: { - "type": { const: "continue" }, - "data": { - additionalProperties: false, - required: ["last_seq", "session_timeout", "heartbeat_interval"], - properties: { - "last_seq": { type: ["integer", "null"] }, - "session_timeout": { type: "number" }, - "heartbeat_interval": { type: "number" }, - }, - }, - }, - }, - { - properties: { - "type": { const: "invalidate" }, - "data": { const: {} }, - }, - }, - ], -}); - -/** - * Validator for client sent part of handshake - * @function - * @param {Object} obj - Object to validate - * @returns {boolean} True if object passed validation. - */ -const clientHandshake = ajv.compile({ - $schema: "http://json-schema.org/draft-07/schema#", - title: "Client Message", - description: "Message sent from a client to the server", - allOf: [messageSchema], - anyOf: [ - { - properties: { - "type": { const: "register_host" }, - "data": { - additionalProperties: false, - required: ["token", "agent", "version", "name", "id", "plugins"], - properties: { - "token": { type: "string" }, - "agent": { type: "string" }, - "version": { type: "string" }, - "name": { type: "string" }, - "id": { type: "integer" }, - "public_address": { type: "string" }, - "plugins": { - type: "object", - additionalProperties: { type: "string" }, - }, - }, - }, - }, - }, - { - properties: { - "type": { const: "register_control" }, - "data": { - additionalProperties: false, - required: ["token", "agent", "version"], - properties: { - "token": { type: "string" }, - "agent": { type: "string" }, - "version": { type: "string" }, - }, - }, - }, - }, - { - properties: { - "type": { const: "resume" }, - "data": { - additionalProperties: false, - required: ["session_token", "last_seq"], - properties: { - "session_token": { type: "string" }, - "last_seq": { type: ["integer", "null"] }, - }, - }, - }, - }, - ], -}); - - module.exports = { /** * Compile JSON schema into validator @@ -226,10 +20,4 @@ module.exports = { * @returns {Function} Validator for the schema. */ compile: ajv.compile.bind(ajv), - - message, - heartbeat, - - serverHandshake, - clientHandshake, }; diff --git a/packages/web_ui/package.json b/packages/web_ui/package.json index c9bc0499..b8b815c9 100644 --- a/packages/web_ui/package.json +++ b/packages/web_ui/package.json @@ -37,6 +37,7 @@ "react-router-dom": "^5.2.0", "stream-browserify": "^3.0.0", "style-loader": "^2.0.0", + "terser-webpack-plugin": "^5.3.9", "util": "^0.12.3", "webpack": "^5.73.0", "webpack-cli": "^4.9.2", diff --git a/packages/web_ui/src/bootstrap.jsx b/packages/web_ui/src/bootstrap.jsx index e6f3bbb0..d07d1ed3 100644 --- a/packages/web_ui/src/bootstrap.jsx +++ b/packages/web_ui/src/bootstrap.jsx @@ -3,7 +3,7 @@ import "./index.css"; import React from "react"; import ReactDOM from "react-dom"; -import { libConfig, libLogging, libPlugin } from "@clusterio/lib"; +import { libConfig, libLink, libLogging, libPlugin } from "@clusterio/lib"; const { ConsoleTransport, WebConsoleFormat, logger } = libLogging; import App from "./components/App"; @@ -102,6 +102,7 @@ export default async function bootstrap() { format: new WebConsoleFormat(), })); let pluginInfos = await loadPluginInfos(); + libLink.registerPluginMessages(pluginInfos); libConfig.registerPluginConfigGroups(pluginInfos); libConfig.finalizeConfigs(); let plugins = await loadPlugins(pluginInfos); diff --git a/packages/web_ui/src/components/AssignInstanceModal.jsx b/packages/web_ui/src/components/AssignInstanceModal.jsx index 97f49576..f64edfbc 100644 --- a/packages/web_ui/src/components/AssignInstanceModal.jsx +++ b/packages/web_ui/src/components/AssignInstanceModal.jsx @@ -1,7 +1,7 @@ import React, { useContext, useState } from "react"; import { Button, Form, Modal, Select, Typography } from "antd"; -import { libLink } from "@clusterio/lib"; +import { libData } from "@clusterio/lib"; import ControlContext from "./ControlContext"; import { notifyErrorHandler } from "../util/notify"; @@ -33,10 +33,9 @@ export default function AssignInstanceModal(props) { } setApplying(true); - libLink.messages.assignInstanceCommand.send(control, { - instance_id: props.id, - host_id: hostId, - }).then(() => { + control.send( + new libData.InstanceAssignRequest(props.id, hostId) + ).then(() => { setVisible(false); if (props.onFinish) { props.onFinish(); diff --git a/packages/web_ui/src/components/ControllerConfigTree.jsx b/packages/web_ui/src/components/ControllerConfigTree.jsx index 5197f8c6..40dd7947 100644 --- a/packages/web_ui/src/components/ControllerConfigTree.jsx +++ b/packages/web_ui/src/components/ControllerConfigTree.jsx @@ -1,6 +1,6 @@ import React, { useContext } from "react"; -import { libConfig, libLink } from "@clusterio/lib"; +import { libConfig, libData } from "@clusterio/lib"; import BaseConfigTree from "./BaseConfigTree"; import ControlContext from "./ControlContext"; @@ -10,27 +10,26 @@ export default function ControllerConfigTree(props) { let control = useContext(ControlContext); async function retrieveConfig() { - let result = await libLink.messages.getControllerConfig.send(control); - return result.serialized_config; + let result = await control.send(new libData.ControllerConfigGetRequest()); + return result.serializedConfig; } async function setField(field, value) { - await libLink.messages.setControllerConfigField.send(control, { + await control.send(new libData.ControllerConfigSetFieldRequest( field, - value: String(value), - }); + String(value), + )); } async function setProp(field, prop, value) { - let data = { field, prop }; if (value) { try { - data.value = JSON.parse(value); + value = JSON.parse(value); } catch (err) { return; } } - await libLink.messages.setControllerConfigProp.send(control, data); + await control.send(new libData.ControllerConfigSetPropRequest(field, prop, value)); } return { + control.sendTo( + { instanceId: props.instance.id }, + new libData.InstanceCreateSaveRequest(name, seed, mapGenSettings, mapSettings), + ).then(() => { form.resetFields(); setVisible(false); }).catch( diff --git a/packages/web_ui/src/components/HostViewPage.jsx b/packages/web_ui/src/components/HostViewPage.jsx index 23589ef0..51f1db69 100644 --- a/packages/web_ui/src/components/HostViewPage.jsx +++ b/packages/web_ui/src/components/HostViewPage.jsx @@ -20,7 +20,7 @@ export default function HostViewPage(props) { let [instanceList] = useInstanceList(); let [host] = useHost(hostId); - instanceList = instanceList.filter(instance => instance["assigned_host"] === hostId); + instanceList = instanceList.filter(instance => instance.assignedHost === hostId); let nav = [{ name: "Hosts", path: "/hosts" }, { name: host.name || hostId }]; if (host.loading) { diff --git a/packages/web_ui/src/components/HostsPage.jsx b/packages/web_ui/src/components/HostsPage.jsx index f941602a..afb8c2dd 100644 --- a/packages/web_ui/src/components/HostsPage.jsx +++ b/packages/web_ui/src/components/HostsPage.jsx @@ -3,7 +3,7 @@ import { useHistory } from "react-router-dom"; import { Button, Form, InputNumber, Modal, PageHeader, Table, Tag, Typography } from "antd"; import CopyOutlined from "@ant-design/icons/lib/icons/CopyOutlined"; -import { libLink } from "@clusterio/lib"; +import { libData } from "@clusterio/lib"; import { useAccount } from "../model/account"; import ControlContext from "./ControlContext"; @@ -35,7 +35,7 @@ function GenerateHostTokenButton(props) { }, []); async function generateToken() { - let id; + let id = null; let values = form.getFieldsValue(); if (values.hostId) { id = Number.parseInt(values.hostId, 10); @@ -46,8 +46,8 @@ function GenerateHostTokenButton(props) { form.setFields([{ name: "hostId", errors: [] }]); } - let result = await libLink.messages.generateHostToken.send(control, { host_id: id || null }); - setToken(result.token); + let newToken = await control.send(new libData.HostGenerateTokenRequest(id)); + setToken(newToken); setHostId(id); } @@ -192,37 +192,37 @@ export default function HostsPage() { title: "Name", dataIndex: "name", defaultSortOrder: "ascend", - sorter: (a, b) => strcmp(a["name"], b["name"]), + sorter: (a, b) => strcmp(a.name, b.name), }, { title: "Agent", dataIndex: "agent", - sorter: (a, b) => strcmp(a["agent"], b["agent"]), + sorter: (a, b) => strcmp(a.agent, b.agent), responsive: ["lg"], }, { title: "Version", dataIndex: "version", - sorter: (a, b) => strcmp(a["version"], b["version"]), + sorter: (a, b) => strcmp(a.version, b.version), }, { title: "Public address", - dataIndex: "public_address", - sorter: (a, b) => strcmp(a["public_address"], b["public_address"]), + dataIndex: "publicAddress", + sorter: (a, b) => strcmp(a.publicAddress, b.publicAddress), }, { title: "Connected", key: "connected", render: host => - {host["connected"] ? "Connected" : "Disconnected"} + {host.connected ? "Connected" : "Disconnected"} , - sorter: (a, b) => a["connected"] - b["connected"], + sorter: (a, b) => a.connected - b.connected, }, ]} dataSource={hostList} - rowKey={host => host["id"]} + rowKey={host => host.id} pagination={false} onRow={(record, rowIndex) => ({ onClick: event => { diff --git a/packages/web_ui/src/components/InstanceConfigTree.jsx b/packages/web_ui/src/components/InstanceConfigTree.jsx index a2d05a71..74afaa31 100644 --- a/packages/web_ui/src/components/InstanceConfigTree.jsx +++ b/packages/web_ui/src/components/InstanceConfigTree.jsx @@ -1,6 +1,6 @@ import React, { useContext } from "react"; -import { libConfig, libLink } from "@clusterio/lib"; +import { libConfig, libData } from "@clusterio/lib"; import BaseConfigTree from "./BaseConfigTree"; import ControlContext from "./ControlContext"; @@ -10,34 +10,23 @@ export default function InstanceConfigTree(props) { let control = useContext(ControlContext); async function retrieveConfig() { - let result = await libLink.messages.getInstanceConfig.send(control, { - instance_id: props.id, - }); - return result.serialized_config; + let result = await control.send(new libData.InstanceConfigGetRequest(props.id)); + return result.config; } async function setField(field, value) { - await libLink.messages.setInstanceConfigField.send(control, { - instance_id: props.id, - field, - value, - }); + await control.send(new libData.InstanceConfigSetFieldRequest(props.id, field, value)); } async function setProp(field, prop, value) { - let data = { - instance_id: props.id, - field, - prop, - }; if (value) { try { - data.value = JSON.parse(value); + value = JSON.parse(value); } catch (err) { return; } } - await libLink.messages.setInstanceConfigProp.send(control, data); + await control.send(new libData.InstanceConfigSetPropRequest(props.id, field, prop, value)); } return s.id === instance.assigned_host); - if (host && host.public_address) { - if (instance.game_port) { - return `${host.public_address}:${instance.game_port}`; + let host = hostList.find(s => s.id === instance.assignedHost); + if (host && host.publicAddress) { + if (instance.gamePort) { + return `${host.publicAddress}:${instance.gamePort}`; } - return host.public_address; + return host.publicAddress; } return null; } @@ -46,30 +46,30 @@ export default function InstanceList(props) { }, { title: "Assigned Host", - key: "assigned_host", - render: instance => hostName(instance["assigned_host"]), - sorter: (a, b) => strcmp(hostName(a["assigned_host"]), hostName(b["assigned_host"])), + key: "assignedHost", + render: instance => hostName(instance.assignedHost), + sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)), responsive: ["sm"], }, { title: "Public address", - key: "public_address", + key: "publicAddress", render: instance => { - let public_address = instancePublicAddress(instance); - return public_address ? <> - {public_address} + let publicAddress = instancePublicAddress(instance); + return publicAddress ? <> + {publicAddress} @@ -550,6 +558,43 @@ function useExportedAsset(modPack, asset) { return assetData; } +function applyModPackChanges(modPack, changes) { + let modifiedModPack = modPack.shallowClone(); + for (let change of changes) { + // Only modify fields that change to reduce re-renders + if (["mods.set", "mods.delete"].includes(change.type) && modifiedModPack.mods === modPack.mods) { + modifiedModPack.mods = new Map(modifiedModPack.mods); + } + if ( + ["settings.set", "settings.delete"].includes(change.type) + && modifiedModPack.settings[change.scope] === modPack.settings[change.scope] + ) { + if (modifiedModPack.settings === modPack.settings) { + modifiedModPack.settings = { ...modPack.settings }; + } + modifiedModPack.settings[change.scope] = new Map(modifiedModPack.settings[change.scope]); + } + + if (change.type === "name") { + modifiedModPack.name = change.value; + } else if (change.type === "description") { + modifiedModPack.description = change.value; + } else if (change.type === "factorioVersion") { + modifiedModPack.factorioVersion = change.value; + } else if (change.type === "mods.set") { + modifiedModPack.mods.set(change.name, change.value); + } else if (change.type === "mods.delete") { + modifiedModPack.mods.delete(change.name); + } else if (change.type === "settings.set") { + modifiedModPack.settings[change.scope].set(change.name, change.value); + } else if (change.type === "settings.delete") { + modifiedModPack.settings[change.scope].delete(change.name); + } else { + throw new Error(`Unknown change type ${change.type}`); + } + } +} + export default function ModPackViewPage() { let account = useAccount(); let history = useHistory(); @@ -562,12 +607,20 @@ export default function ModPackViewPage() { let prototypes = useExportedAsset(modPack, "settings"); let locale = useExportedAsset(modPack, "locale"); let [changes, setChanges] = useState([]); - let [modifiedModPack, setModifiedModPack] = useState(modPack); let syncTimeout = useRef(); + let modifiedModPack; + if (!(modPack instanceof libData.ModPack)) { + // Loading or invalid id + modifiedModPack = modPack; + + } else { + modifiedModPack = applyModPackChanges(modPack, changes); + } + useEffect(() => () => { clearTimeout(syncTimeout.current); }); - function pushChange(change) { + const pushChange = useCallback((change) => { setChanges(oldChanges => { const newChanges = [...oldChanges]; if ( @@ -582,20 +635,22 @@ export default function ModPackViewPage() { } return newChanges; }); - } + }, []); - function revertChange(change) { - const newChanges = [...changes]; - const index = newChanges.findLastIndex(reference => ( - change.type === reference.type && change.name === reference.name - )); - if (index === -1) { - logger.error(`Unable to revert ${change.type} ${change.name}: change not found`); - return; - } - newChanges.splice(index, 1); - setChanges(newChanges); - } + const revertChange = useCallback((change) => { + setChanges(oldChanges => { + const newChanges = [...oldChanges]; + const index = newChanges.findLastIndex(reference => ( + change.type === reference.type && change.name === reference.name + )); + if (index === -1) { + logger.error(`Unable to revert ${change.type} ${change.name}: change not found`); + return oldChanges; + } + newChanges.splice(index, 1); + return newChanges; + }); + }, []); function syncPack(fields) { for (let field of fields) { @@ -623,36 +678,6 @@ export default function ModPackViewPage() { } } - useEffect(() => { - // Loading or invalid id - if (!(modPack instanceof libData.ModPack)) { - setModifiedModPack(modPack); - return; - } - - const modified = new libData.ModPack(modPack.toJSON()); - for (let change of changes) { - if (change.type === "name") { - modified.name = change.value; - } else if (change.type === "description") { - modified.description = change.value; - } else if (change.type === "factorioVersion") { - modified.factorioVersion = change.value; - } else if (change.type === "mods.set") { - modified.mods.set(change.name, change.value); - } else if (change.type === "mods.delete") { - modified.mods.delete(change.name); - } else if (change.type === "settings.set") { - modified.settings[change.scope].set(change.name, change.value); - } else if (change.type === "settings.delete") { - modified.settings[change.scope].delete(change.name); - } else { - throw new Error(`Unknown change type ${change.type}`); - } - } - setModifiedModPack(modified); - }, [modPack, changes]); - let nav = [{ name: "Mods", path: "/mods" }, { name: "Mod Packs" }, { name: modPack.name || modPackId }]; if (modifiedModPack.loading) { return @@ -686,8 +711,8 @@ export default function ModPackViewPage() { okText="Delete" okButtonProps={{ danger: true }} onConfirm={() => { - libLink.messages.deleteModPack.send( - control, { id: modPack.id } + control.send( + new libData.ModPackDeleteRequest(modPack.id) ).then(() => { history.push("/mods"); }).catch(notifyErrorHandler("Error deleting mod pack")); @@ -785,8 +810,8 @@ export default function ModPackViewPage() { :