Merge pull request #498 from Hornwitser/typescript-host

Port host package to TypeScript
This commit is contained in:
Hornwitser authored and GitHub committed 2023-08-17 19:48:22 +02:00
commit 477b600b1b
26 files changed
+832 -680

No files matched your search

+3 -3
View File
@@ -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;
+1 -1
View File
@@ -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:
+26 -25
View File
@@ -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<string, string> = {};
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);
+13 -2
View File
@@ -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"
}
}
@@ -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<Map<integer, Object>>}
* @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<number, { path: string, config: lib.InstanceConfig }>();
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<number, InstanceConnection>();
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
adminlist = new Set<string>();
banlist = new Map<string, string>();
whitelist = new Set<string>();
serverVersion = "unknown";
serverPlugins = new Map<string, string>();
_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<T>(event: lib.Event<T>) {
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<string>, updatedList: Set<string>, Event: lib.EventClass<unknown>) => {
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<module:host/host~InstanceConnection>} 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;
@@ -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<string, string>)[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<string, lib.BaseInstancePlugin>;
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<string, lib.PlayerStats> = new Map();
/**
* Players currently online on the instance.
*/
playersOnline: Set<string> = 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<typeof setInterval> | 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<string, module:lib.PlayerStats>}
*/
this.playerStats = new Map();
/**
* Players currently online on the instance.
* @type {Map<string, string>}
*/
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<string, number> = 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<typeof lib.PlayerStats.jsonSchema>]) => [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<string, string> = {};
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<Object>}
* @param overrides - Server settings to override.
* @returns
* server example settings with the given settings applied over it.
*/
async resolveServerSettings(overrides) {
async resolveServerSettings(overrides: Record<string, unknown>) {
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<string>} 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<string, string>;
}
// Find plugin modules to patch in
let modules = new Map();
let modules: Map<string, ModuleInfo> = 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<ModuleJson, "version">;
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<string, unknown>, previous: Record<string, unknown>) {
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;
-40
View File
@@ -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;
+32
View File
@@ -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<string, string>();
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);
}
}
}
@@ -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<string, Record<string, Prototype>>
/**
* 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<string, string>, 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<string, string>} modVersions - Mapping of mod to version used.
* @param {string} modPath - Factorio style path to the file to load.
* @returns {Promise<?Buffer>} 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<string, string>, 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<string, Jimp | null>;
async function loadIcon(
server: FactorioServer,
modVersions: Map<string, string>,
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<string, string>,
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<string, string>,
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<string, string>} modVersions -
* @param modVersions -
* Mapping of mod name to versions to get icons from.
* @param {Array<Object>} items - Array of item prototypes.
* @return {Promise<{itemSheet: Jimp, itemData: Map<string,Object>}>}
* 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<string, string>, 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<string, string>} modVersions -
* Mapping of mod name to version to export locale from.
* @param {Array<string>} modOrder - Load order of the mods.
* @param {string} languageCode - Language to export locale for.
* @returns {Promise<Map<string, string>>} 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<string, string>,
modOrder: string[],
languageCode: string
) {
let mergedLocales = new Map<string, string>();
function mergeLocale(locale) {
function mergeLocale(locale: Record<string, string | Record<string, string>>) {
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<JSZip>} 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;
@@ -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<string, string>;
load: string[];
require: string[];
}
interface PatchInfo {
patch_number: number;
scenario: ScenarioInfo;
modules: { name: string, files: { path: string, load: boolean, require: boolean }[], }[];
}
const knownScenarios: Record<string, ScenarioInfo> = {
// 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<Object>} 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<Object>} 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;
@@ -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>} 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<string>} 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> = T extends T ? keyof T : never;
interface Heuristic {
filter: {
[P in KeysOfUnion<lib.ParsedFactorioOutput>]?: 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<string>} 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<boolean>}
* @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<object>} 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;
+19
View File
@@ -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
}
}
+4 -3
View File
@@ -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<string, unknown>;
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);
}
}
+1 -1
View File
@@ -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(
+2 -8
View File
@@ -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<typeof setInterval> | 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");
}
+11 -8
View File
@@ -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<Req, Res> {
constructor: Partial<JSONDeserialisable<Request<Req, Res>>> & {
@@ -71,7 +71,7 @@ export type RequestHandler<Req, Res> = (request: Req, src: libData.Address, dst:
export type EventHandler<T> = (event: T, src: libData.Address, dst: libData.Address) => Promise<void>;
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 `+
+3 -1
View File
@@ -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<winston.Logger,
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "http" | "verbose" | "input" | "silly" |
"emerg" | "alert" | "crit" | "warning" | "notice"
"emerg" | "alert" | "crit" | "warning" | "notice" |
"child"
> & {
fatal: winston.LeveledLogMethod,
error: winston.LeveledLogMethod,
@@ -174,4 +175,5 @@ export type Logger = Omit<winston.Logger,
info: winston.LeveledLogMethod,
server: winston.LeveledLogMethod,
verbose: winston.LeveledLogMethod,
child(options: Object): Logger,
};
+10 -15
View File
@@ -1696,28 +1696,23 @@ exposition.contentType = "text/plain; version=0.0.4";
export function serializeResult(
result: CollectorResult,
options: {
addLabels?: string,
addLabels?: Record<string, string>,
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<string, Map<string, number>>;
if (addLabels === null) {
if (!addLabels) {
samples = result.samples;
addLabels = {};
+3 -2
View File
@@ -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();
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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() {
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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");
+2 -2
View File
@@ -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() {
+1 -1
View File
@@ -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");
+1 -1
View File
@@ -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");