diff --git a/packages/controller/src/ControllerRouter.ts b/packages/controller/src/ControllerRouter.ts index c7337611..51e972df 100644 --- a/packages/controller/src/ControllerRouter.ts +++ b/packages/controller/src/ControllerRouter.ts @@ -21,7 +21,7 @@ export default class ControllerRouter { */ forwardMessage( origin: ControlConnection | HostConnection, - message: lib.MessageSrcDst, + message: lib.MessageRoutable, hasFallback: boolean ): boolean { if (!["request", "response", "responseError", "event"].includes(message.type)) { @@ -95,7 +95,7 @@ export default class ControllerRouter { broadcastMessage( origin: lib.Link, - message: lib.MessageSrcDst, + message: lib.MessageRoutable, ) { let dst = message.dst; if (message.type !== "event") { @@ -118,7 +118,7 @@ export default class ControllerRouter { } warnUnrouted( - message: lib.MessageSrcDst, + message: lib.MessageRoutable, msg: string ) { let dst = message.dst; diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 9f820595..013d3c9e 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -84,7 +84,7 @@ export default class HostConnection extends BaseConnection { this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this)); } - validateIngress(message: lib.MessageSrcDst) { + validateIngress(message: lib.MessageRoutable) { let origin = this.connector.dst; switch (message.src.type) { case lib.Address.control: diff --git a/packages/host/host.js b/packages/host/host.ts similarity index 89% rename from packages/host/host.js rename to packages/host/host.ts index d5239f09..56654d9c 100755 --- a/packages/host/host.js +++ b/packages/host/host.ts @@ -12,36 +12,37 @@ * @example * npx clusteriohost run */ -"use strict"; -const fs = require("fs-extra"); -const path = require("path"); -const yargs = require("yargs"); -const setBlocking = require("set-blocking"); -const version = require("./package").version; -const winston = require("winston"); -require("winston-daily-rotate-file"); +import fs from "fs-extra"; +import path from "path"; +import yargs from "yargs"; +import setBlocking from "set-blocking"; +import { version } from "./package.json"; +import winston from "winston"; +import "winston-daily-rotate-file"; // internal libraries -const lib = require("@clusterio/lib"); -const { ConsoleTransport, levels, logger } = lib; +import * as lib from "@clusterio/lib"; +import { ConsoleTransport, levels, logger } from "@clusterio/lib"; -const Host = require("./src/Host"); +import Host from "./src/Host"; -class HostConnector extends lib.WebSocketClientConnector { - constructor(hostConfig, tlsCa, pluginInfos) { +export class HostConnector extends lib.WebSocketClientConnector { + constructor( + public hostConfig: lib.HostConfig, + tlsCa: string | undefined, + public pluginInfos: lib.PluginInfo[] + ) { super( hostConfig.get("host.controller_url"), hostConfig.get("host.max_reconnect_delay"), tlsCa ); - this.hostConfig = hostConfig; - this.pluginInfos = pluginInfos; } register() { logger.info("Connector | registering host"); - let plugins = {}; + let plugins: Record = {}; for (let pluginInfo of this.pluginInfos) { plugins[pluginInfo.name] = pluginInfo.version; } @@ -98,7 +99,7 @@ async function startHost() { .command("run", "Run host") .demandCommand(1, "You need to specify a command to run") .strict() - .argv + .parseSync() ; logger.add(new winston.transports.DailyRotateFile({ @@ -111,10 +112,10 @@ async function startHost() { logger.add(new ConsoleTransport({ level: args.logLevel, format: new lib.TerminalFormat(), - filter: info => info.instance_id === undefined, + filter: (info: any) => info.instance_id === undefined, })); } - lib.handleUnhandledErrors(logger); + lib.handleUnhandledErrors(); let command = args._[0]; if (command === "run") { @@ -124,8 +125,8 @@ async function startHost() { logger.info(`Loading available plugins from ${args.pluginList}`); let pluginList = new Map(); try { - pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList))); - } catch (err) { + pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, "utf8"))); + } catch (err: any) { if (err.code !== "ENOENT") { throw err; } @@ -146,9 +147,9 @@ async function startHost() { logger.info(`Loading config from ${args.config}`); let hostConfig = new lib.HostConfig("host"); try { - await hostConfig.load(JSON.parse(await fs.readFile(args.config))); + await hostConfig.load(JSON.parse(await fs.readFile(args.config, "utf8"))); - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { logger.info("Config not found, initializing new config"); await hostConfig.init(); @@ -194,10 +195,10 @@ async function startHost() { return; } - let tlsCa = null; + let tlsCa: string | undefined; let tlsCaPath = hostConfig.get("host.tls_ca"); if (tlsCaPath) { - tlsCa = await fs.readFile(tlsCaPath); + tlsCa = await fs.readFile(tlsCaPath, "utf8"); } let hostConnector = new HostConnector(hostConfig, tlsCa, pluginInfos); diff --git a/packages/host/package.json b/packages/host/package.json index bfbdf6fc..0468f46b 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -4,12 +4,13 @@ "author": "Hornwitser", "license": "MIT", "version": "2.0.0-alpha.13", - "main": "host.js", + "main": "dist/host.js", "scripts": { + "prepare": "tsc --build", "test": "echo \"Error: no test specified\" && exit 1" }, "bin": { - "clusteriohost": "./host.js" + "clusteriohost": "./dist/host.js" }, "keywords": [ "factorio" @@ -33,5 +34,15 @@ }, "publishConfig": { "access": "public" + }, + "devDependencies": { + "@sinclair/typebox": "^0.30.4", + "@types/fs-extra": "^11.0.1", + "@types/node": "^20.4.5", + "@types/pidusage": "^2.0.2", + "@types/semver": "^7.5.0", + "@types/set-blocking": "^2.0.0", + "@types/yargs": "^17.0.24", + "typescript": "^5.1.6" } } diff --git a/packages/host/src/Host.js b/packages/host/src/Host.ts similarity index 79% rename from packages/host/src/Host.js rename to packages/host/src/Host.ts index 01852403..313fdd9c 100644 --- a/packages/host/src/Host.js +++ b/packages/host/src/Host.ts @@ -1,27 +1,28 @@ "use strict"; -const fs = require("fs-extra"); -const path = require("path"); -const events = require("events"); -const pidusage = require("pidusage"); -const setBlocking = require("set-blocking"); -const phin = require("phin"); -const stream = require("stream"); -const util = require("util"); +import fs from "fs-extra"; +import path from "path"; +import events from "events"; +import pidusage from "pidusage"; +import setBlocking from "set-blocking"; +import phin from "phin"; +import stream from "stream"; +import util from "util"; // internal libraries -const lib = require("@clusterio/lib"); -const { logger } = lib; +import * as lib from "@clusterio/lib"; +import { logger } from "@clusterio/lib"; -const Instance = require("./Instance"); -const InstanceConnection = require("./InstanceConnection"); +import type { HostConnector } from "../host"; +import Instance from "./Instance"; +import InstanceConnection from "./InstanceConnection"; const finished = util.promisify(stream.finished); -function checkRequestSaveName(name) { +function checkRequestSaveName(name: string) { try { lib.checkFilename(name); - } catch (err) { + } catch (err: any) { throw new lib.RequestError(`Save name ${err.message}`); } } @@ -33,22 +34,22 @@ function checkRequestSaveName(name) { * instance definitions and return a mapping of instance id to * instanceInfo objects. * - * @param {string} instancesDir - Directory containing instances - * @returns {Promise>} + * @param instancesDir - Directory containing instances + * @returns * mapping between instance id and information about this instance. - * @private + * @internal */ -async function discoverInstances(instancesDir) { - let instanceInfos = new Map(); +async function discoverInstances(instancesDir: string) { + let instanceInfos = new Map(); for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) { if (entry.isDirectory()) { let instanceConfig = new lib.InstanceConfig("host"); let configPath = path.join(instancesDir, entry.name, "instance.json"); try { - await instanceConfig.load(JSON.parse(await fs.readFile(configPath))); + await instanceConfig.load(JSON.parse(await fs.readFile(configPath, "utf8"))); - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { continue; // Ignore folders without config.json } @@ -81,27 +82,24 @@ const instanceStartingMessages = new Set([ ]); class HostRouter { - /** @type {module:host/src/Host} */ - host; - - constructor(host) { - this.host = host; - } + constructor( + public host: Host + ) { } /** * Forward a message to the next hop towards its destination. * - * @param {module:lib.Link} origin - + * @param origin - * Link the message originated from. - * @param {module:lib.Message} message - Message to process. - * @param {boolean} hasFallback - is fallback available - * @returns {boolean} true if the message was handled, false if fallback + * @param message - Message to process. + * @param hasFallback - is fallback available + * @returns true if the message was handled, false if fallback * is requested */ - forwardMessage(origin, message, hasFallback) { + forwardMessage(origin: lib.Link, message: lib.MessageRoutable, hasFallback: boolean) { let dst = message.dst; - let nextHop; - let msg; + let nextHop: lib.Link | undefined; + let msg: string | undefined; if (dst.type === lib.Address.broadcast) { this.broadcastMessage(origin, message); return true; @@ -113,16 +111,19 @@ class HostRouter { ) { nextHop = this.host; } else if (dst.type === lib.Address.instance && this.host.instanceInfos.has(dst.id)) { - nextHop = this.host.instanceConnections.get(dst.id); + nextHop = this.host.instanceConnections.get(dst.id)!; } if (nextHop === origin) { - msg = `Message would return back to sender ${origin.dst}.`; + msg = `Message would return back to sender ${origin.connector.dst}.`; nextHop = undefined; } if (message.type === "request") { - if (dst.type === lib.Address.instance && instanceStartingMessages.has(message.name)) { + if ( + dst.type === lib.Address.instance + && instanceStartingMessages.has((message as lib.MessageRequest).name) + ) { this.wakeInstance(origin, message, nextHop); return true; @@ -151,7 +152,7 @@ class HostRouter { return true; } - broadcastMessage(origin, message) { + broadcastMessage(origin: lib.Link, message: lib.MessageRoutable) { let dst = message.dst; if (message.type !== "event") { this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`); @@ -168,14 +169,14 @@ class HostRouter { if (this.host !== origin) { this.host.connector.send(message); } else { - logger.warn(`Received control broadcast of ${message.name} from master.`); + logger.warn(`Received control broadcast of ${(message as lib.MessageEvent).name} from master.`); } } else { this.warnUnrouted(message, `Unexpected broacdast target ${dst.id}`); } } - wakeInstance(origin, message, nextHop) { + wakeInstance(origin: lib.Link, message: lib.MessageRoutable, nextHop?: lib.Link) { let dst = message.dst; if (nextHop) { origin.connector.sendResponseError( @@ -200,24 +201,24 @@ class HostRouter { }); } - sendMessage(nextHop, message, origin) { + sendMessage(nextHop: lib.Link, message: lib.MessageRoutable, origin: lib.Link) { try { if (message.type === "request") { - nextHop.forwardRequest(message, origin); + nextHop.forwardRequest(message as lib.MessageRequest, origin); } else { nextHop.connector.send(message); } - } catch (err) { + } catch (err: any) { if (message.type === "request") { origin.connector.sendResponseError( new lib.ResponseError(err.message, err.code, err.stack), message.src ); } - logger.warn(`Failed to deliver ${message.name || "message"} (${message.type}): ${err.message}`); + logger.warn(`Failed to deliver ${(message as any).name || "message"} (${message.type}): ${err.message}`); } } - warnUnrouted(message, msg) { + warnUnrouted(message: lib.MessageRoutable, msg?: string) { let dst = message.dst; let baseMsg = `No destination for ${message.constructor.name} routed from ${message.src} to ${dst}`; logger.warn(msg ? `${baseMsg}: ${msg}.` : `${baseMsg}.`); @@ -228,39 +229,50 @@ class HostRouter { * Handles running the host * * Connects to the controller over the WebSocket and manages intsances. - * @alias module:host/src/Host */ -class Host extends lib.Link { - constructor(connector, hostConfig, tlsCa, pluginInfos) { +export default class Host extends lib.Link { + declare ["connector"]: HostConnector; + + router = new HostRouter(this); + /** + * Certificate authority used to validate TLS connections to the controller. + */ + tlsCa?: string; + pluginInfos: lib.PluginInfo[]; + config: lib.HostConfig; + + instanceConnections = new Map(); + discoveredInstanceInfos = new Map(); + instanceInfos = new Map(); + + adminlist = new Set(); + banlist = new Map(); + whitelist = new Set(); + + serverVersion = "unknown"; + serverPlugins = new Map(); + + _startup = true; + _disconnecting = false; + _shuttingDown = false; + + constructor( + connector: HostConnector, + hostConfig: lib.HostConfig, + tlsCa: string | undefined, + pluginInfos: lib.PluginInfo[] + ) { super(connector); - - this.router = new HostRouter(this); - this.pluginInfos = pluginInfos; - this.config = hostConfig; - - /** - * Certificate authority used to validate TLS connections to the controller. - * @type {?string} - */ this.tlsCa = tlsCa; - this.instanceConnections = new Map(); - this.discoveredInstanceInfos = new Map(); - this.instanceInfos = new Map(); - - this.adminlist = new Set(); - this.banlist = new Map(); - this.whitelist = new Set(); + this.pluginInfos = pluginInfos; + this.config = hostConfig; this.connector.on("hello", data => { this.serverVersion = data.version; this.serverPlugins = new Map(Object.entries(data.plugins)); }); - this._startup = true; - this._disconnecting = false; - this._shuttingDown = false; - this.connector.on("connect", () => { if (this._shuttingDown) { return; @@ -320,11 +332,11 @@ class Host extends lib.Link { this.handle(lib.InstanceDeleteInternalRequest, this.handleInstanceDeleteInternalRequest.bind(this)); } - async _createNewInstanceDir(name) { + async _createNewInstanceDir(name: string) { name = lib.cleanFilename(name); try { lib.checkFilename(name); - } catch (err) { + } catch (err: any) { throw new Error(`Instance folder was unepectedly invalid: name ${err.message}`); } @@ -333,7 +345,7 @@ class Host extends lib.Link { let candidateDir = path.join(instancesDir, await lib.findUnusedName(instancesDir, name)); try { await fs.mkdir(candidateDir); - } catch (err) { + } catch (err: any) { if (err.code === "EEXIST") { continue; } @@ -344,15 +356,17 @@ class Host extends lib.Link { throw Error("Unable to create instance dir, retry threshold reached"); } - async broadcastEventToInstance(event) { + async broadcastEventToInstance(event: lib.Event) { for (let instanceConnection of this.instanceConnections.values()) { - if (event.constructor.plugin && !instanceConnection.plugins.has(event.plugin)) { continue; } + if (event.constructor.plugin && !instanceConnection.plugins.has(event.constructor.plugin)) { + continue; + } instanceConnection.send(event); } } - async handleSyncUserListsEvent(request) { - let updateList = (list, updatedList, Event) => { + async handleSyncUserListsEvent(event: lib.SyncUserListsEvent) { + let updateList = (list: Set, updatedList: Set, Event: lib.EventClass) => { let added = new Set(updatedList); let removed = new Set(list); list.forEach(el => added.delete(el)); @@ -369,10 +383,10 @@ class Host extends lib.Link { } }; - updateList(this.adminlist, request.adminlist, lib.InstanceAdminlistUpdateEvent); - updateList(this.whitelist, request.whitelist, lib.InstanceWhitelistUpdateEvent); + updateList(this.adminlist, event.adminlist, lib.InstanceAdminlistUpdateEvent); + updateList(this.whitelist, event.whitelist, lib.InstanceWhitelistUpdateEvent); - let addedOrChanged = new Map(request.banlist); + let addedOrChanged = new Map(event.banlist); let removed = new Set(this.banlist.keys()); addedOrChanged.forEach((_, name) => removed.delete(name)); this.banlist.forEach((reason, name) => { @@ -392,8 +406,8 @@ class Host extends lib.Link { } } - async handleAdminlistUpdateEvent(request) { - let { name, admin } = request; + async handleAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent) { + let { name, admin } = event; if (admin) { this.adminlist.add(name); } else { @@ -401,8 +415,8 @@ class Host extends lib.Link { } } - async handleBanlistUpdateEvent(request) { - let { name, banned, reason } = request; + async handleBanlistUpdateEvent(event: lib.InstanceBanlistUpdateEvent) { + let { name, banned, reason } = event; if (banned) { this.banlist.set(name, reason); } else { @@ -410,8 +424,8 @@ class Host extends lib.Link { } } - async handleWhitelistUpdateEvent(request) { - let { name, whitelisted } = request; + async handleWhitelistUpdateEvent(event: lib.InstanceWhitelistUpdateEvent) { + let { name, whitelisted } = event; if (whitelisted) { this.whitelist.add(name); } else { @@ -419,21 +433,21 @@ class Host extends lib.Link { } } - async handleInstanceAssignInternalRequest(request) { + async handleInstanceAssignInternalRequest(request: lib.InstanceAssignInternalRequest) { let { instanceId, config } = request; let instanceInfo = this.instanceInfos.get(instanceId); if (instanceInfo) { - instanceInfo.config.update(config, true, "controller"); + instanceInfo.config.update(config as any, true, "controller"); logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instanceId, instanceInfo)); } else { instanceInfo = this.discoveredInstanceInfos.get(instanceId); if (instanceInfo) { - instanceInfo.config.update(config, true, "controller"); + instanceInfo.config.update(config as any, true, "controller"); } else { let instanceConfig = new lib.InstanceConfig("host"); - await instanceConfig.load(config, "controller"); + await instanceConfig.load(config as any, "controller"); let instanceDir = await this._createNewInstanceDir(instanceConfig.get("instance.name")); @@ -472,7 +486,7 @@ class Host extends lib.Link { ); } - async handleInstanceUnassignInternalRequest(request) { + async handleInstanceUnassignInternalRequest(request: lib.InstanceUnassignInternalRequest) { let instanceId = request.instanceId; let instanceInfo = this.instanceInfos.get(instanceId); if (instanceInfo) { @@ -486,7 +500,7 @@ class Host extends lib.Link { } } - instanceLogMeta(instanceId, instanceInfo) { + instanceLogMeta(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) { instanceInfo = instanceInfo || this.instanceInfos.get(instanceId); if (!instanceInfo) { return { instance_id: instanceId, instance_name: String(instanceId) }; @@ -494,7 +508,7 @@ class Host extends lib.Link { return { instance_id: instanceId, instance_name: instanceInfo.config.get("instance.name") }; } - getRequestInstanceInfo(instanceId) { + getRequestInstanceInfo(instanceId: number) { let instanceInfo = this.instanceInfos.get(instanceId); if (!instanceInfo) { throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`); @@ -505,16 +519,16 @@ class Host extends lib.Link { /** * Initialize and connect an unloaded instance * - * @param {number} instanceId - ID of instance to initialize. - * @returns {Promise} connection to instance. + * @param instanceId - ID of instance to initialize. + * @returns connection to instance. */ - async _connectInstance(instanceId) { + async _connectInstance(instanceId: number) { let instanceInfo = this.getRequestInstanceInfo(instanceId); if (this.instanceConnections.has(instanceId)) { throw new lib.RequestError(`Instance with ID ${instanceId} is running`); } - let hostAddress = new lib.Address(lib.Address.host, this.id); + let hostAddress = new lib.Address(lib.Address.host, this.config.get("host.id")); let instanceAddress = new lib.Address(lib.Address.instance, instanceId); let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress); let instanceConnection = new InstanceConnection(connectionServer, this, instanceId); @@ -554,12 +568,12 @@ class Host extends lib.Link { return { results }; } - async fallbackInstanceListSavesRequest(request, src, dst) { + async fallbackInstanceListSavesRequest(request: lib.InstanceListSavesRequest, src: lib.Address, dst: lib.Address) { let instanceInfo = this.getRequestInstanceInfo(dst.id); return await Instance.listSaves(path.join(instanceInfo.path, "saves"), null); } - async handleInstanceRenameSaveRequest(request) { + async handleInstanceRenameSaveRequest(request: lib.InstanceRenameSaveRequest) { let { instanceId, oldName, newName } = request; checkRequestSaveName(oldName); checkRequestSaveName(newName); @@ -570,7 +584,7 @@ class Host extends lib.Link { path.join(instanceInfo.path, "saves", newName), { overwrite: false }, ); - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { throw new lib.RequestError(`${oldName} does not exist`); } @@ -579,7 +593,7 @@ class Host extends lib.Link { await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); } - async handleInstanceCopySaveRequest(request) { + async handleInstanceCopySaveRequest(request: lib.InstanceCopySaveRequest) { let { instanceId, source, destination } = request; checkRequestSaveName(source); checkRequestSaveName(destination); @@ -590,7 +604,7 @@ class Host extends lib.Link { path.join(instanceInfo.path, "saves", destination), { overwrite: false, errorOnExist: true }, ); - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { throw new lib.RequestError(`${source} does not exist`); } @@ -599,7 +613,7 @@ class Host extends lib.Link { await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); } - async handleInstanceTransferSaveRequest(request) { + async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) { let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request; checkRequestSaveName(sourceName); checkRequestSaveName(targetName); @@ -626,7 +640,7 @@ class Host extends lib.Link { { overwrite: true }, ); } - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { throw new lib.RequestError(`${sourceName} does not exist`); } @@ -638,9 +652,9 @@ class Host extends lib.Link { return targetName; } - async sendSaveListUpdate(instanceId, savesDir) { + async sendSaveListUpdate(instanceId: number, savesDir: string) { let instanceConnection = this.instanceConnections.get(instanceId); - let saveList; + let saveList: lib.SaveDetails[]; if (instanceConnection) { saveList = await instanceConnection.send(new lib.InstanceListSavesRequest()); } else { @@ -650,14 +664,14 @@ class Host extends lib.Link { this.send(new lib.InstanceSaveListUpdateEvent(instanceId, saveList)); } - async handleInstanceDeleteSaveRequest(request) { + async handleInstanceDeleteSaveRequest(request: lib.InstanceDeleteSaveRequest) { let { instanceId, name } = request; checkRequestSaveName(name); let instanceInfo = this.getRequestInstanceInfo(instanceId); try { await fs.unlink(path.join(instanceInfo.path, "saves", name)); - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { throw new lib.RequestError(`${name} does not exist`); } @@ -666,7 +680,7 @@ class Host extends lib.Link { await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); } - async handleInstancePullSaveRequest(request) { + async handleInstancePullSaveRequest(request: lib.InstancePullSaveRequest) { let { instanceId, streamId, name } = request; checkRequestSaveName(name); let instanceInfo = this.getRequestInstanceInfo(instanceId); @@ -675,7 +689,7 @@ class Host extends lib.Link { url.pathname += `api/stream/${streamId}`; let response = await phin({ url, method: "GET", - core: { ca: this.tlsCa }, + core: { ca: this.tlsCa } as {}, stream: true, }); @@ -686,13 +700,13 @@ class Host extends lib.Link { let savesDir = path.join(instanceInfo.path, "saves"); let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip"); - let writeStream; + let writeStream: NodeJS.WritableStream; while (true) { try { writeStream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" }); await events.once(writeStream, "open"); break; - } catch (err) { + } catch (err: any) { if (err.code === "EEXIST") { tempFilename = await lib.findUnusedName(savesDir, tempFilename, ".tmp.zip"); } else { @@ -710,16 +724,16 @@ class Host extends lib.Link { return name; } - async handleInstancePushSaveRequest(request) { + async handleInstancePushSaveRequest(request: lib.InstancePushSaveRequest) { let { instanceId, streamId, name } = request; checkRequestSaveName(name); let instanceInfo = this.getRequestInstanceInfo(instanceId); - let content; + let content: Buffer; try { // phin doesn't support streaming requests :( content = await fs.readFile(path.join(instanceInfo.path, "saves", name)); - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { throw new lib.RequestError(`${name} does not exist`); } @@ -730,19 +744,21 @@ class Host extends lib.Link { url.pathname += `api/stream/${streamId}`; phin({ url, method: "PUT", - core: { ca: this.tlsCa }, + core: { ca: this.tlsCa } as {}, data: content, }).catch(err => { logger.error(`Error pushing save to controller:\n${err.stack}`, this.instanceLogMeta(instanceId)); }); } - async stopInstance(instanceId) { + async stopInstance(instanceId: number) { let instanceConnection = this.instanceConnections.get(instanceId); - await instanceConnection.send(new lib.InstanceStopRequest()); + if (instanceConnection) { + await instanceConnection.send(new lib.InstanceStopRequest()); + } } - async handleInstanceDeleteInternalRequest(request) { + async handleInstanceDeleteInternalRequest(request: lib.InstanceDeleteInternalRequest) { let instanceId = request.instanceId; if (this.instanceConnections.has(instanceId)) { throw new lib.RequestError(`Instance with ID ${instanceId} is running`); @@ -784,8 +800,8 @@ class Host extends lib.Link { if (instanceInfo.config.get("instance.auto_start")) { try { let instanceConnection = await this._connectInstance(instanceId); - await instanceConnection.send(new lib.InstanceStartRequest(null)); - } catch (err) { + await instanceConnection.send(new lib.InstanceStartRequest()); + } catch (err: any) { logger.error( `Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`, this.instanceLogMeta(instanceId, instanceInfo) @@ -816,14 +832,14 @@ class Host extends lib.Link { for (let instanceId of this.instanceConnections.keys()) { try { await this.stopInstance(instanceId); - } catch (err) { + } catch (err: any) { logger.error(`Unexpected error stopping instance:\n${err.stack}`); } } try { await this.connector.disconnect(); - } catch (err) { + } catch (err: any) { if (!(err instanceof lib.SessionLost)) { logger.error(`Unexpected error preparing disconnect:\n${err.stack}`); } @@ -832,7 +848,7 @@ class Host extends lib.Link { try { // Clear silly interval in pidfile library. pidusage.clear(); - } catch (err) { + } catch (err: any) { setBlocking(true); logger.error(` +------------------------------------------------------------+ @@ -849,15 +865,11 @@ ${err.stack}` /** * True if the connection to the controller is connected, not in the dropped * state,and not in the process of disconnecting. - * @type {boolean} */ get connected() { return !this._disconnecting && this.connector.connected; } } -module.exports = Host; - // For testing only -module.exports._discoverInstances = discoverInstances; - +export const _discoverInstances = discoverInstances; diff --git a/packages/host/src/Instance.js b/packages/host/src/Instance.ts similarity index 83% rename from packages/host/src/Instance.js rename to packages/host/src/Instance.ts index add7aceb..b92e9bfa 100644 --- a/packages/host/src/Instance.js +++ b/packages/host/src/Instance.ts @@ -1,17 +1,17 @@ -"use strict"; -const fs = require("fs-extra"); -const path = require("path"); -const pidusage = require("pidusage"); -const phin = require("phin"); -const util = require("util"); +import fs from "fs-extra"; +import path from "path"; +import pidusage from "pidusage"; +import phin from "phin"; +import util from "util"; +import type { Static } from "@sinclair/typebox"; // internal libraries -const lib = require("@clusterio/lib"); -const { PlayerStats } = lib; +import * as lib from "@clusterio/lib"; -const { FactorioServer } = require("./server"); -const { patch } = require("./patch"); -const { exportData } = require("./export"); +import { FactorioServer } from "./server"; +import { ModuleInfo, patch } from "./patch"; +import { exportData } from "./export"; +import type Host from "./Host" const instanceRconCommandDuration = new lib.Histogram( @@ -47,8 +47,8 @@ const instanceFactorioAutosaveSize = new lib.Gauge( { labels: ["instance_id"] } ); -function applyAsConfig(name) { - return async function action(instance, value, logger) { +function applyAsConfig(name: string) { + return async function action(instance: Instance, value: unknown, logger: lib.Logger) { if (name === "tags" && value instanceof Array) { // Replace spaces with non-break spaces and delimit by spaces. // This does change the defined tags, but there doesn't seem to @@ -57,7 +57,7 @@ function applyAsConfig(name) { } try { await instance.sendRcon(`/config set ${name} ${value}`); - } catch (err) { + } catch (err: any) { logger.error(`Error applying server setting ${name} ${err.message}`); } }; @@ -78,10 +78,10 @@ const serverSettingsActions = { "game_password": applyAsConfig("password"), "require_user_verification": applyAsConfig("require-user-verification"), "tags": applyAsConfig("tags"), - "visibility": async (instance, value, logger) => { + "visibility": async (instance: Instance, value: unknown, logger: lib.Logger) => { for (let scope of ["lan", "public", "steam"]) { try { - let enabled = Boolean(value[scope]); + let enabled = Boolean((value as Record)[scope]); await instance.sendRcon(`/config set visibility-${scope} ${enabled}`); } catch (err) { logger.error(`Error applying visibility ${scope} ${err}`); @@ -95,8 +95,46 @@ const serverSettingsActions = { * Keeps track of the runtime parameters of an instance * @alias module:host/src/Instance */ -class Instance extends lib.Link { - constructor(host, connector, dir, factorioDir, instanceConfig) { +export default class Instance extends lib.Link { + /** + * ID of this instance, equivalenet to `instance.config.get("instance.id")`. + */ + readonly id: number + plugins: Map; + config: lib.InstanceConfig; + logger: lib.Logger; + server: FactorioServer; + /** + * Mod pack currently running on this instance + */ + activeModPack = new lib.ModPack(); + /** + * Per player statistics recorded by this instance. + */ + playerStats: Map = new Map(); + /** + * Players currently online on the instance. + */ + playersOnline: Set = new Set(); + + + _host: Host; + _dir: string; + _configFieldChanged: (group: lib.ConfigGroup, field: string, prev: unknown) => void; + _status: "stopped" | "starting" | "running" | "stopping" | "creating_save" | "exporting_data" = "stopped"; + _loadedSave: string | null = null; + _playerCheckInterval: ReturnType | undefined; + _hadPlayersOnline = false; + _playerAutosaveSlot = 1; + + + constructor( + host: Host, + connector: lib.VirtualConnector, + dir: string, + factorioDir: string, + instanceConfig: lib.InstanceConfig + ) { super(connector); this._host = host; this._dir = dir; @@ -104,10 +142,6 @@ class Instance extends lib.Link { this.plugins = new Map(); this.config = instanceConfig; - /** - * ID of this instance, equivalenet to `instance.config.get("instance.id")`. - * @constant {number} - */ this.id = this.config.get("instance.id"); this.logger = lib.logger.child({ @@ -115,16 +149,16 @@ class Instance extends lib.Link { instance_name: this.name, }); - this._configFieldChanged = (group, field, prev) => { + this._configFieldChanged = (group: lib.ConfigGroup, field: string, prev: unknown) => { let hook = () => lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", group, field, prev); if (group.name === "factorio" && field === "settings") { - this.updateFactorioSettings(group.get(field), prev).finally(hook); + this.updateFactorioSettings(group.get(field) as any, prev as any).finally(hook); } else if (group.name === "factorio" && field === "enable_whitelist") { - this.updateFactorioWhitelist(group.get(field)).finally(hook); + this.updateFactorioWhitelist(group.get(field) as any).finally(hook); } else { if (group.name === "factorio" && field === "max_concurrent_commands") { - this.server.maxConcurrentCommands = group.get(field); + this.server.maxConcurrentCommands = group.get(field) as number; } hook(); } @@ -134,9 +168,9 @@ class Instance extends lib.Link { let serverOptions = { logger: this.logger, version: this.config.get("factorio.version"), - gamePort: this.config.get("factorio.game_port"), - rconPort: this.config.get("factorio.rcon_port"), - rconPassword: this.config.get("factorio.rcon_password"), + gamePort: this.config.get("factorio.game_port") ?? undefined, + rconPort: this.config.get("factorio.rcon_port") ?? undefined, + rconPassword: this.config.get("factorio.rcon_password") ?? undefined, enableWhitelist: this.config.get("factorio.enable_whitelist"), enableAuthserverBans: this.config.get("factorio.enable_authserver_bans"), verboseLogging: this.config.get("factorio.verbose_logging"), @@ -144,9 +178,6 @@ class Instance extends lib.Link { maxConcurrentCommands: this.config.get("factorio.max_concurrent_commands"), }; - // Valid statuses are stopped, starting, running, stopping, creating_save and exporting_data. - this._status = "stopped"; - this._loadedSave = null; this.server = new FactorioServer( factorioDir, this._dir, serverOptions ); @@ -185,27 +216,6 @@ class Instance extends lib.Link { } }); - /** - * Mod pack currently running on this instance - * @type {module:lib.ModPack} - */ - this.activeModPack = undefined; - - /** - * Per player statistics recorded by this instance. - * @type {Map} - */ - this.playerStats = new Map(); - - /** - * Players currently online on the instance. - * @type {Map} - */ - this.playersOnline = new Set(); - this._playerCheckInterval = null; - this._hadPlayersOnline = false; - this._playerAutosaveSlot = 1; - this.handle(lib.InstanceExtractPlayersRequest, this.handleInstanceExtractPlayersRequest.bind(this)); this.handle(lib.InstanceAdminlistUpdateEvent, this.handleInstanceAdminlistUpdateEvent.bind(this)); this.handle(lib.InstanceBanlistUpdateEvent, this.handleInstanceBanlistUpdateEvent.bind(this)); @@ -226,12 +236,12 @@ class Instance extends lib.Link { } _watchPlayerJoinsByChat() { - this.server.on("output", (parsed, line) => { + this.server.on("output", (parsed: lib.ParsedFactorioOutput, line: string) => { if (parsed.type !== "action") { return; } - let name = /^([^ ]+)/.exec(parsed.message)[1]; + let name = /^([^ ]+)/.exec(parsed.message)![1]; if (parsed.action === "JOIN") { this._recordPlayerJoin(name); } else if (["LEAVE", "KICK", "BAN"].includes(parsed.action)) { @@ -239,7 +249,7 @@ class Instance extends lib.Link { "LEAVE": "quit", "KICK": "kicked", "BAN": "banned", - }[parsed.action]; + }[parsed.action]!; this._recordPlayerLeave(name, reason); } }); @@ -278,7 +288,7 @@ class Instance extends lib.Link { } } - _recordPlayerJoin(name) { + _recordPlayerJoin(name: string) { if (this.playersOnline.has(name)) { return; } @@ -286,7 +296,7 @@ class Instance extends lib.Link { let stats = this.playerStats.get(name); if (!stats) { - stats = new PlayerStats(); + stats = new lib.PlayerStats(); this.playerStats.set(name, stats); } stats.lastJoinAt = new Date(); @@ -302,15 +312,15 @@ class Instance extends lib.Link { lib.invokeHook(this.plugins, "onPlayerEvent", event); } - _recordPlayerLeave(name, reason) { + _recordPlayerLeave(name: string, reason: string) { if (!this.playersOnline.delete(name)) { return; } - let stats = this.playerStats.get(name); + let stats = this.playerStats.get(name)!; stats.lastLeaveAt = new Date(); stats.lastLeaveReason = reason; - stats.onlineTimeMs += stats.lastLeaveAt.getTime() - stats.lastJoinAt.getTime(); + stats.onlineTimeMs += stats.lastLeaveAt.getTime() - stats.lastJoinAt!.getTime(); this._hadPlayersOnline = true; let event = { @@ -331,13 +341,13 @@ for _, p in pairs(game.players) do players[p.name] = p.online_time end rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); - let playerTimes = JSON.parse(await this.sendRcon(exportPlayerTimes)); + let playerTimes: Record = JSON.parse(await this.sendRcon(exportPlayerTimes)); let count = 0; for (let [name, onlineTimeTicks] of Object.entries(playerTimes)) { let stats = this.playerStats.get(name); if (!stats) { - stats = new PlayerStats(); + stats = new lib.PlayerStats(); this.playerStats.set(name, stats); } stats.onlineTimeMs = onlineTimeTicks * 1000 / 60; @@ -355,7 +365,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); this.logger.info(`Extracted data for ${count} player(s)`); } - async sendRcon(message, expectEmpty, plugin = "") { + async sendRcon(message: string, expectEmpty = false, plugin = "") { let instanceId = String(this.id); let observeDuration = instanceRconCommandDuration.labels(instanceId).startTimer(); try { @@ -366,7 +376,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } } - static async listSaves(savesDir, loadedSave) { + static async listSaves(savesDir: string, loadedSave: string | null) { let defaultSave = null; if (loadedSave === null) { defaultSave = await lib.getNewestFile( @@ -374,9 +384,9 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); ); } - let list = []; + let list: lib.SaveDetails[] = []; for (let name of await fs.readdir(savesDir)) { - let type; + let type: "file" | "directory" | "special"; let stat = await fs.stat(path.join(savesDir, name)); if (stat.isFile()) { type = "file"; @@ -409,7 +419,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); ); } - async _autosave(name) { + async _autosave(name: string) { let stat = await fs.stat(this.path("saves", `${name}.zip`)); instanceFactorioAutosaveSize.labels(String(this.id)).set(stat.size); @@ -431,7 +441,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); await this.sendSaveListUpdate(); } - notifyStatus(status) { + notifyStatus(status: Instance["_status"]) { this._status = status; this.sendTo( "controller", @@ -447,8 +457,6 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); * Current state of the instance * * One of stopped, starting, running, stopping, creating_save and exporting_data - * - * @returns {string} instance status. */ get status() { return this._status; @@ -482,7 +490,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); this._saveStats().catch(err => this.logger.error(`Error saving stats:\n${err.stack}`)); } - async _loadPlugin(pluginInfo, host) { + async _loadPlugin(pluginInfo: lib.PluginInfo, host: Host) { let pluginLoadStarted = Date.now(); let InstancePluginClass = await lib.loadInstancePluginClass(pluginInfo); let instancePlugin = new InstancePluginClass(pluginInfo, this, host); @@ -495,14 +503,16 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); async _loadStats() { let instanceStats; try { - instanceStats = JSON.parse(await fs.readFile(this.path("instance-stats.json"))); - } catch (err) { + instanceStats = JSON.parse(await fs.readFile(this.path("instance-stats.json"), "utf8")); + } catch (err: any) { if (err.code === "ENOENT") { return; } throw err; } - this.playerStats = new Map(instanceStats["players"].map(([id, stats]) => [id, new PlayerStats(stats)])); + this.playerStats = new Map(instanceStats["players"].map( + ([id, stats]: [number, Static]) => [id, new lib.PlayerStats(stats)]) + ); this._playerAutosaveSlot = instanceStats["player_autosave_slot"] || 1; } @@ -514,7 +524,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); await lib.safeOutputFile(this.path("instance-stats.json"), content); } - async init(pluginInfos) { + async init(pluginInfos: lib.PluginInfo[]) { this.notifyStatus("starting"); try { await this._loadStats(); @@ -544,7 +554,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } } - let plugins = {}; + let plugins: Record = {}; for (let [name, plugin] of this.plugins) { plugins[name] = plugin.info.version; } @@ -557,11 +567,11 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); * Use the example settings as the basis and override it with all the * entries from the given settings object. * - * @param {Object} overrides - Server settings to override. - * @returns {Promise} + * @param overrides - Server settings to override. + * @returns * server example settings with the given settings applied over it. */ - async resolveServerSettings(overrides) { + async resolveServerSettings(overrides: Record) { let serverSettings = await this.server.exampleSettings(); for (let [key, value] of Object.entries(overrides)) { @@ -595,11 +605,11 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); * Creates the neccessary files for starting up a new instance into the * provided instance directory. * - * @param {String} instanceDir - + * @param instanceDir - * Directory to create the new instance into. - * @param {String} factorioDir - Path to factorio installation. + * @param factorioDir - Path to factorio installation. */ - static async create(instanceDir, factorioDir) { + static async create(instanceDir: string, factorioDir: string) { await fs.ensureDir(path.join(instanceDir, "script-output")); await fs.ensureDir(path.join(instanceDir, "saves")); } @@ -722,12 +732,12 @@ 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|undefined} saveName - + * @param saveName - * Save to prepare from the instance saves directory. Creates a new * save if null. - * @returns {Promise} Name of the save prepared. + * @returns Name of the save prepared. */ - async prepareSave(saveName) { + async prepareSave(saveName?: string) { // Use latest save if no save was specified if (saveName === undefined) { saveName = await lib.getNewestFile( @@ -766,8 +776,16 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); // Patch save with lua modules from plugins this.logger.verbose("Patching save"); + interface ModuleJson { + version: string, + name: string; + load?: string[]; + require?: string[]; + dependencies?: Record; + } + // Find plugin modules to patch in - let modules = new Map(); + let modules: Map = new Map(); for (let [pluginName, plugin] of this.plugins) { let pluginPackagePath = require.resolve(path.posix.join(plugin.info.requirePath, "package.json")); let modulePath = path.join(path.dirname(pluginPackagePath), "module"); @@ -780,30 +798,29 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); throw new Error(`Module for plugin ${pluginName} is missing module.json`); } - let module; + let module: Omit; try { - module = JSON.parse(await fs.readFile(moduleJsonPath)); - } catch (err) { + module = JSON.parse(await fs.readFile(moduleJsonPath, "utf8")); + } catch (err: any) { throw new Error(`Loading module/module.json in plugin ${pluginName} failed: ${err.message}`); } if (module.name !== pluginName) { throw new Error(`Expected name of module for plugin ${pluginName} to match the plugin name`); } - module = { + modules.set(module.name, { version: plugin.info.version, dependencies: { "clusterio": "*" }, path: modulePath, load: [], require: [], ...module, - }; - modules.set(module.name, module); + }); } // Find stand alone modules to load // XXX for now only the included clusterio module is loaded - let modulesDirectory = path.join(__dirname, "..", "modules"); + let modulesDirectory = path.join(__dirname, "..", "..", "modules"); for (let entry of await fs.readdir(modulesDirectory, { withFileTypes: true })) { if (!entry.isFile()) { if (modules.has(entry.name)) { @@ -815,19 +832,18 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); throw new Error(`Module ${entry.name} is missing module.json`); } - let module = JSON.parse(await fs.readFile(moduleJsonPath)); + let module: ModuleJson = JSON.parse(await fs.readFile(moduleJsonPath, "utf8")); if (module.name !== entry.name) { throw new Error(`Expected name of module ${entry.name} to match the directory name`); } - module = { + modules.set(module.name, { path: path.join(modulesDirectory, entry.name), dependencies: { "clusterio": "*" }, load: [], require: [], ...module, - }; - modules.set(module.name, module); + }); } } @@ -840,9 +856,9 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); * * Launches the Factorio server for this instance with the given save. * - * @param {String} saveName - Name of save game to load. + * @param saveName - Name of save game to load. */ - async start(saveName) { + async start(saveName: string) { this.server.on("rcon-ready", () => { this.logger.verbose("RCON connection established"); }); @@ -870,12 +886,12 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); * Launches the Factorio server for this instance with the given * scenario. * - * @param {String} scenario - Name of scenario to load. - * @param {?number} seed - seed to use. - * @param {?object} mapGenSettings - MapGenSettings to use. - * @param {?object} mapSettings - MapSettings to use. + * @param scenario - Name of scenario to load. + * @param seed - seed to use. + * @param mapGenSettings - MapGenSettings to use. + * @param mapSettings - MapSettings to use. */ - async startScenario(scenario, seed, mapGenSettings, mapSettings) { + async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) { this.server.on("rcon-ready", () => { this.logger.verbose("RCON connection established"); }); @@ -897,7 +913,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); await this.sendRcon(`/sc clusterio_private.update_instance(${this.id}, "${name}")`, true); } - async updateFactorioSettings(current, previous) { + async updateFactorioSettings(current: Record, previous: Record) { current = await this.resolveServerSettings(current); previous = await this.resolveServerSettings(previous); @@ -911,10 +927,10 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); /** * Enable or disable the player whitelist * - * @param {boolean} enable - + * @param enable - * True to enable the whitelist, False to disable the whitelist. */ - async updateFactorioWhitelist(enable) { + async updateFactorioWhitelist(enable: boolean) { if (!enable) { await this.sendRcon("/whitelist disable"); } @@ -931,7 +947,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } } - async handleInstanceAdminlistUpdateEvent(request) { + async handleInstanceAdminlistUpdateEvent(request: lib.InstanceAdminlistUpdateEvent) { if (!this.config.get("factorio.sync_adminlist")) { return; } @@ -941,7 +957,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); await this.sendRcon(command); } - async handleInstanceBanlistUpdateEvent(request) { + async handleInstanceBanlistUpdateEvent(request: lib.InstanceBanlistUpdateEvent) { if (!this.config.get("factorio.sync_banlist")) { return; } @@ -951,7 +967,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); await this.sendRcon(command); } - async handleInstanceWhitelistUpdateEvent(request) { + async handleInstanceWhitelistUpdateEvent(request: lib.InstanceWhitelistUpdateEvent) { if (!this.config.get("factorio.sync_whitelist")) { return; } @@ -985,7 +1001,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); await this.server.kill(true); } - async handleControllerConnectionEvent(event) { + async handleControllerConnectionEvent(event: lib.ControllerConnectionEvent) { await lib.invokeHook(this.plugins, "onControllerConnectionEvent", event.event); } @@ -998,7 +1014,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); if (!["stopped", "stopping"].includes(this._status)) { let pluginResults = await lib.invokeHook(this.plugins, "onMetrics"); for (let metricIterator of pluginResults) { - for await (let metric of metricIterator) { + for await (let metric of metricIterator as any) { results.push(lib.serializeResult(metric)); } } @@ -1014,7 +1030,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); return new lib.InstanceMetricsRequest.Response(results); } - async handleInstanceStartRequest(request) { + async handleInstanceStartRequest(request: lib.InstanceStartRequest) { let saveName = request.save; try { await this.prepare(); @@ -1033,7 +1049,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); } } - async handleInstanceLoadScenarioRequest(request) { + async handleInstanceLoadScenarioRequest(request: lib.InstanceLoadScenarioRequest) { if (this.config.get("factorio.enable_save_patching")) { this.notifyExit(); throw new lib.RequestError("Load scenario cannot be used with save patching enabled"); @@ -1060,7 +1076,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); return await Instance.listSaves(this.path("saves"), this._loadedSave); } - async handleInstanceCreateSaveRequest(request) { + async handleInstanceCreateSaveRequest(request: lib.InstanceCreateSaveRequest) { this.notifyStatus("creating_save"); try { this.logger.verbose("Writing server-settings.json"); @@ -1095,11 +1111,11 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); let content = await zip.generateAsync({ type: "nodebuffer" }); let url = new URL(this._host.config.get("host.controller_url")); url.pathname += "api/upload-export"; - url.searchParams.set("mod_pack_id", this.activeModPack.id); + url.searchParams.set("mod_pack_id", String(this.activeModPack.id)); let response = await phin({ url, method: "PUT", data: content, - core: { ca: this._host.tlsCa }, + core: { ca: this._host.tlsCa } as {}, headers: { "Content-Type": "application/zip", "x-access-token": this._host.config.get("host.controller_token"), @@ -1122,7 +1138,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); await this.kill(); } - async handleInstanceSendRconRequest(request) { + async handleInstanceSendRconRequest(request: lib.InstanceSendRconRequest) { return await this.sendRcon(request.command); } @@ -1143,11 +1159,9 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " "); * returns a path to the mods directory of the instance. If no parts are * given it returns a path to the directory of the instance. * - * @returns {string} path in instance directory. + * @returns path in instance directory. */ - path(...parts) { + path(...parts: string[]) { return path.join(this._dir, ...parts); } } - -module.exports = Instance; diff --git a/packages/host/src/InstanceConnection.js b/packages/host/src/InstanceConnection.js deleted file mode 100644 index d404c3bf..00000000 --- a/packages/host/src/InstanceConnection.js +++ /dev/null @@ -1,40 +0,0 @@ -"use strict"; -const lib = require("@clusterio/lib"); - - -class InstanceConnection extends lib.Link { - constructor(connector, host, instanceId) { - super(connector); - this.host = host; - this.router = this.host.router; - this.instanceId = instanceId; - this.plugins = new Map(); - this.status = "stopped"; - - this.handle(lib.InstanceInitialisedEvent, this.handleInstanceInitialisedEvent.bind(this)); - this.snoopEvent(lib.InstanceStatusChangedEvent, this.snoopInstanceStatusChangedEvent.bind(this)); - } - - async broadcastEventToInstance(message, event) { - for (let instanceConnection of this.host.instanceConnections.values()) { - // Do not broadcast back to the source - if (instanceConnection === this) { continue; } - if (event.plugin && !instanceConnection.plugins.has(event.plugin)) { continue; } - - event.send(instanceConnection, message.data); - } - } - - async handleInstanceInitialisedEvent(event) { - this.plugins = new Map(Object.entries(event.plugins)); - } - - async snoopInstanceStatusChangedEvent(event) { - this.status = event.status; - if (this.status === "stopped") { - this.host.instanceConnections.delete(this.instanceId); - } - } -} - -module.exports = InstanceConnection; diff --git a/packages/host/src/InstanceConnection.ts b/packages/host/src/InstanceConnection.ts new file mode 100644 index 00000000..57780263 --- /dev/null +++ b/packages/host/src/InstanceConnection.ts @@ -0,0 +1,32 @@ +import * as lib from "@clusterio/lib"; +import type Host from "./Host"; +import type Instance from "./Instance"; + + +export default class InstanceConnection extends lib.Link { + plugins = new Map(); + status: Instance["status"] = "stopped"; + + constructor( + connector: lib.VirtualConnector, + public host: Host, + public instanceId: number + ) { + super(connector); + this.router = this.host.router; + + this.handle(lib.InstanceInitialisedEvent, this.handleInstanceInitialisedEvent.bind(this)); + this.snoopEvent(lib.InstanceStatusChangedEvent, this.snoopInstanceStatusChangedEvent.bind(this)); + } + + async handleInstanceInitialisedEvent(event: lib.InstanceInitialisedEvent) { + this.plugins = new Map(Object.entries(event.plugins)); + } + + async snoopInstanceStatusChangedEvent(event: lib.InstanceStatusChangedEvent) { + this.status = event.status as Instance["status"]; + if (this.status === "stopped") { + this.host.instanceConnections.delete(this.instanceId); + } + } +} diff --git a/packages/host/src/export.js b/packages/host/src/export.ts similarity index 73% rename from packages/host/src/export.js rename to packages/host/src/export.ts index 8394377d..76f13ebb 100644 --- a/packages/host/src/export.js +++ b/packages/host/src/export.ts @@ -1,12 +1,43 @@ // Export of item icons and locale "use strict"; -const fs = require("fs-extra"); -const path = require("path"); -const Jimp = require("jimp"); -const JSZip = require("jszip"); +import fs from "fs-extra"; +import path from "path"; +import Jimp from "jimp"; +import JSZip from "jszip"; -const lib = require("@clusterio/lib"); -const libBuildMod = require("@clusterio/lib/build_mod"); +import * as lib from "@clusterio/lib"; +import * as libBuildMod from "@clusterio/lib/dist/build_mod"; +import type { FactorioServer } from "./server"; + +interface Prototype { + [index: string]: unknown, + type: string, + name: string, +} +interface SimpleIconSpecification { + icon: string; + icon_size: number; + icon_mipmaps?: number; +} +type Color = { r: number, g: number, b: number, a: number }; +interface IconLayer { + icon: string; + icon_size: number; + tint?: Color, + shift?: [number, number], + scale?: number, + icon_mipmaps?: number, +} +interface LayeredIconSpecification { + icons: IconLayer[]; + icon_size: number; + icon_mipmaps?: number; +} +type IconSpecification = LayeredIconSpecification | SimpleIconSpecification; +type ItemPrototype = Prototype & IconSpecification; + + +type Prototypes = Record> /** * Generate the export mod needed for exportData @@ -15,11 +46,10 @@ const libBuildMod = require("@clusterio/lib/build_mod"); * the server given, with dependencies generated for all the mods present in * the server's mods folder. * - * @param {module:host/src/server.FactorioServer} server - + * @param server - * The server to generate the export mod for. - * @memberof module:lib */ -async function generateExportMod(server) { +async function generateExportMod(server: FactorioServer) { let dependencies = []; let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/; for (let entry of await fs.readdir(server.writePath("mods"))) { @@ -33,7 +63,7 @@ async function generateExportMod(server) { clean: false, build: true, pack: true, - sourceDir: path.join(__dirname, "..", "lua", "export"), + sourceDir: path.join(__dirname, "..", "..", "lua", "export"), outputDir: server.writePath("mods"), bumpPatch: false, factorioVersion: server.version.replace(/\.\d+$/, ""), @@ -42,7 +72,7 @@ async function generateExportMod(server) { } let zipCache = new Map(); -async function loadZip(server, modVersions, mod) { +async function loadZip(server: FactorioServer, modVersions: Map, mod: string) { let modVersion = modVersions.get(mod); if (!modVersion) { throw new Error(`Got path for unknown mod ${mod}`); @@ -61,16 +91,13 @@ async function loadZip(server, modVersions, mod) { /** * Load the given Factorio file path into a Buffer * - * @param {module:host/src/server.FactorioServer} server - - * The server to load the file from. - * @param {Map} modVersions - Mapping of mod to version used. - * @param {string} modPath - Factorio style path to the file to load. - * @returns {Promise} The content of the file or null if not found. - * @memberof module:lib - * @private - * @inner + * @param server - The server to load the file from. + * @param modVersions - Mapping of mod to version used. + * @param modPath - Factorio style path to the file to load. + * @returns The content of the file or null if not found. + * @internal */ -async function loadFile(server, modVersions, modPath) { +async function loadFile(server: FactorioServer, modVersions: Map, modPath: string) { let match = /^__([^\/]+)__\/(.*)$/.exec(modPath); if (!match) { throw new Error(`Bad mod path ${match}`); @@ -81,7 +108,7 @@ async function loadFile(server, modVersions, modPath) { if (["core", "base"].includes(mod)) { try { return await fs.readFile(server.dataPath(mod, filePath)); - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { return null; } @@ -92,7 +119,7 @@ async function loadFile(server, modVersions, modPath) { let zip; try { zip = await loadZip(server, modVersions, mod); - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { return null; } @@ -107,7 +134,15 @@ async function loadFile(server, modVersions, modPath) { return await file.async("nodebuffer"); } -async function loadIcon(server, modVersions, iconPath, iconSize, iconMipmaps, iconCache) { +type IconCache = Map; +async function loadIcon( + server: FactorioServer, + modVersions: Map, + iconPath: string, + iconSize: number, + iconMipmaps: number, + iconCache: IconCache, +) { let icon = iconCache.get(iconPath); if (icon === undefined) { let fileContent = await loadFile(server, modVersions, iconPath); @@ -124,7 +159,13 @@ async function loadIcon(server, modVersions, iconPath, iconSize, iconMipmaps, ic return icon; } -async function loadSimpleIcon(server, modVersions, item, size, iconCache) { +async function loadSimpleIcon( + server: FactorioServer, + modVersions: Map, + item: SimpleIconSpecification, + size: number, + iconCache: IconCache, +) { let icon = await loadIcon(server, modVersions, item.icon, item.icon_size, 0, iconCache); if (icon) { let iconScale = size / item.icon_size; @@ -136,7 +177,13 @@ async function loadSimpleIcon(server, modVersions, item, size, iconCache) { return icon; } -async function loadLayeredIcon(server, modVersions, item, size, iconCache) { +async function loadLayeredIcon( + server: FactorioServer, + modVersions: Map, + item: LayeredIconSpecification, + size: number, + iconCache: IconCache, +) { let baseLayerSize = item.icons[0].icon_size || item.icon_size; let icon = await Jimp.create(size, size); @@ -156,7 +203,7 @@ async function loadLayeredIcon(server, modVersions, item, size, iconCache) { iconLayer = iconLayer.clone(); - let tint; + let tint: Color; if (layer.tint) { let divisor = (layer.tint.r > 1 || layer.tint.g > 1 || layer.tint.b > 1) ? 255 : 1; tint = { @@ -188,7 +235,7 @@ async function loadLayeredIcon(server, modVersions, item, size, iconCache) { if (x < 0 || x >= size || y < 0 || y >= size) { return; } - let sdata = iconLayer.bitmap.data; + let sdata = iconLayer!.bitmap.data; let ddata = icon.bitmap.data; let didx = icon.getPixelIndex(x, y); @@ -235,10 +282,10 @@ const itemTypes = new Set([ // TODO consider adding virtual signals and recipes ]); -function filterItems(prototypes) { +function filterItems(prototypes: Prototypes): ItemPrototype[] { return Object.entries(prototypes) .filter(([type, _]) => itemTypes.has(type)) - .flatMap(([_, typePrototypes]) => Object.values(typePrototypes)) + .flatMap(([_, typePrototypes]) => Object.values(typePrototypes)) as ItemPrototype[] ; } @@ -248,18 +295,15 @@ function filterItems(prototypes) { * Assembles and packs the icons for the item prototypes given into a single * spritesheet and json file with meta data. * - * @param {module:host/src/server.FactorioServer} server - + * @param server - * The server to generate the export mod for. - * @param {Map} modVersions - + * @param modVersions - * Mapping of mod name to versions to get icons from. - * @param {Array} items - Array of item prototypes. - * @return {Promise<{itemSheet: Jimp, itemData: Map}>} - * Item spritesheet and metadata. - * @memberof module:lib - * @private - * @inner + * @param items - Array of item prototypes. + * @return Item spritesheet and metadata. + * @internal */ -async function exportItems(server, modVersions, items) { +async function exportItems(server: FactorioServer, modVersions: Map, items: ItemPrototype[]) { // Size to render icons at let size = 32; @@ -272,19 +316,19 @@ async function exportItems(server, modVersions, items) { let itemData = new Map(); let pos = 0; - let iconCache = new Map(); + let iconCache: IconCache = new Map(); let simpleIcons = new Map(); for (let item of items) { - let icon; - let iconPos; + let icon: Jimp | null = null; + let iconPos: number | undefined; if (item.icons) { - icon = await loadLayeredIcon(server, modVersions, item, size, iconCache); + icon = await loadLayeredIcon(server, modVersions, item as LayeredIconSpecification, size, iconCache); iconPos = pos; } else { iconPos = simpleIcons.get(item.icon); if (iconPos === undefined) { - icon = await loadSimpleIcon(server, modVersions, item, size, iconCache); + icon = await loadSimpleIcon(server, modVersions, item as SimpleIconSpecification, size, iconCache); if (icon) { iconPos = pos; simpleIcons.set(item.icon, pos); @@ -319,21 +363,22 @@ async function exportItems(server, modVersions, items) { * Parses and merges all the locales for the all the mods given through * `modVersions` and `modOrder`. * - * @param {module:host/src/server.FactorioServer} server - - * The server to export the locale from. - * @param {Map} modVersions - - * Mapping of mod name to version to export locale from. - * @param {Array} modOrder - Load order of the mods. - * @param {string} languageCode - Language to export locale for. - * @returns {Promise>} merged locale information - * @memberof module:lib - * @private - * @inner + * @param server - The server to export the locale from. + * @param modVersions - Mapping of mod name to version to export locale from. + * @param modOrder - Load order of the mods. + * @param languageCode - Language to export locale for. + * @returns merged locale information + * @internal */ -async function exportLocale(server, modVersions, modOrder, languageCode) { - let mergedLocales = new Map(); +async function exportLocale( + server: FactorioServer, + modVersions: Map, + modOrder: string[], + languageCode: string +) { + let mergedLocales = new Map(); - function mergeLocale(locale) { + function mergeLocale(locale: Record>) { for (let [category, entries] of Object.entries(locale)) { if (typeof entries === "string") { mergedLocales.set(category, entries); @@ -357,7 +402,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) { let zip; try { zip = await loadZip(server, modVersions, mod); - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { continue; } @@ -375,20 +420,18 @@ async function exportLocale(server, modVersions, modOrder, languageCode) { /** * Export the locale and item icons for the given factorio server * - * @param {module:host/src/server.FactorioServer} server - - * The server to export the data from. - * @returns {Promise} zip file with exported data. - * @memberof module:lib + * @param server - The server to export the data from. + * @returns zip file with exported data. */ -async function exportData(server) { +export async function exportData(server: FactorioServer) { await generateExportMod(server); - let settings = {}; - let prototypes = {}; + let settings: Prototypes = {}; + let prototypes: Prototypes = {}; let modVersions = new Map(); - let modOrder = []; + let modOrder: string[] = []; - function add(obj, prototype) { + function add(obj: Prototypes, prototype: Prototype) { if (!Object.prototype.hasOwnProperty.call(obj, prototype.type)) { obj[prototype.type] = {}; } @@ -465,9 +508,5 @@ async function exportData(server) { } -module.exports = { - exportData, - - // For testing only - _exportLocale: exportLocale, -}; +// For testing only +export const _exportLocale = exportLocale; diff --git a/packages/host/src/patch.js b/packages/host/src/patch.ts similarity index 78% rename from packages/host/src/patch.js rename to packages/host/src/patch.ts index 17d2cfed..111cf8da 100644 --- a/packages/host/src/patch.js +++ b/packages/host/src/patch.ts @@ -1,16 +1,36 @@ // Library for patching Factorio saves with scenario code. "use strict"; -const events = require("events"); -const fs = require("fs-extra"); -const JSZip = require("jszip"); -const path = require("path"); -const semver = require("semver"); +import events from "events"; +import fs from "fs-extra"; +import JSZip from "jszip"; +import path from "path"; +import semver from "semver"; -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; -const knownScenarios = { +interface ScenarioInfo { + name: string; + modules: string[]; +} + +export interface ModuleInfo { + name: string; + version: string; + path: string; + dependencies: Record; + load: string[]; + require: string[]; +} + +interface PatchInfo { + patch_number: number; + scenario: ScenarioInfo; + modules: { name: string, files: { path: string, load: boolean, require: boolean }[], }[]; +} + +const knownScenarios: Record = { // First seen in 0.17.63 "4e866186ebe297f1038fd325b09df1a1f5e2fdd1": { "name": "freeplay", @@ -24,13 +44,11 @@ const knownScenarios = { /** * Generates control.lua code for loading the Clusterio modules * - * @param {Object} patchInfo - The patch info files's json content - * @returns {string} Generated control.lua code. - * @memberof module:lib - * @private - * @inner + * @param patchInfo - The patch info files's json content + * @returns Generated control.lua code. + * @internal */ -function generateLoader(patchInfo) { +function generateLoader(patchInfo: PatchInfo) { let lines = [ "-- Auto generated scenario module loader created by Clusterio", "-- Modifications to this file will be lost when loaded in Clusterio", @@ -75,12 +93,10 @@ function generateLoader(patchInfo) { * satisfy the property that dependencies are earlier in the array than * their dependents. Throws an error if this is not possible. * - * @param {Array} modules - Array of modules to reorder - * @memberof module:lib - * @private - * @inner + * @param modules - Array of modules to reorder + * @internal */ -function reorderDependencies(modules) { +function reorderDependencies(modules: ModuleInfo[]) { let index = 0; let present = new Map(); let hold = new Map(); @@ -141,7 +157,7 @@ function reorderDependencies(modules) { // Start with a random module from the remaining modules for (let module of remaining.values()) { - let cycle = []; + let cycle: string[] = []; while (true) { // Find an unmet dependency let dependency = Object.keys(module.dependencies).find(name => !present.has(name)); @@ -172,28 +188,33 @@ function reorderDependencies(modules) { * modules that were added. Will also remove any previous module * located in the save. * - * @param {string} savePath - Path to the Factorio save to patch. - * @param {Array} modules - Description of the modules to patch. - * @memberof module:lib + * @param savePath - Path to the Factorio save to patch. + * @param modules - Description of the modules to patch. */ -async function patch(savePath, modules) { +export async function patch(savePath: string, modules: ModuleInfo[]) { let zip = await JSZip.loadAsync(await fs.readFile(savePath)); - let root = zip.folder(lib.findRoot(zip)); + let root = zip.folder(lib.findRoot(zip))!; let patchInfoFile = root.file("clusterio.json"); - let patchInfo; + let patchInfo: PatchInfo; if (patchInfoFile !== null) { let content = await patchInfoFile.async("string"); patchInfo = JSON.parse(content); // No info file present, try to detect if it's a known compatible scenario. } else { - let controlStream = root.file("control.lua").nodeStream("nodebuffer"); + let controlFile = root.file("control.lua"); + if (!controlFile) { + throw new Error("Unable to patch save, missing control.lua file."); + } + let controlStream = controlFile.nodeStream("nodebuffer"); let controlHash = await lib.hashStream(controlStream); if (controlHash in knownScenarios) { patchInfo = { + "patch_number": 0, "scenario": knownScenarios[controlHash], + "modules": [], }; } else { throw new Error(`Unable to patch save, unknown scenario (${controlHash})`); @@ -213,15 +234,15 @@ async function patch(savePath, modules) { // Add the modules to the save. for (let module of modules) { - let moduleDir = root.folder(`modules/${module.name}`); + let moduleDir = root.folder(`modules/${module.name}`)!; let moduleInfo = { "name": module.name, - "files": [], + "files": [] as { path: string, load: boolean, require: boolean }[], }; - let dirs = [[module.path, ""]]; + let dirs: [string, string][] = [[module.path, ""]]; while (dirs.length) { - let [dir, relativeDir] = dirs.pop(); + let [dir, relativeDir] = dirs.pop()!; for (let entry of await fs.readdir(dir, { withFileTypes: true })) { let fsPath = path.join(dir, entry.name); let relativePath = path.posix.join(relativeDir, entry.name); @@ -261,10 +282,6 @@ async function patch(savePath, modules) { await fs.rename(tempSavePath, savePath); } -module.exports = { - patch, - - // For testing only - _generateLoader: generateLoader, - _reorderDependencies: reorderDependencies, -}; +// For testing only +export const _generateLoader = generateLoader; +export const _reorderDependencies = reorderDependencies; diff --git a/packages/host/src/server.js b/packages/host/src/server.ts similarity index 73% rename from packages/host/src/server.js rename to packages/host/src/server.ts index 73090b45..93a93364 100644 --- a/packages/host/src/server.js +++ b/packages/host/src/server.ts @@ -1,30 +1,27 @@ // Factorio Server interface -"use strict"; -const fs = require("fs-extra"); -const child_process = require("child_process"); -const path = require("path"); -const events = require("events"); -const util = require("util"); -const crypto = require("crypto"); -const rconClient = require("rcon-client"); +import fs from "fs-extra"; +import child_process from "child_process"; +import path from "path"; +import events from "events"; +import util from "util"; +import crypto from "crypto"; +import { Rcon } from "rcon-client"; -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; /** * Determines the version of Factorio the datadir is pointing to by * reading the changelog.txt in it. * - * @param {string} changelogPath - Path to changelog.txt. - * @memberof module:lib - * @private - * @inner + * @param changelogPath - Path to changelog.txt. + * @internal */ -async function getVersion(changelogPath) { +async function getVersion(changelogPath: string) { let changelog; try { changelog = await fs.readFile(changelogPath, "utf-8"); - } catch (err) { + } catch (err: any) { if (err.code === "ENOENT") { return null; } @@ -47,14 +44,12 @@ async function getVersion(changelogPath) { /** * Comparison function for sorting Factorio version strings * - * @param {string} a - Version to compare. - * @param {string} b - Version to compare. - * @returns {number} 1 if a < b, 0 if a = b and -1 if a > b. - * @memberof module:lib - * @private - * @inner + * @param a - Version to compare. + * @param b - Version to compare. + * @returns 1 if a < b, 0 if a = b and -1 if a > b. + * @internal */ -function versionOrder(a, b) { +function versionOrder(a: string, b: string) { let aParts = a.split(".").map(s => Number.parseInt(s, 10)); let bParts = b.split(".").map(s => Number.parseInt(s, 10)); @@ -77,16 +72,14 @@ function versionOrder(a, b) { * Searches the given factorio dir for an installation of Factorio of * the target version. * - * @param {string} factorioDir - Path to Factorio installation dir(s). - * @param {string} targetVersion - + * @param factorioDir - Path to Factorio installation dir(s). + * @param targetVersion - * Version to look for, supports the special value "latest" for the * latest version available. - * @returns {Promise} Array with path to data dir and version found. - * @memberof module:lib - * @private - * @inner + * @returns Array with path to data dir and version found. + * @internal */ -async function findVersion(factorioDir, targetVersion) { +async function findVersion(factorioDir: string, targetVersion: string) { // There are two supported setups: having the factorio dir be the actual // install directory, and having the factorio dir be a folder containing @@ -139,10 +132,8 @@ async function findVersion(factorioDir, targetVersion) { * Returns a random port number in the Dynamic Ports range as defined by * RFC 6335. * - * @return {number} a number in the range 49152 to 65535. - * @memberof module:lib - * @private - * @inner + * @return a number in the range 49152 to 65535. + * @internal */ function randomDynamicPort() { const start = 49152; @@ -157,17 +148,15 @@ function randomDynamicPort() { * Uses crypto.randomBytes to generate a secure alphanumeric password of * the given length. * - * @param {number} length - the length of the password to generate. - * @return {Promise} password of the given length - * @memberof module:lib - * @private - * @inner + * @param length - the length of the password to generate. + * @return password of the given length + * @internal */ -async function generatePassword(length) { - function validChar(byte) { +async function generatePassword(length: number) { + function validChar(byte: number) { const ranges = ["az", "AZ", "09"]; return ranges.some(range => ( - range.codePointAt(0) <= byte && byte <= range.codePointAt(1) + range.codePointAt(0)! <= byte && byte <= range.codePointAt(1)! )); } let randomBytesAsync = util.promisify(crypto.randomBytes); @@ -198,28 +187,22 @@ async function generatePassword(length) { * * TODO document output format. * - * @param {string} line - A line of output not including the line terminator - * @param {string} source - Passed into the output structure as source + * @param line - A line of output not including the line terminator + * @param source - Passed into the output structure as source * - * @returns {object} - An object with interpeted data. - * @memberof module:lib - * @private - * @inner + * @returns - An object with interpeted data. + * @internal */ -function parseOutput(line, source) { - let output = { - source, - }; - +function parseOutput(line: string, source: "stdout" | "stderr"): lib.ParsedFactorioOutput { // There are three broad categories of output from Factorio, the first kind // starts with the seconds since the server started and has a format of // " 0.704 message" const secRegex = /^ {0,3}(\d+\.\d+) (.*)$/; let secMatch = secRegex.exec(line); if (secMatch) { - output.format = "seconds"; - output.time = secMatch[1]; - let secContent = secMatch[2]; + const format = "seconds"; + const time = secMatch[1]; + const secContent = secMatch[2]; // A seconds output has two different kinds of formats: The first is a // log level and source location and has a format of @@ -229,15 +212,25 @@ function parseOutput(line, source) { const secLogRegex = /^(\w+) ((\w+\.cpp:\d+)|([@=].*?:\d+)): (.*)$/; let secLogMatch = secLogRegex.exec(secContent); if (secLogMatch) { - output.type = "log"; - output.level = secLogMatch[1]; - output.file = secLogMatch[2]; - output.message = secLogMatch[5]; + return { + source, + format, + time, + type: "log", + level: secLogMatch[1], + file: secLogMatch[2], + message: secLogMatch[5], + } // The other possibility is that the content is a generic message } else { - output.type = "generic"; - output.message = secContent; + return { + source, + format, + time, + type: "generic", + message: secContent, + } } // The second category of output starts with a date stamp of the format @@ -246,8 +239,8 @@ function parseOutput(line, source) { const dateRegex = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) (.*)$/; let dateMatch = dateRegex.exec(line); if (dateMatch) { - output.format = "date"; - output.time = dateMatch[1]; + const format = "date"; + const time = dateMatch[1]; let dateContent = dateMatch[2]; // A date output has two general formats. The first is an action @@ -255,26 +248,47 @@ function parseOutput(line, source) { const dateActionRegex = /^\[(\w+)\] (.*)$/; let dateActionMatch = dateActionRegex.exec(dateContent); if (dateActionMatch) { - output.type = "action"; - output.action = dateActionMatch[1]; - output.message = dateActionMatch[2]; + return { + source, + format, + time, + type: "action", + action: dateActionMatch[1], + message: dateActionMatch[2], + } // The other format is a generic message } else { - output.type = "generic"; - output.message = dateContent; + return { + source, + format, + time, + type: "generic", + message: dateContent, + } } // The last category of output is simply a generic message with no // formating. } else { - output.format = "none"; - output.type = "generic"; - output.message = line; + return { + source, + format: "none", + type: "generic", + message: line, + } } } +} - return output; +// https://stackoverflow.com/a/49402091 +type KeysOfUnion = T extends T ? keyof T : never; + +interface Heuristic { + filter: { + [P in KeysOfUnion]?: string | string[] | RegExp; + }, + action(this: FactorioServer, parsed: lib.ParsedFactorioOutput): void; } // These are filters applied to the output from Factorio. The filter @@ -285,7 +299,7 @@ function parseOutput(line, source) { // // If the filter matches the action is called with the FactorioServer // instance bound as this. -const outputHeuristics = [ +const outputHeuristics: Heuristic[] = [ // Message indicating the RCON interface has started { filter: { type: "log", message: /^Starting RCON interface/ }, @@ -309,7 +323,7 @@ const outputHeuristics = [ message: /^Saving to _autosave\d+ \((non-)?blocking\)\.$/, }, action: function(parsed) { - let name = /^Saving to (_autosave\d+) /.exec(parsed.message)[1]; + let name = /^Saving to (_autosave\d+) /.exec(parsed.message)![1]; this.emit("_autosave", name); }, }, @@ -354,7 +368,7 @@ const outputHeuristics = [ // when an error occurs. Since we can't send input there we terminate the server // process when this happens. if (process.platform === "win32" && parsed.message === "Quitting: multiplayer error.") { - this._server.kill(); + this._server!.kill(); } }, }, @@ -389,6 +403,37 @@ const outputHeuristics = [ ]; +export interface FactorioServerOptions { + /** Logger to use for reporting errors. */ + logger?: lib.Logger, + /** + * Version of Factorio to use. Can also be the string "latest" to use + * the latest version found in `factorioDir`. + */ + version?: string, + /** UDP port to host game on. */ + gamePort?: number, + /** TCP port to use for RCON. */ + rconPort?: number, + /** Password use for RCON. */ + rconPassword?: string, + /** Turn on whitelisting. */ + enableWhitelist?: boolean, + /** Enable Factorio.com based multiplayer bans. */ + enableAuthserverBans?: boolean, + /** Enable verbose logging. */ + verboseLogging?: boolean, + /** Strip paths in the console. */ + stripPaths?: boolean, + /** + * Maximum number of RCON commands transmitted in parallel on the RCON + * connection. + */ + maxConcurrentCommands?: number + /** Timeout in ms to wait for a response to a shutdown command. */ + hangTimeout?: number, +} + /** * Factorio Server interface * @@ -407,43 +452,60 @@ const outputHeuristics = [ * - save-finished - invoked when the server has finished a manual save * - exit - invoked when the sterver has exited * @extends events.EventEmitter - * @memberof module:lib */ -class FactorioServer extends events.EventEmitter { +export class FactorioServer extends events.EventEmitter { + /** UDP port used for hosting the Factorio game server on */ + gamePort: number; + /** TCP port used for RCON on the Factorio game server */ + rconPort: number; + /** Password used for RCON on the Factorio game server */ + rconPassword: string; + /** Enable player whitelist */ + enableWhitelist: boolean; + /** Enable Factorio.com based multiplayer bans **/ + enableAuthserverBans: boolean; + /** Enable verbose logging */ + verboseLogging: boolean + /** Timeout in ms to wait for a response to a shutdown command */ + hangTimeout = 5000; + + _factorioDir: string; + _writeDir: string; + + // Resolved in init + _version: string | null = null; + _dataDir: string | null = null; + + + _logger: lib.Logger; + _targetVersion: string; + _state: "new" | "init" | "create" | "running" | "stopping" = "new"; + _server: child_process.ChildProcessWithoutNullStreams | null = null; + _rconClient: Rcon | null= null; + _rconReady = false; + _gameReady = false; + _stripRegExp?: RegExp; + _maxConcurrentCommands = 5; + + // Array of possible causes for an unexpected shutdown + _unexpected: string[] = []; + _killed = false; + _runningAutosave: string | null = null; + + /** * Create a Factorio server interface * - * @param {string} factorioDir - Directory of the Factorio install(s). - * @param {string} writeDir - Directory to write runtime data to. - * @param {object} options - Optional parameters. - * @param {Logger} options.logger - Logger to use for reporting errors. - * @param {string} options.version - - * Version of Factorio to use. Can also be the string "latest" to - * use the latest version found in `factorioDir`. - * @param {number} options.gamePort - UDP port to host game on. - * @param {number} options.rconPort - TCP port to use for RCON. - * @param {string} options.rconPassword - Password use for RCON. - * @param {boolean} options.enableWhitelist - Turn on whitelisting. - * @param {boolean} options.enableAuthserverBans - - * Enable Factorio.com based multiplayer bans. - * @param {boolean} options.verboseLogging - Enable verbose logging. - * @param {boolean} options.stripPaths - Strip paths in the console. - * @param {number} options.maxConcurrentCommands - - * Maximum number of RCON commands transmitted in parallel on the - * RCON connection. - * @param {number} options.hangTimeout - - * Timeout in ms to wait for a response to a shutdown command. + * @param factorioDir - Directory of the Factorio install(s). + * @param writeDir - Directory to write runtime data to. + * @param options - Optional parameters. */ - constructor(factorioDir, writeDir, options) { + constructor(factorioDir: string, writeDir: string, options: FactorioServerOptions) { super(); this._factorioDir = factorioDir; this._writeDir = writeDir; - // Resolved in init - this._version = null; - this._dataDir = null; - this._logger = options.logger || lib.logger; this._targetVersion = options.version || "latest"; /** UDP port used for hosting the Factorio game server on */ @@ -451,27 +513,22 @@ class FactorioServer extends events.EventEmitter { /** TCP port used for RCON on the Factorio game server */ this.rconPort = options.rconPort || randomDynamicPort(); /** Password used for RCON on the Factorio game server */ - this.rconPassword = options.rconPassword; + this.rconPassword = options.rconPassword as string; // init will generate one if not there /** Enable player whitelist */ - this.enableWhitelist = options.enableWhitelist; + this.enableWhitelist = options.enableWhitelist || false; /** Enable Factorio.com based multiplayer bans **/ - this.enableAuthserverBans = options.enableAuthserverBans; + this.enableAuthserverBans = options.enableAuthserverBans || false; /** Enable verbose logging */ - this.verboseLogging = options.verboseLogging; + this.verboseLogging = options.verboseLogging || false; /** Maximum number of RCON commands transmitted in parallel on the RCON connection */ this.maxConcurrentCommands = options.maxConcurrentCommands || 5; /** Timeout in ms to wait for a response to a shutdown command */ - this.hangTimeout = options.hangTimeout || 5000; - this._state = "new"; - this._server = null; - this._rconClient = null; - this._rconReady = false; - this._gameReady = false; + this.hangTimeout = options.hangTimeout ?? 5000; if (options.stripPaths) { - let chars = new Set(path.resolve(this.writePath("temp"))); - chars.delete(":"); // Having a colon could lead to matching the line number - chars = [...chars].join(""); + let charSet = new Set(path.resolve(this.writePath("temp"))); + charSet.delete(":"); // Having a colon could lead to matching the line number + let chars = [...charSet].join(""); let tempPath = `${path.resolve(this.writePath("temp", "currently-playing"))}${path.sep}`; let writePath = `${path.resolve(this.writePath())}${path.sep}`; @@ -485,12 +542,7 @@ class FactorioServer extends events.EventEmitter { ); } - // Array of possible causes for an unexpected shutdown - this._unexpected = []; - this._killed = false; - // Track autosaving - this._runningAutosave = null; this.on("_autosave", name => { this.emit("autosave-start", name); this._runningAutosave = name; @@ -506,7 +558,7 @@ class FactorioServer extends events.EventEmitter { }); } - _check(expectedStates) { + _check(expectedStates: FactorioServer["_state"][]) { if (!expectedStates.includes(this._state)) { throw new Error( `Expected state ${expectedStates} but state is ${this._state}` @@ -514,31 +566,31 @@ class FactorioServer extends events.EventEmitter { } } - async _handleIpc(line) { + async _handleIpc(line: Buffer) { let channelEnd = line.indexOf("?"); if (channelEnd === -1) { throw new Error(`Malformed IPC line "${line.toString()}"`); } let channel = line - .slice(6, channelEnd) + .subarray(6, channelEnd) .toString("utf-8") .replace(/\\x([0-9a-f]{2})/g, (match, p1) => ( String.fromCharCode(parseInt(p1, 16)) )) ; - let type = line.slice(channelEnd + 1, channelEnd + 2).toString("utf-8"); + let type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8"); let content; if (type === "j") { try { - content = JSON.parse(line.slice(channelEnd + 2).toString("utf-8")); + content = JSON.parse(line.subarray(channelEnd + 2).toString("utf-8")); } catch (err) { - throw new Error(`Malformed JSON to ${channel}: ${line.slice(channelEnd + 2).toString("utf-8")}`); + throw new Error(`Malformed JSON to ${channel}: ${line.subarray(channelEnd + 2).toString("utf-8")}`); } } else if (type === "f") { - let fileName = line.slice(channelEnd + 2).toString("utf-8"); + let fileName = line.subarray(channelEnd + 2).toString("utf-8"); let filePath = this.writePath("script-output", fileName); // Prevent malicious names @@ -564,15 +616,15 @@ class FactorioServer extends events.EventEmitter { } } - _handleOutput(line, source) { - if (line.slice(0, 6).equals(Buffer.from("\f$ipc:"))) { - this._handleIpc(line).catch(err => this.emit("error", err)); + _handleOutput(rawLine: Buffer, source: "stdout" | "stderr") { + if (rawLine.subarray(0, 6).equals(Buffer.from("\f$ipc:"))) { + this._handleIpc(rawLine).catch(err => this.emit("error", err)); return; } - this.emit(source, line); + this.emit(source, rawLine); - line = line.toString("utf-8"); + let line = rawLine.toString("utf-8"); if (this._stripRegExp) { line = line.replace(this._stripRegExp, ""); } @@ -586,14 +638,14 @@ class FactorioServer extends events.EventEmitter { } if (expected instanceof RegExp) { - if (!expected.test(parsed[name])) { + if (!expected.test((parsed as any)[name])) { continue heuristicLoop; } } else if (expected instanceof Array) { - if (!expected.includes(parsed[name])) { + if (!expected.includes((parsed as any)[name])) { continue heuristicLoop; } - } else if (expected !== parsed[name]) { + } else if (expected !== (parsed as any)[name]) { continue heuristicLoop; } } @@ -622,7 +674,7 @@ class FactorioServer extends events.EventEmitter { maxPending: this.maxConcurrentCommands, }; - this._rconClient = new rconClient.Rcon(config); + this._rconClient = new Rcon(config); this._rconClient.on("error", () => { /* Ignore */ }); this._rconClient.on("authenticated", () => { this._rconReady = true; this.emit("rcon-ready"); }); this._rconClient.on("end", () => { @@ -635,15 +687,18 @@ class FactorioServer extends events.EventEmitter { await this._rconClient.connect(); } + /** Maximum number of RCON commands transmitted in parallel on the RCON connection */ get maxConcurrentCommands() { return this._maxConcurrentCommands; } - set maxConcurrentCommands(value) { + set maxConcurrentCommands(value: number) { this._maxConcurrentCommands = value; this.setMaxListeners(value + 5); if (this._rconClient) { + // @ts-ignore if (this._rconClient.sendQueue) { + // @ts-ignore this._rconClient.sendQueue.maxConcurrent = value; } // XXX: Workaround to suppress bogus event listener warning @@ -662,7 +717,7 @@ class FactorioServer extends events.EventEmitter { async init() { this._check(["new"]); [this._dataDir, this._version] = await findVersion(this._factorioDir, this._targetVersion); - this.rconPassword = this.rconPassword || await generatePassword(10); + this.rconPassword = this.rconPassword ?? await generatePassword(10); this._state = "init"; } @@ -672,7 +727,7 @@ class FactorioServer extends events.EventEmitter { * constructor. */ get version() { - return this._version; + return this._version!; } /** @@ -688,10 +743,10 @@ class FactorioServer extends events.EventEmitter { _attachStdio() { let stdout = new lib.LineSplitter({ readableObjectMode: true }); stdout.on("data", line => { this._handleOutput(line, "stdout"); }); - this._server.stdout.pipe(stdout); + this._server!.stdout.pipe(stdout); let stderr = new lib.LineSplitter({ readableObjectMode: true }); stderr.on("data", line => { this._handleOutput(line, "stderr"); }); - this._server.stderr.pipe(stderr); + this._server!.stderr.pipe(stderr); } _resetState() { @@ -705,7 +760,7 @@ class FactorioServer extends events.EventEmitter { } _watchExit() { - this._server.on("exit", (code, signal) => { + this._server!.on("exit", (code, signal) => { if (this._state !== "stopping") { if (signal === "SIGKILL") { if (this._killed) { @@ -744,7 +799,7 @@ class FactorioServer extends events.EventEmitter { this._resetState(); this.emit("exit"); }); - this._server.on("error", err => { + this._server!.on("error", (err: any) => { if (err.code === "EACCES") { this.emit("error", new lib.EnvironmentError("Unable to run server: Permission denied")); } else { @@ -777,15 +832,15 @@ class FactorioServer extends events.EventEmitter { * Spawns the Factorio server with the --create argument to create a new * map save with the given name. * - * @param {string} name - + * @param name - * Name of the save to create. Should end with ".zip". - * @param {?number=} seed - Seed to pass via --map-gen-seed - * @param {?object=} mapGenSettings - + * @param seed - Seed to pass via --map-gen-seed + * @param mapGenSettings - * Map get settings to pass via --map-gen-settings. - * @param {?object=} mapSettings - + * @param mapSettings - * Map setting to pass via --map-settings. */ - async create(name, seed = null, mapGenSettings = null, mapSettings = null) { + async create(name: string, seed?: number, mapGenSettings?: object, mapSettings?: object) { this._check(["init"]); this._state = "create"; @@ -796,11 +851,11 @@ class FactorioServer extends events.EventEmitter { [ "--config", this.writePath("config.ini"), "--create", this.writePath("saves", name), - /* eslint-disable eqeqeq, no-eq-null */ - ...(seed != null ? ["--map-gen-seed", seed] : []), - ...(mapGenSettings != null ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []), - ...(mapSettings != null ? ["--map-settings", this.writePath("map-settings.json")] : []), - /* eslint-enable eqeqeq, no-eq-null */ + ...(seed !== undefined ? ["--map-gen-seed", String(seed)] : []), + ...(mapGenSettings !== undefined ? + ["--map-gen-settings", this.writePath("map-gen-settings.json")] : [] + ), + ...(mapSettings !== undefined ? ["--map-settings", this.writePath("map-settings.json")] : []), ...(this.verboseLogging ? ["--verbose"] : []), ], { @@ -818,7 +873,7 @@ class FactorioServer extends events.EventEmitter { if (code !== 0) { throw new Error(`Factorio exited with status ${code}`); } - } catch (err) { + } catch (err: any) { if (err.code === "EACCES") { throw new lib.EnvironmentError("Unable to run server: Permission denied"); } @@ -836,7 +891,7 @@ class FactorioServer extends events.EventEmitter { * * @param {string} save - Name of the save to run. */ - async start(save) { + async start(save: string) { this._check(["init"]); this._state = "running"; @@ -846,8 +901,8 @@ class FactorioServer extends events.EventEmitter { [ "--config", this.writePath("config.ini"), "--start-server", this.writePath("saves", save), - "--port", this.gamePort, - "--rcon-port", this.rconPort, + "--port", String(this.gamePort), + "--rcon-port", String(this.rconPort), "--rcon-password", this.rconPassword, ...(this.enableWhitelist ? ["--use-server-whitelist"] : []), ...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []), @@ -870,14 +925,14 @@ class FactorioServer extends events.EventEmitter { * Spawn the Factorio server with the --start-server-load-scenario * argument to start the given scenario. * - * @param {string} scenario - Name of the scenario to run. - * @param {?number=} seed - Seed to pass via --map-gen-seed - * @param {?object=} mapGenSettings - + * @param scenario - Name of the scenario to run. + * @param seed - Seed to pass via --map-gen-seed + * @param mapGenSettings - * Map get settings to pass via --map-gen-settings. - * @param {?object=} mapSettings - + * @param mapSettings - * Map setting to pass via --map-settings. */ - async startScenario(scenario, seed = null, mapGenSettings = null, mapSettings = null) { + async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) { this._check(["init"]); this._state = "running"; @@ -888,13 +943,13 @@ class FactorioServer extends events.EventEmitter { [ "--config", this.writePath("config.ini"), "--start-server-load-scenario", scenario, - /* eslint-disable eqeqeq, no-eq-null */ - ...(seed != null ? ["--map-gen-seed", seed] : []), - ...(mapGenSettings != null ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []), - ...(mapSettings != null ? ["--map-settings", this.writePath("map-settings.json")] : []), - /* eslint-enable eqeqeq, no-eq-null */ - "--port", this.gamePort, - "--rcon-port", this.rconPort, + ...(seed !== undefined ? ["--map-gen-seed", String(seed)] : []), + ...(mapGenSettings !== undefined ? + ["--map-gen-settings", this.writePath("map-gen-settings.json")] : [] + ), + ...(mapSettings !== undefined ? ["--map-settings", this.writePath("map-settings.json")] : []), + "--port", String(this.gamePort), + "--rcon-port", String(this.rconPort), "--rcon-password", this.rconPassword, ...(this.enableWhitelist ? ["--use-server-whitelist"] : []), ...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []), @@ -918,13 +973,13 @@ class FactorioServer extends events.EventEmitter { * If the rcon connection hasn't been established yet, this will * wait until it is establied and then send the message. * - * @param {string} message - message to send to server over RCON. - * @param {boolean} expectEmpty - + * @param message - message to send to server over RCON. + * @param expectEmpty - * if true throw if the response is not empty. Useful for detecting * errors that might have been sent in response. - * @returns {Promise} response from server. + * @returns response from server. */ - async sendRcon(message, expectEmpty) { + async sendRcon(message: string, expectEmpty?: boolean) { this._check(["running", "stopping"]); if (!this._rconReady) { await events.once(this, "rcon-ready"); @@ -959,13 +1014,13 @@ class FactorioServer extends events.EventEmitter { if (this._rconClient) { this._rconClient.end().catch(() => {}); } - this._server.kill("SIGKILL"); + this._server!.kill("SIGKILL"); } }, this.hangTimeout); // It's possible the server decides to exit on its own, in which // case the stop operation has to be cancelled. - this._server.once("exit", () => { + this._server!.once("exit", () => { stopped = true; clearTimeout(timeoutId); }); @@ -1011,7 +1066,7 @@ class FactorioServer extends events.EventEmitter { // complaint for the lack of graceful shutdown to the Factorio // developers. Rcon does not recognize /quit, stdin is not // recognized, and there's no "send CTRL+C to process" on Windows. - this._server.kill(); + this._server!.kill(); // There appears to be an race condition where sending SIGTERM // immediatly before the RCON interface comes online causes the @@ -1020,7 +1075,7 @@ class FactorioServer extends events.EventEmitter { // case there's no recovering from it. if (process.platform !== "win32") { this.on("_quitting", setAlive); - await events.once(this._server, "exit"); + await events.once(this._server!, "exit"); clearTimeout(timeoutId); this.off("_quitting", setAlive); @@ -1028,7 +1083,7 @@ class FactorioServer extends events.EventEmitter { // On windows the process is terminated immediately, but to keep // ordering wait until after the exit event here. } else { - await events.once(this._server, "exit"); + await events.once(this._server!, "exit"); } } @@ -1036,7 +1091,7 @@ class FactorioServer extends events.EventEmitter { * Kill the server * * Terminates the server without any cleanup or saving. - * @param {boolean} [unexpected=false] - + * @param unexpected - * If true raise an error event as a result of killing the Factorio * process. */ @@ -1046,8 +1101,8 @@ class FactorioServer extends events.EventEmitter { if (!unexpected) { this._state = "stopping"; } - this._server.kill("SIGKILL"); - await new Promise(resolve => this._server.once("exit", resolve)); + this._server!.kill("SIGKILL"); + await new Promise(resolve => this._server!.once("exit", resolve)); } /** @@ -1056,7 +1111,7 @@ class FactorioServer extends events.EventEmitter { * Ensures achievements are disabled on the save that's running. This is * necessary in order to run any commands at all. * - * @returns {Promise} + * @returns * True if acheivements got disabled and false if they already where * disabled. */ @@ -1085,11 +1140,11 @@ class FactorioServer extends events.EventEmitter { * the data directory of the Factorio server. Not valid before init has * been called. * - * @param {...string} parts - Extra parts to add to the data path. - * @returns {string} Data directory path. + * @param parts - Extra parts to add to the data path. + * @returns Data directory path. */ - dataPath(...parts) { - return path.join(this._dataDir, ...parts); + dataPath(...parts: string[]) { + return path.join(this._dataDir!, ...parts); } /** @@ -1097,7 +1152,7 @@ class FactorioServer extends events.EventEmitter { * * Get the path to the factorio binary depending on the platform (MacOS support) * - * @returns {string} Path to factorio binary + * @returns Path to factorio binary */ binaryPath() { if (process.platform === "darwin") { @@ -1112,10 +1167,10 @@ class FactorioServer extends events.EventEmitter { * Creates a path using path.join with the given parts that's relative to * the write directory of the Factorio server. * - * @param {...string} parts - Extra parts to add to the write path. - * @returns {string} Write directory path. + * @param parts - Extra parts to add to the write path. + * @returns Write directory path. */ - writePath(...parts) { + writePath(...parts: string[]) { return path.join(this._writeDir, ...parts); } @@ -1124,7 +1179,7 @@ class FactorioServer extends events.EventEmitter { * * Loads server-settings.example.json from the data dir. * - * @returns {Promise} the parsed server-settings. + * @returns the parsed server-settings. */ async exampleSettings() { return JSON.parse(await fs.readFile(this.dataPath("server-settings.example.json"), "utf-8")); @@ -1140,7 +1195,7 @@ class FactorioServer extends events.EventEmitter { await lib.safeOutputFile(this.writePath("config.ini"), content); } - async _writeMapSettings(mapGenSettings, mapSettings) { + async _writeMapSettings(mapGenSettings?: object, mapSettings?: object) { if (mapGenSettings) { await lib.safeOutputFile( this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, 4) @@ -1155,14 +1210,10 @@ class FactorioServer extends events.EventEmitter { } -module.exports = { - FactorioServer, - - // For testing only - _getVersion: getVersion, - _versionOrder: versionOrder, - _findVersion: findVersion, - _randomDynamicPort: randomDynamicPort, - _generatePassword: generatePassword, - _parseOutput: parseOutput, -}; +// For testing only +export const _getVersion = getVersion; +export const _versionOrder = versionOrder; +export const _findVersion = findVersion; +export const _randomDynamicPort = randomDynamicPort; +export const _generatePassword = generatePassword; +export const _parseOutput = parseOutput; diff --git a/packages/host/tsconfig.json b/packages/host/tsconfig.json new file mode 100644 index 00000000..0d450dbb --- /dev/null +++ b/packages/host/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["host.ts", "package.json", "src/**/*.ts", "src/**/*.js"], + "compilerOptions": { + "lib": ["es2023"], + "module": "commonjs", + "target": "es2022", + "rootDir": ".", + "outDir": "dist", + "composite": true, + "sourceMap": true, + "strict": true, + "alwaysStrict": true, + "allowJs": true, + "checkJs": false, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 34b9876b..b39d2ef4 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -439,17 +439,18 @@ export class InstanceConfig extends classes.Config { get(name: "factorio.rcon_port"): number | null; get(name: "factorio.rcon_password"): string | null; get(name: "factorio.player_online_autosave_slots"): number; - get(name: "factorio.mod_pack"): string | null; + get(name: "factorio.mod_pack"): number | null; get(name: "factorio.enable_save_patching"): boolean; get(name: "factorio.enable_whitelist"): boolean; get(name: "factorio.enable_authserver_bans"): boolean; - get(name: "factorio.settings"): Object; + get(name: "factorio.settings"): Record; get(name: "factorio.verbose_logging"): boolean; get(name: "factorio.strip_paths"): boolean; get(name: "factorio.sync_adminlist"): boolean; get(name: "factorio.sync_whitelist"): boolean; + get(name: "factorio.sync_banlist"): boolean; get(name: "factorio.max_concurrent_commands"): number; - get(name: string) { + get(name: string) { return super.get(name); } } diff --git a/packages/lib/src/data/messages_core.ts b/packages/lib/src/data/messages_core.ts index 62df2711..73ffceea 100644 --- a/packages/lib/src/data/messages_core.ts +++ b/packages/lib/src/data/messages_core.ts @@ -172,7 +172,7 @@ export class Message { } } -export type MessageSrcDst = MessageRequest | MessageResponse | MessageResponseError | MessageEvent; +export type MessageRoutable = MessageRequest | MessageResponse | MessageResponseError | MessageEvent; export class HelloData { constructor( diff --git a/packages/lib/src/link/connectors.ts b/packages/lib/src/link/connectors.ts index 742b5c30..b8960f42 100644 --- a/packages/lib/src/link/connectors.ts +++ b/packages/lib/src/link/connectors.ts @@ -81,12 +81,6 @@ export abstract class BaseConnector extends events.EventEmitter { } type ConnectorState = "closed" | "connecting" | "connected" | "resuming"; -type ReliableMessages = - | libData.MessageRequest - | libData.MessageResponse - | libData.MessageResponseError - | libData.MessageEvent -; /** * Base connector for links @@ -102,7 +96,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector { _heartbeatId: ReturnType | null = null; _heartbeatInterval: number | null = null; _lastReceivedSeq = undefined; - _sendBuffer: (ReliableMessages)[] = []; + _sendBuffer: (libData.MessageRoutable)[] = []; constructor(src: libData.Address, dst: libData.Address) { super(src, dst); @@ -249,7 +243,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector { * * @param message - Message to send. */ - send(message: ReliableMessages) { + send(message: libData.MessageRoutable) { if (!["connected", "resuming"].includes(this._state)) { throw new libErrors.SessionLost("No session"); } diff --git a/packages/lib/src/link/link.ts b/packages/lib/src/link/link.ts index 99c55037..46cc267d 100644 --- a/packages/lib/src/link/link.ts +++ b/packages/lib/src/link/link.ts @@ -8,7 +8,7 @@ import { BaseConnector, WebSocketBaseConnector } from "./connectors"; import { strict as assert } from "assert"; import type { PluginInfo } from "../plugin"; import type { User } from "../users"; -import type { AddressType, JSONDeserialisable, Message, MessageRequest, MessageEvent } from "../data"; +import type { AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent } from "../data"; export interface Request { constructor: Partial>> & { @@ -71,7 +71,7 @@ export type RequestHandler = (request: Req, src: libData.Address, dst: export type EventHandler = (event: T, src: libData.Address, dst: libData.Address) => Promise; interface Router { - forwardMessage(origin: Link, message: Message, fallback: boolean): boolean, + forwardMessage(origin: Link, message: MessageRoutable, fallback: boolean): boolean, } // Some definitions for the terminology used here: @@ -166,7 +166,7 @@ export class Link { * @throws {libErrors.InvalidMessage} if the message is invalid or not handled. */ _processMessage( - message: libData.MessageRequest | libData.MessageResponse | libData.MessageResponseError | libData.MessageEvent + message: MessageRoutable ) { if (!["request", "response", "responseError", "event"].includes(message.type)) { throw new libErrors.InvalidMessage(`Unhandled message type ${message.type}`); @@ -175,7 +175,10 @@ export class Link { let entry = this._validateMessage(message); if (entry && this.connector.dst.type === libData.Address.control) { try { - this.validatePermission(message, entry); // Somewhat hacky, defined in ControlConnection + this.validatePermission( + message as libData.MessageRequest | libData.MessageEvent, + entry + ); // Somewhat hacky, defined in ControlConnection } catch (err) { if (err instanceof libErrors.PermissionError) { return; @@ -229,10 +232,10 @@ export class Link { * @param message - Message to check. * @throws {libErrors.InvalidMessage} if the message is invalid. */ - validateIngress(message: libData.Message) { } + validateIngress(message: MessageRoutable) { } _validateMessage( - message: libData.MessageRequest | libData.MessageResponse | libData.MessageResponseError | libData.MessageEvent + message: MessageRoutable ) { try { if (message.src.type === libData.Address.broadcast) { @@ -307,9 +310,9 @@ export class Link { * @param entry - Request or Event entry for this Message. * @throws {libErrors.PermissionError} if unauthorized. */ - validatePermission(message: libData.Message, entry: RequestEntry | EventEntry) { } + validatePermission(message: libData.MessageRequest | libData.MessageEvent, entry: RequestEntry | EventEntry) { } - _routeMessage(message: libData.Message, entry?: RequestEntry | EventEntry) { + _routeMessage(message: MessageRoutable, entry?: RequestEntry | EventEntry) { if (!this.router) { let err = new libErrors.InvalidMessage( `Received message addressed to ${(message as libData.MessageRequest).dst} but this link `+ diff --git a/packages/lib/src/logging.ts b/packages/lib/src/logging.ts index 8eac6098..03b08870 100644 --- a/packages/lib/src/logging.ts +++ b/packages/lib/src/logging.ts @@ -165,7 +165,8 @@ export const logger = winston.createLogger({ // into the type when you can define custom levels you want to support? export type Logger = Omit & { fatal: winston.LeveledLogMethod, error: winston.LeveledLogMethod, @@ -174,4 +175,5 @@ export type Logger = Omit, metricName?: string, metricHelp?: string, } = {} ) { - let addLabels = null; - let metricName = result.metric.name; - let metricHelp = result.metric.help; - for (let [name, value] of Object.entries(options)) { - if (name === "addLabels") { - addLabels = value; - } else if (name === "metricName") { - metricName = value; - } else if (name === "metricHelp") { - metricHelp = value; - } else { - throw new Error(`Unrecognized option '${name}'`); - } + let { + addLabels, + metricName = result.metric.name, + metricHelp = result.metric.help, + ...rest + } = options; + for (let name of Object.keys(rest)) { + throw new Error(`Unrecognized option '${name}'`); } let samples: Map>; - if (addLabels === null) { + if (!addLabels) { samples = result.samples; addLabels = {}; diff --git a/test/host.js b/test/host.js index 0b3eddd0..9aee450c 100644 --- a/test/host.js +++ b/test/host.js @@ -3,7 +3,8 @@ const assert = require("assert").strict; const path = require("path"); const lib = require("@clusterio/lib"); -const Host = require("@clusterio/host/src/Host"); +const Host = require("@clusterio/host/dist/src/Host").default; +const { _discoverInstances } = require("@clusterio/host/dist/src/Host"); describe("Host testing", function() { before(function() { @@ -13,7 +14,7 @@ describe("Host testing", function() { describe("discoverInstances()", function() { it("should discover test instance", async function() { let instancePath = path.join("test", "file", "instances"); - let instanceInfos = await Host._discoverInstances(instancePath); + let instanceInfos = await _discoverInstances(instancePath); let referenceConfig = new lib.InstanceConfig("host"); await referenceConfig.init(); diff --git a/test/host/Instance.js b/test/host/Instance.js index bc4e99fc..b53d015c 100644 --- a/test/host/Instance.js +++ b/test/host/Instance.js @@ -4,7 +4,7 @@ const path = require("path"); const lib = require("@clusterio/lib"); const { PlayerStats, wait } = lib; -const Instance = require("@clusterio/host/src/Instance"); +const Instance = require("@clusterio/host/dist/src/Instance").default; const { MockConnector, MockServer } = require("../mock"); const addr = lib.Address.fromShorthand; diff --git a/test/host/export.js b/test/host/export.js index 3d3f5253..f99b53ba 100644 --- a/test/host/export.js +++ b/test/host/export.js @@ -2,8 +2,8 @@ const assert = require("assert").strict; const path = require("path"); -const { FactorioServer } = require("@clusterio/host/src/server"); -const { _exportLocale } = require("@clusterio/host/src/export"); +const { FactorioServer } = require("@clusterio/host/dist/src/server"); +const { _exportLocale } = require("@clusterio/host/dist/src/export"); describe("host/src/export", function() { diff --git a/test/host/patch.js b/test/host/patch.js index b40bbae4..aa64a7a1 100644 --- a/test/host/patch.js +++ b/test/host/patch.js @@ -4,7 +4,7 @@ const fs = require("fs-extra"); const JSZip = require("jszip"); const path = require("path"); -const patch = require("@clusterio/host/src/patch"); +const patch = require("@clusterio/host/dist/src/patch"); describe("host/patch", function() { diff --git a/test/host/server.js b/test/host/server.js index 83c1552f..637838b8 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -4,7 +4,7 @@ const events = require("events"); const fs = require("fs-extra"); const path = require("path"); -const hostServer = require("@clusterio/host/src/server"); +const hostServer = require("@clusterio/host/dist/src/server"); const lib = require("@clusterio/lib"); const { wait } = lib; const { testLines } = require("../lib/factorio/lines"); diff --git a/test/integration/export.js b/test/integration/export.js index 53521dc9..3ab962cc 100644 --- a/test/integration/export.js +++ b/test/integration/export.js @@ -2,8 +2,8 @@ const assert = require("assert").strict; const path = require("path"); -const { _exportLocale } = require("@clusterio/host/src/export"); -const { FactorioServer } = require("@clusterio/host/src/server"); +const { _exportLocale } = require("@clusterio/host/dist/src/export"); +const { FactorioServer } = require("@clusterio/host/dist/src/server"); describe("Integration of lib/factorio/export", function() { diff --git a/test/integration/patch.js b/test/integration/patch.js index b8e71c71..79b5807e 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -4,7 +4,7 @@ const fs = require("fs-extra"); const JSZip = require("jszip"); const path = require("path"); -const { patch } = require("@clusterio/host/src/patch"); +const { patch } = require("@clusterio/host/dist/src/patch"); const { slowTest } = require("./index"); diff --git a/test/integration/server.js b/test/integration/server.js index c5264410..1a8e6e57 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -5,7 +5,7 @@ const fs = require("fs-extra"); const path = require("path"); const lib = require("@clusterio/lib"); -const { FactorioServer, _getVersion } = require("@clusterio/host/src/server"); +const { FactorioServer, _getVersion } = require("@clusterio/host/dist/src/server"); const { logger } = lib; const { slowTest } = require("./index");