mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 07:24:52 +09:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e39b587df | ||
|
|
e28789c1c0 |
No files matched your search
@@ -20,6 +20,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -46,9 +48,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 18.x
|
||||||
- run: pnpm i --no-frozen-lockfile
|
- run: pnpm i --no-frozen-lockfile
|
||||||
- run: pnpm run lint
|
- run: pnpm run lint
|
||||||
|
|
||||||
@@ -57,9 +61,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 18.x
|
||||||
- run: pnpm i --no-frozen-lockfile
|
- run: pnpm i --no-frozen-lockfile
|
||||||
- run: pnpm run build-mod
|
- run: pnpm run build-mod
|
||||||
# Upload build to artifacts
|
# Upload build to artifacts
|
||||||
|
|||||||
@@ -30,12 +30,8 @@ dist/
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
Factorio*
|
Factorio*
|
||||||
!*Factorio*.ts
|
|
||||||
!*Factorio*.tsx
|
|
||||||
!*Factorio*.js
|
|
||||||
/factorio
|
/factorio
|
||||||
!/test/file/factorio
|
!/test/file/factorio
|
||||||
!/test/file/html/*.html
|
|
||||||
!/test/lib/factorio
|
!/test/lib/factorio
|
||||||
!/packages/lib/src/factorio
|
!/packages/lib/src/factorio
|
||||||
/instances
|
/instances
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi
|
|||||||
|
|
||||||
## Running Clusterio
|
## Running Clusterio
|
||||||
|
|
||||||
After completing the installation and creating your admin user, start up the controller and at least one host separately.
|
After completing the installation start up the controller and at least one host separately.
|
||||||
The installer provides the `run-controller` and `run-host` scripts to make this simple.
|
The installer provides the `run-controller` and `run-host` scripts to make this simple.
|
||||||
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
|
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
|
*
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!README.md
|
!README.md
|
||||||
+1
-2
@@ -2,7 +2,6 @@
|
|||||||
"name": "root",
|
"name": "root",
|
||||||
"private": true,
|
"private": true,
|
||||||
"files": [],
|
"files": [],
|
||||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build",
|
"build": "tsc --build",
|
||||||
"watch": "tsc --build --watch",
|
"watch": "tsc --build --watch",
|
||||||
@@ -28,7 +27,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|||||||
@@ -127,8 +127,7 @@ void new lib.Gauge(
|
|||||||
|
|
||||||
async function handleBootstrapCommand(
|
async function handleBootstrapCommand(
|
||||||
args: any,
|
args: any,
|
||||||
controllerConfig: lib.ControllerConfig,
|
controllerConfig: lib.ControllerConfig
|
||||||
controllerConfigLock: lib.LockFile,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let subCommand = args._[1];
|
let subCommand = args._[1];
|
||||||
|
|
||||||
@@ -142,8 +141,6 @@ async function handleBootstrapCommand(
|
|||||||
await userManager.load(path.join(databaseDirectory, "users.json"));
|
await userManager.load(path.join(databaseDirectory, "users.json"));
|
||||||
|
|
||||||
if (subCommand === "create-admin") {
|
if (subCommand === "create-admin") {
|
||||||
await controllerConfigLock.acquire(); // Also needed to write to the database files
|
|
||||||
|
|
||||||
if (!args.name) {
|
if (!args.name) {
|
||||||
logger.error("name cannot be blank");
|
logger.error("name cannot be blank");
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -159,7 +156,6 @@ async function handleBootstrapCommand(
|
|||||||
admin.roleIds.add(adminRole.id);
|
admin.roleIds.add(adminRole.id);
|
||||||
admin.isAdmin = true;
|
admin.isAdmin = true;
|
||||||
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
|
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
|
||||||
await controllerConfigLock.release();
|
|
||||||
|
|
||||||
} else if (subCommand === "generate-user-token") {
|
} else if (subCommand === "generate-user-token") {
|
||||||
let user = userManager.getByName(args.name);
|
let user = userManager.getByName(args.name);
|
||||||
@@ -255,7 +251,6 @@ async function initialize(): Promise<InitializeParameters> {
|
|||||||
default: "plugin-list.json",
|
default: "plugin-list.json",
|
||||||
type: "string",
|
type: "string",
|
||||||
})
|
})
|
||||||
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
|
|
||||||
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
||||||
.command("config", "Manage Controller config", lib.configCommand)
|
.command("config", "Manage Controller config", lib.configCommand)
|
||||||
.command("bootstrap", "Bootstrap access to cluster", yargs => {
|
.command("bootstrap", "Bootstrap access to cluster", yargs => {
|
||||||
@@ -286,7 +281,6 @@ async function initialize(): Promise<InitializeParameters> {
|
|||||||
type: "boolean", nargs: 0, default: false,
|
type: "boolean", nargs: 0, default: false,
|
||||||
describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected",
|
describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected",
|
||||||
});
|
});
|
||||||
yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true });
|
|
||||||
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
|
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
|
||||||
yargs.option("dev-plugin", { hidden: true, type: "array" });
|
yargs.option("dev-plugin", { hidden: true, type: "array" });
|
||||||
})
|
})
|
||||||
@@ -356,14 +350,8 @@ async function initialize(): Promise<InitializeParameters> {
|
|||||||
lib.registerPluginMessages(pluginInfos);
|
lib.registerPluginMessages(pluginInfos);
|
||||||
lib.addPluginConfigFields(pluginInfos);
|
lib.addPluginConfigFields(pluginInfos);
|
||||||
|
|
||||||
const controllerConfigPath = args.config;
|
|
||||||
const controllerConfigLockPath = `${controllerConfigPath}.lock`;
|
|
||||||
if (args.bypassLockFile) {
|
|
||||||
await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist
|
|
||||||
}
|
|
||||||
|
|
||||||
let controllerConfig;
|
let controllerConfig;
|
||||||
const controllerConfigLock = new lib.LockFile(controllerConfigLockPath);
|
const controllerConfigPath = args.config;
|
||||||
logger.info(`Loading config from ${controllerConfigPath}`);
|
logger.info(`Loading config from ${controllerConfigPath}`);
|
||||||
try {
|
try {
|
||||||
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
|
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
|
||||||
@@ -389,11 +377,10 @@ async function initialize(): Promise<InitializeParameters> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === "config") {
|
if (command === "config") {
|
||||||
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
|
await lib.handleConfigCommand(args, controllerConfig);
|
||||||
|
|
||||||
} else if (command === "bootstrap") {
|
} else if (command === "bootstrap") {
|
||||||
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
|
await handleBootstrapCommand(args, controllerConfig);
|
||||||
} else if (shouldRun) {
|
|
||||||
await controllerConfigLock.acquire(); // Hold the lock until process exit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -430,14 +417,6 @@ async function startup() {
|
|||||||
...await Controller.bootstrap(controllerConfig)
|
...await Controller.bootstrap(controllerConfig)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refuse to start if there are no users loaded
|
|
||||||
if (args.checkUserCount && controller.userManager.users.size === 0) {
|
|
||||||
logger.fatal(
|
|
||||||
"Cannot start controller, no users loaded.\n" +
|
|
||||||
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let secondSigint = false;
|
let secondSigint = false;
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
if (secondSigint) {
|
if (secondSigint) {
|
||||||
|
|||||||
@@ -17,45 +17,45 @@
|
|||||||
"factorio"
|
"factorio"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clusterio/lib": "workspace:*",
|
"@clusterio/lib": "workspace:*",
|
||||||
"@sinclair/typebox": "catalog:",
|
"@sinclair/typebox": "^0.30.4",
|
||||||
"set-blocking": "catalog:",
|
|
||||||
"jsonwebtoken": "catalog:",
|
|
||||||
"express": "catalog:",
|
|
||||||
"fs-extra": "catalog:",
|
|
||||||
"ws": "catalog:",
|
|
||||||
"jszip": "catalog:",
|
|
||||||
"winston": "catalog:",
|
|
||||||
"winston-daily-rotate-file": "catalog:",
|
|
||||||
"busboy": "^1.6.0",
|
"busboy": "^1.6.0",
|
||||||
"compression": "^1.8.0",
|
"compression": "^1.8.0",
|
||||||
|
"express": "^4.21.2",
|
||||||
"finalhandler": "^1.3.1",
|
"finalhandler": "^1.3.1",
|
||||||
|
"fs-extra": "^11.3.0",
|
||||||
|
"jsonwebtoken": "^9.0.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"winston": "^3.17.0",
|
||||||
|
"winston-daily-rotate-file": "^4.7.1",
|
||||||
|
"ws": "^8.18.1",
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/web_ui": "workspace:*",
|
"@clusterio/web_ui": "workspace:*",
|
||||||
"@types/jsonwebtoken": "catalog:",
|
"@swc/core": "^1.13.3",
|
||||||
"@types/set-blocking": "catalog:",
|
|
||||||
"@types/finalhandler": "catalog:",
|
|
||||||
"@types/express": "catalog:",
|
|
||||||
"@types/fs-extra": "catalog:",
|
|
||||||
"@types/node": "catalog:",
|
|
||||||
"@types/ws": "catalog:",
|
|
||||||
"@types/yargs": "^17.0.33",
|
|
||||||
"@types/compression": "^1.7.2",
|
|
||||||
"@types/busboy": "^1.5.0",
|
"@types/busboy": "^1.5.0",
|
||||||
"@swc/core": "catalog:",
|
"@types/compression": "^1.7.2",
|
||||||
"react": "catalog:",
|
"@types/express": "^4.17.17",
|
||||||
"react-dom": "catalog:",
|
"@types/finalhandler": "^1.2.0",
|
||||||
"react-router-dom": "catalog:",
|
"@types/fs-extra": "^11.0.1",
|
||||||
"typescript": "catalog:",
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"webpack": "catalog:",
|
"@types/node": "^20.17.19",
|
||||||
"webpack-cli": "catalog:",
|
"@types/set-blocking": "^2.0.0",
|
||||||
"webpack-merge": "catalog:",
|
"@types/ws": "^8.5.14",
|
||||||
"webpack-dev-middleware": "^6.1.1"
|
"@types/yargs": "^17.0.33",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.29.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"webpack": "^5.98.0",
|
||||||
|
"webpack-cli": "^5.1.4",
|
||||||
|
"webpack-dev-middleware": "^6.1.1",
|
||||||
|
"webpack-merge": "^5.9.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
|
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
|
||||||
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
|
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
|
||||||
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
|
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
|
||||||
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
|
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
|
||||||
@@ -1223,10 +1222,6 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
this.connector._socket!.clusterio_ignore_dump = true;
|
this.connector._socket!.clusterio_ignore_dump = true;
|
||||||
this._controller.debugEvents.on("message", this.ws_dumper);
|
this._controller.debugEvents.on("message", this.ws_dumper);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
|
|
||||||
return await this._controller.factorioVersions.get(request.maxAgeMs);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = ControlConnection;
|
module.exports = ControlConnection;
|
||||||
@@ -28,8 +28,6 @@ import HostConnection from "./HostConnection";
|
|||||||
import HostInfo from "./HostInfo";
|
import HostInfo from "./HostInfo";
|
||||||
import BaseControllerPlugin from "./BaseControllerPlugin";
|
import BaseControllerPlugin from "./BaseControllerPlugin";
|
||||||
import ControllerRouter from "./ControllerRouter";
|
import ControllerRouter from "./ControllerRouter";
|
||||||
import PlayerRoutingStore from "./PlayerRoutingStore";
|
|
||||||
import PlayerProxyServer from "./PlayerProxyServer";
|
|
||||||
|
|
||||||
const endpointDurationSummary = new Summary(
|
const endpointDurationSummary = new Summary(
|
||||||
"clusterio_controller_http_endpoint_duration_seconds",
|
"clusterio_controller_http_endpoint_duration_seconds",
|
||||||
@@ -78,8 +76,6 @@ export default class Controller {
|
|||||||
trustedProxies: BlockList;
|
trustedProxies: BlockList;
|
||||||
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
||||||
private _events = new events.EventEmitter<ControllerEvents>();
|
private _events = new events.EventEmitter<ControllerEvents>();
|
||||||
playerRoutingStore: PlayerRoutingStore;
|
|
||||||
playerProxyServer: PlayerProxyServer | null = null;
|
|
||||||
|
|
||||||
/** Event subscription controller */
|
/** Event subscription controller */
|
||||||
subscriptions = new lib.SubscriptionController();
|
subscriptions = new lib.SubscriptionController();
|
||||||
@@ -109,8 +105,6 @@ export default class Controller {
|
|||||||
|
|
||||||
// Cache for mod portal requests
|
// Cache for mod portal requests
|
||||||
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
|
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
|
||||||
// Cache for factorio versions
|
|
||||||
factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions);
|
|
||||||
|
|
||||||
static async bootstrap(config: lib.ControllerConfig) {
|
static async bootstrap(config: lib.ControllerConfig) {
|
||||||
let databaseDirectory = config.get("controller.database_directory");
|
let databaseDirectory = config.get("controller.database_directory");
|
||||||
@@ -213,9 +207,6 @@ export default class Controller {
|
|||||||
this.clusterLogger = clusterLogger;
|
this.clusterLogger = clusterLogger;
|
||||||
this.pluginInfos = pluginInfos;
|
this.pluginInfos = pluginInfos;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.playerRoutingStore = new PlayerRoutingStore(
|
|
||||||
path.join(this.config.get("controller.database_directory"), "player-routing.json"),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.app = express();
|
this.app = express();
|
||||||
this.app.locals.controller = this;
|
this.app.locals.controller = this;
|
||||||
@@ -349,17 +340,6 @@ export default class Controller {
|
|||||||
// Load plugins
|
// Load plugins
|
||||||
await this.loadPlugins();
|
await this.loadPlugins();
|
||||||
|
|
||||||
await this.playerRoutingStore.load();
|
|
||||||
const playerProxyPort = this.config.get("controller.player_proxy_port");
|
|
||||||
if (playerProxyPort !== null) {
|
|
||||||
this.playerProxyServer = new PlayerProxyServer(
|
|
||||||
this,
|
|
||||||
logger.child({ component: "player_proxy" }),
|
|
||||||
playerProxyPort,
|
|
||||||
);
|
|
||||||
await this.playerProxyServer.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log all express errors from middleware or routes (app.use is missing the type overload)
|
// Log all express errors from middleware or routes (app.use is missing the type overload)
|
||||||
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
||||||
logger.log({
|
logger.log({
|
||||||
@@ -502,11 +482,6 @@ export default class Controller {
|
|||||||
|
|
||||||
await lib.invokeHook(this.plugins, "onShutdown");
|
await lib.invokeHook(this.plugins, "onShutdown");
|
||||||
|
|
||||||
if (this.playerProxyServer) {
|
|
||||||
await this.playerProxyServer.stop();
|
|
||||||
this.playerProxyServer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.wsServer.stop();
|
await this.wsServer.stop();
|
||||||
|
|
||||||
let stopTasks = [];
|
let stopTasks = [];
|
||||||
@@ -674,8 +649,6 @@ export default class Controller {
|
|||||||
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
|
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.playerRoutingStore.save();
|
|
||||||
|
|
||||||
await lib.invokeHook(this.plugins, "onSaveData");
|
await lib.invokeHook(this.plugins, "onSaveData");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,87 +858,6 @@ export default class Controller {
|
|||||||
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvePlayerRoute(username: string): { hostId: number; instanceId: number; publicAddress: string; gamePort: number } | null {
|
|
||||||
const key = username.toLowerCase();
|
|
||||||
let route = this.playerRoutingStore.get(key);
|
|
||||||
let instance: InstanceInfo | undefined;
|
|
||||||
|
|
||||||
if (!route) {
|
|
||||||
const fallbackId = this.config.get("controller.player_proxy_default_instance_id");
|
|
||||||
if (fallbackId !== null) {
|
|
||||||
instance = this.instances.get(fallbackId);
|
|
||||||
if (instance) {
|
|
||||||
route = {
|
|
||||||
name: username,
|
|
||||||
instanceId: instance.id,
|
|
||||||
hostId: instance.config.get("instance.assigned_host") ?? -1,
|
|
||||||
gamePort: instance.gamePort ?? 0,
|
|
||||||
timestampMs: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
instance = this._chooseFallbackInstance();
|
|
||||||
if (instance) {
|
|
||||||
route = {
|
|
||||||
name: username,
|
|
||||||
instanceId: instance.id,
|
|
||||||
hostId: instance.config.get("instance.assigned_host") ?? -1,
|
|
||||||
gamePort: instance.gamePort ?? 0,
|
|
||||||
timestampMs: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!route) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
instance = instance ?? this.instances.get(route.instanceId);
|
|
||||||
if (!instance || instance.isDeleted) {
|
|
||||||
this.playerRoutingStore.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostId = instance.config.get("instance.assigned_host");
|
|
||||||
if (hostId === null || route.hostId !== hostId) {
|
|
||||||
this.playerRoutingStore.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = this.hosts.get(hostId);
|
|
||||||
if (!host || !host.connected) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const gamePort = instance.gamePort ?? route.gamePort;
|
|
||||||
const publicAddress = host.publicAddress || host.remoteAddress;
|
|
||||||
if (!gamePort || !publicAddress) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hostId,
|
|
||||||
instanceId: instance.id,
|
|
||||||
publicAddress,
|
|
||||||
gamePort,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private _chooseFallbackInstance() {
|
|
||||||
for (const instance of this.instances.values()) {
|
|
||||||
if (instance.status === "running" && !instance.isDeleted) {
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const instance of this.instances.values()) {
|
|
||||||
if (!instance.isDeleted && instance.config.get("instance.assigned_host") !== null) {
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get instance by ID for a request
|
* Get instance by ID for a request
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export default class HostConnection extends BaseConnection {
|
|||||||
|
|
||||||
this.info.connected = false;
|
this.info.connected = false;
|
||||||
this._controller.hosts.set(this.info);
|
this._controller.hosts.set(this.info);
|
||||||
this._controller.playerRoutingStore.dropHost(this.id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
|
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
|
||||||
@@ -82,7 +81,6 @@ export default class HostConnection extends BaseConnection {
|
|||||||
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
|
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
|
||||||
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
|
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
|
||||||
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
|
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
|
||||||
this.handle(lib.InstancePlayerRouteUpdateEvent, this.handleInstancePlayerRouteUpdateEvent.bind(this));
|
|
||||||
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this));
|
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this));
|
||||||
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
|
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
|
||||||
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this));
|
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this));
|
||||||
@@ -356,30 +354,6 @@ export default class HostConnection extends BaseConnection {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleInstancePlayerRouteUpdateEvent(event: lib.InstancePlayerRouteUpdateEvent, src: lib.Address) {
|
|
||||||
const instanceId = src.id;
|
|
||||||
if (event.instanceId !== instanceId) {
|
|
||||||
logger.warn(
|
|
||||||
`Received route update for instance ${event.instanceId} from ${instanceId}, ignoring mismatched id`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.hostId !== this.id) {
|
|
||||||
logger.warn(
|
|
||||||
`Received route update for host ${event.hostId} from ${this.id}, ignoring mismatched host`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._controller.playerRoutingStore.update(
|
|
||||||
event.name,
|
|
||||||
instanceId,
|
|
||||||
this.id,
|
|
||||||
event.gamePort,
|
|
||||||
event.timestampMs,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) {
|
async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) {
|
||||||
if (src.type !== lib.Address.instance) {
|
if (src.type !== lib.Address.instance) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,511 +0,0 @@
|
|||||||
import dgram from "dgram";
|
|
||||||
|
|
||||||
import * as lib from "@clusterio/lib";
|
|
||||||
|
|
||||||
import type Controller from "./Controller";
|
|
||||||
|
|
||||||
const TYPE_MASK = 0x1f;
|
|
||||||
const FLAG_MASK = 0xe0;
|
|
||||||
|
|
||||||
const TYPE_CONNECTION_REQUEST = 2;
|
|
||||||
const TYPE_CONNECTION_REQUEST_REPLY = 3;
|
|
||||||
const TYPE_CONNECTION_REQUEST_REPLY_CONFIRM = 4;
|
|
||||||
|
|
||||||
const SESSION_IDLE_TIMEOUT_MS = 30_000;
|
|
||||||
const MAX_PENDING_PACKETS = 64;
|
|
||||||
const MAX_PACKET_SIZE = 1400;
|
|
||||||
|
|
||||||
type ClientKey = string;
|
|
||||||
|
|
||||||
type RouteTarget = {
|
|
||||||
hostId: number;
|
|
||||||
instanceId: number;
|
|
||||||
publicAddress: string;
|
|
||||||
gamePort: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ClientSession = {
|
|
||||||
clientAddress: string;
|
|
||||||
clientPort: number;
|
|
||||||
lastSeenMs: number;
|
|
||||||
pending: Buffer[];
|
|
||||||
upstream: dgram.Socket | null;
|
|
||||||
route: RouteTarget | null;
|
|
||||||
requestBuffer?: Buffer;
|
|
||||||
requestFlags?: number;
|
|
||||||
requestMessageIdAndFlags?: number;
|
|
||||||
requestVersion?: ApplicationVersion;
|
|
||||||
clientRequestId?: number;
|
|
||||||
proxyServerId?: number;
|
|
||||||
pendingConfirm?: Buffer;
|
|
||||||
confirmServerIdOffset?: number;
|
|
||||||
serverConnectionId?: number;
|
|
||||||
username?: string;
|
|
||||||
activating?: Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ApplicationVersion = {
|
|
||||||
major: number;
|
|
||||||
minor: number;
|
|
||||||
sub: number;
|
|
||||||
build: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
class BufferReader {
|
|
||||||
private _offset = 0;
|
|
||||||
constructor(private _buffer: Buffer) {}
|
|
||||||
|
|
||||||
get offset() {
|
|
||||||
return this._offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
readUInt8() {
|
|
||||||
const value = this._buffer.readUInt8(this._offset);
|
|
||||||
this._offset += 1;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
readUInt16LE() {
|
|
||||||
const value = this._buffer.readUInt16LE(this._offset);
|
|
||||||
this._offset += 2;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
readUInt32LE() {
|
|
||||||
const value = this._buffer.readUInt32LE(this._offset);
|
|
||||||
this._offset += 4;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
readSpaceOptimized() {
|
|
||||||
const value = this.readUInt8();
|
|
||||||
if (value === 0xff) {
|
|
||||||
return this.readUInt16LE();
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
readContainerSize() {
|
|
||||||
const value = this.readUInt8();
|
|
||||||
if (value === 0xff) {
|
|
||||||
return this.readUInt32LE();
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
readBytes(length: number) {
|
|
||||||
const start = this._offset;
|
|
||||||
const end = start + length;
|
|
||||||
if (end > this._buffer.length) {
|
|
||||||
throw new Error("Buffer too short");
|
|
||||||
}
|
|
||||||
this._offset = end;
|
|
||||||
return this._buffer.slice(start, end);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class BufferWriter {
|
|
||||||
private _parts: Buffer[] = [];
|
|
||||||
|
|
||||||
writeUInt8(value: number) {
|
|
||||||
const buf = Buffer.allocUnsafe(1);
|
|
||||||
buf.writeUInt8(value, 0);
|
|
||||||
this._parts.push(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeUInt16LE(value: number) {
|
|
||||||
const buf = Buffer.allocUnsafe(2);
|
|
||||||
buf.writeUInt16LE(value, 0);
|
|
||||||
this._parts.push(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeUInt32LE(value: number) {
|
|
||||||
const buf = Buffer.allocUnsafe(4);
|
|
||||||
buf.writeUInt32LE(value >>> 0, 0);
|
|
||||||
this._parts.push(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeSpaceOptimized(value: number) {
|
|
||||||
if (value >= 0xff) {
|
|
||||||
this.writeUInt8(0xff);
|
|
||||||
this.writeUInt16LE(value);
|
|
||||||
} else {
|
|
||||||
this.writeUInt8(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
concat() {
|
|
||||||
return Buffer.concat(this._parts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clientKey(address: string, port: number): ClientKey {
|
|
||||||
return `${address}:${port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class PlayerProxyServer {
|
|
||||||
private _socket: dgram.Socket | null = null;
|
|
||||||
private _sessions = new Map<ClientKey, ClientSession>();
|
|
||||||
private _cleanupTimer: ReturnType<typeof setInterval> | undefined;
|
|
||||||
private _closed = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private _controller: Controller,
|
|
||||||
private _logger: lib.Logger,
|
|
||||||
private _port: number,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async start() {
|
|
||||||
if (this._socket || this._closed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const socket = dgram.createSocket("udp4");
|
|
||||||
this._socket = socket;
|
|
||||||
socket.on("message", this._handleClientMessage.bind(this));
|
|
||||||
socket.on("error", err => {
|
|
||||||
this._logger.error(`Player proxy socket error: ${err.message}`);
|
|
||||||
this.stop().catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
socket.once("listening", resolve);
|
|
||||||
socket.once("error", reject);
|
|
||||||
socket.bind(this._port, "0.0.0.0");
|
|
||||||
});
|
|
||||||
|
|
||||||
this._cleanupTimer = setInterval(() => this._cleanupSessions(), SESSION_IDLE_TIMEOUT_MS);
|
|
||||||
this._cleanupTimer.unref();
|
|
||||||
this._logger.info(`Player proxy listening on UDP ${this._port}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop() {
|
|
||||||
if (this._closed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._closed = true;
|
|
||||||
|
|
||||||
if (this._cleanupTimer) {
|
|
||||||
clearInterval(this._cleanupTimer);
|
|
||||||
this._cleanupTimer = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const session of this._sessions.values()) {
|
|
||||||
session.upstream?.close();
|
|
||||||
}
|
|
||||||
this._sessions.clear();
|
|
||||||
|
|
||||||
if (this._socket) {
|
|
||||||
await new Promise<void>(resolve => this._socket!.close(() => resolve()));
|
|
||||||
this._socket = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleClientMessage(message: Buffer, rinfo: dgram.RemoteInfo) {
|
|
||||||
if (this._closed || !this._socket) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = clientKey(rinfo.address, rinfo.port);
|
|
||||||
let session = this._sessions.get(key);
|
|
||||||
if (!session) {
|
|
||||||
session = this._createSession(rinfo.address, rinfo.port);
|
|
||||||
this._sessions.set(key, session);
|
|
||||||
}
|
|
||||||
session.lastSeenMs = Date.now();
|
|
||||||
|
|
||||||
let type: number;
|
|
||||||
try {
|
|
||||||
type = message.readUInt8(0) & TYPE_MASK;
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === TYPE_CONNECTION_REQUEST) {
|
|
||||||
this._handleConnectionRequest(session, message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === TYPE_CONNECTION_REQUEST_REPLY_CONFIRM) {
|
|
||||||
this._handleConnectionRequestReplyConfirm(session, message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.route || !session.upstream) {
|
|
||||||
this._queuePending(session, message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._flushPending(session);
|
|
||||||
session.upstream.send(message, session.route.gamePort, session.route.publicAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _createSession(address: string, port: number): ClientSession {
|
|
||||||
return {
|
|
||||||
clientAddress: address,
|
|
||||||
clientPort: port,
|
|
||||||
lastSeenMs: Date.now(),
|
|
||||||
pending: [],
|
|
||||||
upstream: null,
|
|
||||||
route: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleConnectionRequest(session: ClientSession, message: Buffer) {
|
|
||||||
let parsed: {
|
|
||||||
flags: number;
|
|
||||||
messageIdAndFlags: number;
|
|
||||||
version: ApplicationVersion;
|
|
||||||
requestId: number;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
parsed = this._parseConnectionRequest(message);
|
|
||||||
} catch (err: any) {
|
|
||||||
this._logger.warn(`Failed to parse connection request: ${err.message ?? err}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.requestBuffer = message;
|
|
||||||
session.requestFlags = parsed.flags;
|
|
||||||
session.requestMessageIdAndFlags = parsed.messageIdAndFlags;
|
|
||||||
session.requestVersion = parsed.version;
|
|
||||||
session.clientRequestId = parsed.requestId;
|
|
||||||
if (!session.proxyServerId) {
|
|
||||||
session.proxyServerId = Math.floor(Math.random() * 2 ** 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reply = this._buildConnectionRequestReply(session);
|
|
||||||
this._socket?.send(reply, session.clientPort, session.clientAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleConnectionRequestReplyConfirm(session: ClientSession, message: Buffer) {
|
|
||||||
let parsed: { username: string; serverIdOffset: number };
|
|
||||||
try {
|
|
||||||
parsed = this._parseConnectionRequestReplyConfirm(message);
|
|
||||||
} catch (err: any) {
|
|
||||||
this._logger.warn(`Failed to parse connection confirm: ${err.message ?? err}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.username = parsed.username;
|
|
||||||
session.pendingConfirm = message;
|
|
||||||
session.confirmServerIdOffset = parsed.serverIdOffset;
|
|
||||||
|
|
||||||
if (session.route || session.activating) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.activating = this._activateRoute(session).finally(() => {
|
|
||||||
session.activating = undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _buildConnectionRequestReply(session: ClientSession) {
|
|
||||||
const flags = session.requestFlags ?? 0;
|
|
||||||
const version = session.requestVersion ?? { major: 0, minor: 0, sub: 0, build: 0 };
|
|
||||||
const requestId = session.clientRequestId ?? 0;
|
|
||||||
const serverId = session.proxyServerId ?? 0;
|
|
||||||
|
|
||||||
const writer = new BufferWriter();
|
|
||||||
writer.writeUInt8((flags & FLAG_MASK) | TYPE_CONNECTION_REQUEST_REPLY);
|
|
||||||
writer.writeSpaceOptimized(version.major);
|
|
||||||
writer.writeSpaceOptimized(version.minor);
|
|
||||||
writer.writeSpaceOptimized(version.sub);
|
|
||||||
writer.writeUInt32LE(version.build);
|
|
||||||
writer.writeUInt32LE(requestId);
|
|
||||||
writer.writeUInt32LE(serverId);
|
|
||||||
writer.writeUInt32LE(MAX_PACKET_SIZE);
|
|
||||||
return writer.concat();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _parseConnectionRequest(buffer: Buffer) {
|
|
||||||
const reader = new BufferReader(buffer);
|
|
||||||
const header = reader.readUInt8();
|
|
||||||
const type = header & TYPE_MASK;
|
|
||||||
if (type !== TYPE_CONNECTION_REQUEST) {
|
|
||||||
throw new Error("Unexpected message type");
|
|
||||||
}
|
|
||||||
|
|
||||||
const flags = header & FLAG_MASK;
|
|
||||||
const messageIdAndFlags = reader.readUInt16LE();
|
|
||||||
const version = {
|
|
||||||
major: reader.readSpaceOptimized(),
|
|
||||||
minor: reader.readSpaceOptimized(),
|
|
||||||
sub: reader.readSpaceOptimized(),
|
|
||||||
build: reader.readUInt32LE(),
|
|
||||||
};
|
|
||||||
const requestId = reader.readUInt32LE();
|
|
||||||
return { flags, messageIdAndFlags, version, requestId };
|
|
||||||
}
|
|
||||||
|
|
||||||
private _parseConnectionRequestReplyConfirm(buffer: Buffer) {
|
|
||||||
const reader = new BufferReader(buffer);
|
|
||||||
const header = reader.readUInt8();
|
|
||||||
const type = header & TYPE_MASK;
|
|
||||||
if (type !== TYPE_CONNECTION_REQUEST_REPLY_CONFIRM) {
|
|
||||||
throw new Error("Unexpected message type");
|
|
||||||
}
|
|
||||||
reader.readUInt16LE();
|
|
||||||
reader.readUInt32LE(); // client request id
|
|
||||||
const serverIdOffset = reader.offset;
|
|
||||||
reader.readUInt32LE();
|
|
||||||
reader.readUInt32LE(); // instance id
|
|
||||||
const nameLength = reader.readContainerSize();
|
|
||||||
const nameBuffer = reader.readBytes(nameLength);
|
|
||||||
const username = nameBuffer.toString("utf8");
|
|
||||||
return { username, serverIdOffset };
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleUpstreamMessage(session: ClientSession, data: Buffer) {
|
|
||||||
if (!this._socket || !session.route) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let type: number;
|
|
||||||
try {
|
|
||||||
type = data.readUInt8(0) & TYPE_MASK;
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === TYPE_CONNECTION_REQUEST_REPLY && session.serverConnectionId === undefined) {
|
|
||||||
try {
|
|
||||||
const serverConnectionId = this._parseConnectionRequestReply(data);
|
|
||||||
session.serverConnectionId = serverConnectionId;
|
|
||||||
this._sendPendingConfirm(session);
|
|
||||||
} catch (err: any) {
|
|
||||||
this._logger.warn(`Failed to parse upstream reply: ${err.message ?? err}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._socket.send(data, session.clientPort, session.clientAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _parseConnectionRequestReply(buffer: Buffer) {
|
|
||||||
const reader = new BufferReader(buffer);
|
|
||||||
const header = reader.readUInt8();
|
|
||||||
const type = header & TYPE_MASK;
|
|
||||||
if (type !== TYPE_CONNECTION_REQUEST_REPLY) {
|
|
||||||
throw new Error("Unexpected message type");
|
|
||||||
}
|
|
||||||
reader.readSpaceOptimized();
|
|
||||||
reader.readSpaceOptimized();
|
|
||||||
reader.readSpaceOptimized();
|
|
||||||
reader.readUInt32LE();
|
|
||||||
reader.readUInt32LE(); // client request id
|
|
||||||
return reader.readUInt32LE();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _activateRoute(session: ClientSession) {
|
|
||||||
if (!session.username) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = this._controller.resolvePlayerRoute(session.username);
|
|
||||||
if (!route) {
|
|
||||||
this._logger.warn(`No route for ${session.username}, dropping client ${session.clientAddress}:${session.clientPort}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const instanceReady = await this._ensureInstanceRunning(route.instanceId);
|
|
||||||
if (!instanceReady) {
|
|
||||||
this._logger.warn(`Instance ${route.instanceId} not ready for ${session.username}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.route = route;
|
|
||||||
if (!session.upstream) {
|
|
||||||
const upstream = dgram.createSocket("udp4");
|
|
||||||
session.upstream = upstream;
|
|
||||||
upstream.on("message", data => this._handleUpstreamMessage(session, data));
|
|
||||||
upstream.on("error", err => {
|
|
||||||
this._logger.warn(
|
|
||||||
`Upstream error for ${session.clientAddress}:${session.clientPort}: ${err.message}`
|
|
||||||
);
|
|
||||||
upstream.close();
|
|
||||||
this._sessions.delete(clientKey(session.clientAddress, session.clientPort));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.requestBuffer) {
|
|
||||||
session.upstream.send(session.requestBuffer, route.gamePort, route.publicAddress);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _ensureInstanceRunning(instanceId: number) {
|
|
||||||
const instance = this._controller.instances.get(instanceId);
|
|
||||||
if (!instance || instance.isDeleted) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (instance.status === "running") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (instance.config.get("instance.assigned_host") === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this._controller.sendTo({ instanceId }, new lib.InstanceStartRequest());
|
|
||||||
} catch (err: any) {
|
|
||||||
this._logger.warn(`Failed to start instance ${instanceId}: ${err.message ?? err}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deadline = Date.now() + SESSION_IDLE_TIMEOUT_MS;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const current = this._controller.instances.get(instanceId);
|
|
||||||
if (!current || current.isDeleted) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (current.status === "running") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _sendPendingConfirm(session: ClientSession) {
|
|
||||||
if (!session.pendingConfirm || session.confirmServerIdOffset === undefined || !session.upstream || !session.route) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.serverConnectionId === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = Buffer.from(session.pendingConfirm);
|
|
||||||
payload.writeUInt32LE(session.serverConnectionId, session.confirmServerIdOffset);
|
|
||||||
session.upstream.send(payload, session.route.gamePort, session.route.publicAddress);
|
|
||||||
session.pendingConfirm = undefined;
|
|
||||||
this._flushPending(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _queuePending(session: ClientSession, message: Buffer) {
|
|
||||||
if (session.pending.length >= MAX_PENDING_PACKETS) {
|
|
||||||
session.pending.shift();
|
|
||||||
}
|
|
||||||
session.pending.push(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _flushPending(session: ClientSession) {
|
|
||||||
if (!session.route || !session.upstream || !session.pending.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const pending of session.pending) {
|
|
||||||
session.upstream.send(pending, session.route.gamePort, session.route.publicAddress);
|
|
||||||
}
|
|
||||||
session.pending = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private _cleanupSessions() {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, session] of this._sessions) {
|
|
||||||
if (now - session.lastSeenMs > SESSION_IDLE_TIMEOUT_MS) {
|
|
||||||
session.upstream?.close();
|
|
||||||
this._sessions.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import fs from "fs-extra";
|
|
||||||
|
|
||||||
import * as lib from "@clusterio/lib";
|
|
||||||
|
|
||||||
export type StoredRoute = {
|
|
||||||
name: string;
|
|
||||||
instanceId: number;
|
|
||||||
hostId: number;
|
|
||||||
gamePort: number;
|
|
||||||
timestampMs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class PlayerRoutingStore {
|
|
||||||
private _routes = new Map<string, StoredRoute>();
|
|
||||||
private _dirty = false;
|
|
||||||
|
|
||||||
constructor(private _filePath: string) {}
|
|
||||||
|
|
||||||
get(name: string): StoredRoute | undefined {
|
|
||||||
return this._routes.get(name.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
update(name: string, instanceId: number, hostId: number, gamePort: number, timestampMs: number) {
|
|
||||||
const key = name.toLowerCase();
|
|
||||||
this._routes.set(key, { name, instanceId, hostId, gamePort, timestampMs });
|
|
||||||
this._dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(name: string) {
|
|
||||||
const key = name.toLowerCase();
|
|
||||||
if (this._routes.delete(key)) {
|
|
||||||
this._dirty = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dropHost(hostId: number) {
|
|
||||||
let removed = false;
|
|
||||||
for (const [key, route] of this._routes) {
|
|
||||||
if (route.hostId === hostId) {
|
|
||||||
this._routes.delete(key);
|
|
||||||
removed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (removed) {
|
|
||||||
this._dirty = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async load(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const routes = JSON.parse(await fs.readFile(this._filePath, { encoding: "utf8" })) as StoredRoute[];
|
|
||||||
this._routes.clear();
|
|
||||||
for (const route of routes) {
|
|
||||||
if (!route || !route.name) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this._routes.set(route.name.toLowerCase(), route);
|
|
||||||
}
|
|
||||||
this._dirty = false;
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.code !== "ENOENT") {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(): Promise<void> {
|
|
||||||
if (!this._dirty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = JSON.stringify([...this._routes.values()], null, "\t");
|
|
||||||
await lib.safeOutputFile(this._filePath, payload);
|
|
||||||
this._dirty = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -754,8 +754,8 @@ async function inquirerMissingArgs(args) {
|
|||||||
async function downloadLinuxServer() {
|
async function downloadLinuxServer() {
|
||||||
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
|
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
|
||||||
|
|
||||||
|
const url = new URL(res.headers.location);
|
||||||
// get the filename of the latest factorio archive from redirected url
|
// get the filename of the latest factorio archive from redirected url
|
||||||
const url = new URL(res.url); // The res is redirects, need final url
|
|
||||||
const filename = path.posix.basename(url.pathname);
|
const filename = path.posix.basename(url.pathname);
|
||||||
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
|
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
|
||||||
if (!match || !match.length) {
|
if (!match || !match.length) {
|
||||||
@@ -772,7 +772,10 @@ async function downloadLinuxServer() {
|
|||||||
if (await fs.pathExists(factorioDir)) {
|
if (await fs.pathExists(factorioDir)) {
|
||||||
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
|
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
|
||||||
} else {
|
} else {
|
||||||
await fs.emptyDir(tmpDir);
|
await fs.ensureDir(tmpDir);
|
||||||
|
|
||||||
|
// follow the redirect
|
||||||
|
res = await fetch(url.href);
|
||||||
|
|
||||||
logger.info("Downloading latest Factorio server release. This may take a while.");
|
logger.info("Downloading latest Factorio server release. This may take a while.");
|
||||||
const writeStream = fs.createWriteStream(tmpArchivePath);
|
const writeStream = fs.createWriteStream(tmpArchivePath);
|
||||||
@@ -791,10 +794,8 @@ async function downloadLinuxServer() {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.ensureDir("factorio");
|
|
||||||
await fs.rename(tmpFactorioDir, factorioDir);
|
|
||||||
await fs.unlink(archivePath);
|
await fs.unlink(archivePath);
|
||||||
await fs.rm(tmpDir);
|
await fs.rename(tmpFactorioDir, factorioDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,10 +821,6 @@ async function main() {
|
|||||||
.option("http-port", {
|
.option("http-port", {
|
||||||
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
|
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
|
||||||
})
|
})
|
||||||
.option("download-headless", {
|
|
||||||
describe: "Download the latest headless release [standalone/controller] [linux only]",
|
|
||||||
type: "boolean", nargs: 0,
|
|
||||||
})
|
|
||||||
.option("host-name", {
|
.option("host-name", {
|
||||||
nargs: 1, describe: "Host name [host]", type: "string",
|
nargs: 1, describe: "Host name [host]", type: "string",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,11 +13,11 @@
|
|||||||
"factorio"
|
"factorio"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "catalog:",
|
"chalk": "^4.1.2",
|
||||||
"fs-extra": "catalog:",
|
"fs-extra": "^11.3.0",
|
||||||
"inquirer": "^8.2.4",
|
"inquirer": "^8.2.4",
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,18 +15,6 @@
|
|||||||
"node": "__node_version__"
|
"node": "__node_version__"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
//%if controller
|
|
||||||
"@clusterio/controller": "^__clusterio_version__",
|
|
||||||
//%endif
|
|
||||||
//%if webpack
|
|
||||||
"@clusterio/web_ui": "^__clusterio_version__",
|
|
||||||
//%endif
|
|
||||||
//%if host
|
|
||||||
"@clusterio/host": "^__clusterio_version__",
|
|
||||||
//%endif
|
|
||||||
//%if ctl
|
|
||||||
"@clusterio/ctl": "^__clusterio_version__",
|
|
||||||
//%endif
|
|
||||||
"@clusterio/lib": "^__clusterio_version__"
|
"@clusterio/lib": "^__clusterio_version__"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -48,18 +36,7 @@
|
|||||||
"webpack": "^5.88.2",
|
"webpack": "^5.88.2",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^5.1.4",
|
||||||
"webpack-merge": "^5.9.0",
|
"webpack-merge": "^5.9.0",
|
||||||
//%endif
|
|
||||||
//%if controller
|
|
||||||
"@clusterio/controller": "^__clusterio_version__",
|
|
||||||
//%endif
|
|
||||||
//%if webpack
|
|
||||||
"@clusterio/web_ui": "^__clusterio_version__",
|
"@clusterio/web_ui": "^__clusterio_version__",
|
||||||
//%endif
|
|
||||||
//%if host
|
|
||||||
"@clusterio/host": "^__clusterio_version__",
|
|
||||||
//%endif
|
|
||||||
//%if ctl
|
|
||||||
"@clusterio/ctl": "^__clusterio_version__",
|
|
||||||
//%endif
|
//%endif
|
||||||
"@clusterio/lib": "^__clusterio_version__"
|
"@clusterio/lib": "^__clusterio_version__"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-9
@@ -189,7 +189,6 @@ export async function initialize(
|
|||||||
default: "plugin-list.json",
|
default: "plugin-list.json",
|
||||||
type: "string",
|
type: "string",
|
||||||
})
|
})
|
||||||
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
|
|
||||||
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
||||||
.command("control-config", "Manage Control config", lib.configCommand)
|
.command("control-config", "Manage Control config", lib.configCommand)
|
||||||
.wrap(parser.terminalWidth())
|
.wrap(parser.terminalWidth())
|
||||||
@@ -235,14 +234,8 @@ export async function initialize(
|
|||||||
.parse(argv) as CtlArguments
|
.parse(argv) as CtlArguments
|
||||||
;
|
;
|
||||||
|
|
||||||
const controlConfigPath = args.config;
|
|
||||||
const controlConfigLockPath = `${controlConfigPath}.lock`;
|
|
||||||
if (args.bypassLockFile) {
|
|
||||||
await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist
|
|
||||||
}
|
|
||||||
|
|
||||||
let controlConfig;
|
let controlConfig;
|
||||||
const controlConfigLock = new lib.LockFile(controlConfigLockPath);
|
const controlConfigPath = args.config;
|
||||||
logger.verbose(`Loading config from ${controlConfigPath}`);
|
logger.verbose(`Loading config from ${controlConfigPath}`);
|
||||||
try {
|
try {
|
||||||
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
|
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
|
||||||
@@ -264,7 +257,7 @@ export async function initialize(
|
|||||||
|
|
||||||
// Handle the control-config command before trying to connect.
|
// Handle the control-config command before trying to connect.
|
||||||
if (args._[0] === "control-config") {
|
if (args._[0] === "control-config") {
|
||||||
await lib.handleConfigCommand(args, controlConfig, controlConfigLock);
|
await lib.handleConfigCommand(args, controlConfig);
|
||||||
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
|
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,25 +17,25 @@
|
|||||||
"factorio"
|
"factorio"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clusterio/lib": "workspace:*",
|
"@clusterio/lib": "workspace:*",
|
||||||
"set-blocking": "catalog:",
|
|
||||||
"fs-extra": "catalog:",
|
|
||||||
"winston": "catalog:",
|
|
||||||
"phin": "catalog:",
|
|
||||||
"as-table": "^1.0.55",
|
"as-table": "^1.0.55",
|
||||||
|
"fs-extra": "^11.3.0",
|
||||||
|
"phin": "^3.7.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"winston": "^3.17.0",
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/fs-extra": "catalog:",
|
"@types/fs-extra": "^11.0.1",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "^20.17.19",
|
||||||
"@types/set-blocking": "catalog:",
|
"@types/set-blocking": "^2.0.0",
|
||||||
"@types/yargs": "^17.0.33",
|
"@types/yargs": "^17.0.33",
|
||||||
"typescript": "catalog:"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-12
@@ -113,7 +113,6 @@ async function startHost() {
|
|||||||
default: "plugin-list.json",
|
default: "plugin-list.json",
|
||||||
type: "string",
|
type: "string",
|
||||||
})
|
})
|
||||||
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
|
|
||||||
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
.command("plugin", "Manage available plugins", lib.pluginCommand)
|
||||||
.command("config", "Manage Host config", lib.configCommand)
|
.command("config", "Manage Host config", lib.configCommand)
|
||||||
.command("run", "Run host", yargs => {
|
.command("run", "Run host", yargs => {
|
||||||
@@ -168,14 +167,8 @@ async function startHost() {
|
|||||||
lib.registerPluginMessages(pluginInfos);
|
lib.registerPluginMessages(pluginInfos);
|
||||||
lib.addPluginConfigFields(pluginInfos);
|
lib.addPluginConfigFields(pluginInfos);
|
||||||
|
|
||||||
const hostConfigPath = args.config;
|
|
||||||
const hostConfigLockPath = `${hostConfigPath}.lock`;
|
|
||||||
if (args.bypassLockFile) {
|
|
||||||
await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist
|
|
||||||
}
|
|
||||||
|
|
||||||
let hostConfig;
|
let hostConfig;
|
||||||
const hostConfigLock = new lib.LockFile(hostConfigLockPath);
|
const hostConfigPath = args.config;
|
||||||
logger.info(`Loading config from ${hostConfigPath}`);
|
logger.info(`Loading config from ${hostConfigPath}`);
|
||||||
try {
|
try {
|
||||||
hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath);
|
hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath);
|
||||||
@@ -193,13 +186,10 @@ async function startHost() {
|
|||||||
hostConfig.set("host.version", version); // Allows tracking last loaded version
|
hostConfig.set("host.version", version); // Allows tracking last loaded version
|
||||||
|
|
||||||
if (command === "config") {
|
if (command === "config") {
|
||||||
await lib.handleConfigCommand(args, hostConfig, hostConfigLock);
|
await lib.handleConfigCommand(args, hostConfig);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// From this point on the host should run
|
|
||||||
await hostConfigLock.acquire();
|
|
||||||
|
|
||||||
// If we get here the command was run
|
// If we get here the command was run
|
||||||
await fs.ensureDir(hostConfig.get("host.instances_directory"));
|
await fs.ensureDir(hostConfig.get("host.instances_directory"));
|
||||||
await fs.ensureDir(hostConfig.get("host.mods_directory"));
|
await fs.ensureDir(hostConfig.get("host.mods_directory"));
|
||||||
|
|||||||
+12
-12
@@ -18,33 +18,33 @@
|
|||||||
"factorio"
|
"factorio"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clusterio/lib": "workspace:*",
|
"@clusterio/lib": "workspace:*",
|
||||||
"@sinclair/typebox": "catalog:",
|
"@sinclair/typebox": "^0.30.4",
|
||||||
"set-blocking": "catalog:",
|
"fs-extra": "^11.3.0",
|
||||||
"fs-extra": "catalog:",
|
|
||||||
"jszip": "catalog:",
|
|
||||||
"phin": "catalog:",
|
|
||||||
"winston": "catalog:",
|
|
||||||
"winston-daily-rotate-file": "catalog:",
|
|
||||||
"jimp": "^0.22.8",
|
"jimp": "^0.22.8",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"phin": "^3.7.0",
|
||||||
"pidusage": "^3.0.2",
|
"pidusage": "^3.0.2",
|
||||||
"rcon-client": "^4.2.5",
|
"rcon-client": "^4.2.5",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"winston": "^3.17.0",
|
||||||
|
"winston-daily-rotate-file": "^4.7.1",
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "catalog:",
|
"@types/fs-extra": "^11.0.1",
|
||||||
"@types/fs-extra": "catalog:",
|
"@types/node": "^20.17.19",
|
||||||
"@types/set-blocking": "catalog:",
|
|
||||||
"@types/pidusage": "^2.0.2",
|
"@types/pidusage": "^2.0.2",
|
||||||
"@types/semver": "^7.5.0",
|
"@types/semver": "^7.5.0",
|
||||||
|
"@types/set-blocking": "^2.0.0",
|
||||||
"@types/yargs": "^17.0.33",
|
"@types/yargs": "^17.0.33",
|
||||||
"typescript": "catalog:"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,6 @@ import * as lib from "@clusterio/lib";
|
|||||||
import { logger } from "@clusterio/lib";
|
import { logger } from "@clusterio/lib";
|
||||||
|
|
||||||
import type { HostConnector } from "../host";
|
import type { HostConnector } from "../host";
|
||||||
import FactorioProxy from "./FactorioProxy";
|
|
||||||
import Instance from "./Instance";
|
import Instance from "./Instance";
|
||||||
import InstanceConnection from "./InstanceConnection";
|
import InstanceConnection from "./InstanceConnection";
|
||||||
import BaseHostPlugin from "./BaseHostPlugin";
|
import BaseHostPlugin from "./BaseHostPlugin";
|
||||||
@@ -267,7 +266,6 @@ export default class Host extends lib.Link {
|
|||||||
instanceConnections = new Map<number, InstanceConnection>();
|
instanceConnections = new Map<number, InstanceConnection>();
|
||||||
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
||||||
instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
||||||
factorioProxies = new Map<number, FactorioProxy>();
|
|
||||||
|
|
||||||
adminlist = new Set<string>();
|
adminlist = new Set<string>();
|
||||||
banlist = new Map<string, string>();
|
banlist = new Map<string, string>();
|
||||||
@@ -279,7 +277,6 @@ export default class Host extends lib.Link {
|
|||||||
_startup = true;
|
_startup = true;
|
||||||
_disconnecting = false;
|
_disconnecting = false;
|
||||||
_shuttingDown = false;
|
_shuttingDown = false;
|
||||||
_proxyStartPromises = new Map<number, Promise<void>>();
|
|
||||||
|
|
||||||
static instanceConfigWarning = {
|
static instanceConfigWarning = {
|
||||||
"_warning": "Changes to this file will be overwritten by the controller's copy.",
|
"_warning": "Changes to this file will be overwritten by the controller's copy.",
|
||||||
@@ -693,9 +690,7 @@ export default class Host extends lib.Link {
|
|||||||
new lib.InstanceStatusChangedEvent(
|
new lib.InstanceStatusChangedEvent(
|
||||||
instanceId,
|
instanceId,
|
||||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||||
instanceConnection
|
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||||
? instanceConnection.instance.server.gamePort
|
|
||||||
: this.publicGamePort(instanceId, instanceInfo),
|
|
||||||
instanceConnection
|
instanceConnection
|
||||||
? instanceConnection.instance.server.version
|
? instanceConnection.instance.server.version
|
||||||
// TODO: factorio.version is not validated
|
// TODO: factorio.version is not validated
|
||||||
@@ -706,8 +701,6 @@ export default class Host extends lib.Link {
|
|||||||
// Send the new list of saves for this assigned instance to the controller.
|
// Send the new list of saves for this assigned instance to the controller.
|
||||||
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
||||||
|
|
||||||
await this._ensureFactorioProxy(instanceId, instanceInfo);
|
|
||||||
|
|
||||||
// save a copy of the instance config
|
// save a copy of the instance config
|
||||||
await instanceInfo.config.save();
|
await instanceInfo.config.save();
|
||||||
}
|
}
|
||||||
@@ -722,10 +715,6 @@ export default class Host extends lib.Link {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.instanceInfos.delete(instanceId);
|
this.instanceInfos.delete(instanceId);
|
||||||
if (this.factorioProxies.has(instanceId)) {
|
|
||||||
await this.factorioProxies.get(instanceId)!.stop();
|
|
||||||
this.factorioProxies.delete(instanceId);
|
|
||||||
}
|
|
||||||
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
|
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -746,23 +735,6 @@ export default class Host extends lib.Link {
|
|||||||
return instanceInfo;
|
return instanceInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieved public port for clients to connect to for the given instance.
|
|
||||||
* @param instanceId - ID of the instance to get the port for.
|
|
||||||
* @returns Game port for the instance.
|
|
||||||
*/
|
|
||||||
publicGamePort(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) {
|
|
||||||
instanceInfo = instanceInfo ?? this.discoveredInstanceInfos.get(instanceId);
|
|
||||||
if (!instanceInfo) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
instanceInfo.config.get("factorio.game_port")
|
|
||||||
?? instanceInfo.config.get("factorio.host_assigned_game_port")
|
|
||||||
?? undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieved assigned port of the given instance
|
* Retrieved assigned port of the given instance
|
||||||
* @param instanceId - ID of the instance to get the assigned game port of:
|
* @param instanceId - ID of the instance to get the assigned game port of:
|
||||||
@@ -780,18 +752,6 @@ export default class Host extends lib.Link {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureGamePortAssigned(instanceId: number, instanceInfo: { config: lib.InstanceConfig }) {
|
|
||||||
const configuredPort = instanceInfo.config.get("factorio.game_port");
|
|
||||||
const assignedPort = instanceInfo.config.get("factorio.host_assigned_game_port");
|
|
||||||
if (configuredPort !== null) {
|
|
||||||
return configuredPort;
|
|
||||||
}
|
|
||||||
if (assignedPort !== null) {
|
|
||||||
return assignedPort;
|
|
||||||
}
|
|
||||||
return this.assignGamePort(instanceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
assignGamePort(instanceId: number) {
|
assignGamePort(instanceId: number) {
|
||||||
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
|
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
|
||||||
for (const [id, instance] of this.instanceInfos) {
|
for (const [id, instance] of this.instanceInfos) {
|
||||||
@@ -819,42 +779,6 @@ export default class Host extends lib.Link {
|
|||||||
return newPort;
|
return newPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
async startInstanceFromProxy(instanceId: number) {
|
|
||||||
const existing = this._proxyStartPromises.get(instanceId);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._shuttingDown || this.recoveryMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = (async () => {
|
|
||||||
let instanceConnection = this.instanceConnections.get(instanceId);
|
|
||||||
if (instanceConnection && instanceConnection.instance.status === "running") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!instanceConnection) {
|
|
||||||
instanceConnection = await this._connectInstance(instanceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instanceConnection.instance.status !== "running") {
|
|
||||||
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
|
|
||||||
}
|
|
||||||
})().catch(err => {
|
|
||||||
logger.error(
|
|
||||||
`Error starting instance from proxy:\n${err.stack ?? err.message ?? err}`,
|
|
||||||
this.instanceLogMeta(instanceId)
|
|
||||||
);
|
|
||||||
}).finally(() => {
|
|
||||||
this._proxyStartPromises.delete(instanceId);
|
|
||||||
});
|
|
||||||
|
|
||||||
this._proxyStartPromises.set(instanceId, promise);
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize and connect an unloaded instance
|
* Initialize and connect an unloaded instance
|
||||||
*
|
*
|
||||||
@@ -1222,9 +1146,7 @@ export default class Host extends lib.Link {
|
|||||||
list.push(new lib.HostInstanceUpdate(
|
list.push(new lib.HostInstanceUpdate(
|
||||||
instanceInfo.config.toRemote("controller"),
|
instanceInfo.config.toRemote("controller"),
|
||||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||||
instanceConnection
|
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||||
? instanceConnection.instance.server.gamePort
|
|
||||||
: this.publicGamePort(instanceId, instanceInfo),
|
|
||||||
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1252,7 +1174,6 @@ export default class Host extends lib.Link {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareDisconnect() {
|
async prepareDisconnect() {
|
||||||
@@ -1283,15 +1204,6 @@ export default class Host extends lib.Link {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const proxy of this.factorioProxies.values()) {
|
|
||||||
try {
|
|
||||||
await proxy.stop();
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.error(`Unexpected error stopping proxy:\n${err.stack ?? err.message ?? err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.factorioProxies.clear();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.connector.disconnect();
|
await this.connector.disconnect();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -398,13 +398,6 @@ export default class Instance extends lib.Link {
|
|||||||
stats,
|
stats,
|
||||||
};
|
};
|
||||||
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats));
|
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats));
|
||||||
const hostId = this.config.get("instance.assigned_host");
|
|
||||||
if (hostId !== null) {
|
|
||||||
this.sendTo(
|
|
||||||
"controller",
|
|
||||||
new lib.InstancePlayerRouteUpdateEvent(name, this.id, hostId, this.server.gamePort, Date.now()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
lib.invokeHook(this.plugins, "onPlayerEvent", event);
|
lib.invokeHook(this.plugins, "onPlayerEvent", event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,13 +548,12 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
|
|
||||||
notifyStatus(status: Instance["_status"]) {
|
notifyStatus(status: Instance["_status"]) {
|
||||||
this._status = status;
|
this._status = status;
|
||||||
const publicPort = this.server.gamePort;
|
|
||||||
this.sendTo(
|
this.sendTo(
|
||||||
"controller",
|
"controller",
|
||||||
new lib.InstanceStatusChangedEvent(
|
new lib.InstanceStatusChangedEvent(
|
||||||
this.id,
|
this.id,
|
||||||
status,
|
status,
|
||||||
publicPort,
|
this.server.gamePort,
|
||||||
// TODO: factorio.version is not validated
|
// TODO: factorio.version is not validated
|
||||||
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
|
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
|
||||||
),
|
),
|
||||||
@@ -970,15 +962,10 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
this._loadedSave = saveName;
|
this._loadedSave = saveName;
|
||||||
await this.server.start(saveName);
|
await this.server.start(saveName);
|
||||||
|
|
||||||
if (this.config.get("factorio.enable_script_commands")) {
|
if (this.config.get("factorio.enable_save_patching") && this.config.get("factorio.enable_script_commands")) {
|
||||||
await this.server.disableAchievements();
|
await this.server.disableAchievements();
|
||||||
}
|
await this.updateInstanceData();
|
||||||
|
|
||||||
if (this.config.get("factorio.enable_save_patching")) {
|
|
||||||
this._watchPlayerPromote();
|
this._watchPlayerPromote();
|
||||||
if (this.config.get("factorio.enable_script_commands")) {
|
|
||||||
await this.updateInstanceData();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this._watchServerLogActions();
|
this._watchServerLogActions();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) {
|
|||||||
* @returns Array with path to data dir and version found.
|
* @returns Array with path to data dir and version found.
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> {
|
async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion) {
|
||||||
|
|
||||||
// There are two supported setups: having the factorio dir be the actual
|
// There are two supported setups: having the factorio dir be the actual
|
||||||
// install directory, and having the factorio dir be a folder containing
|
// install directory, and having the factorio dir be a folder containing
|
||||||
@@ -87,10 +87,8 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion
|
|||||||
|
|
||||||
let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt"));
|
let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt"));
|
||||||
if (simpleVersion !== null) {
|
if (simpleVersion !== null) {
|
||||||
if (simpleVersion === targetVersion || simpleVersion.startsWith(targetVersion) || targetVersion === "latest") {
|
if (simpleVersion === targetVersion || targetVersion === "latest") {
|
||||||
if (lib.isFullVersion(simpleVersion)) {
|
return [path.join(factorioDir, "data"), simpleVersion];
|
||||||
return [path.join(factorioDir, "data"), simpleVersion];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -98,14 +96,14 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let versions = new Map<lib.FullVersion, string>();
|
let versions = new Map();
|
||||||
for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) {
|
for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory()) {
|
if (!entry.isDirectory()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt"));
|
let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt"));
|
||||||
if (version === null || !lib.isFullVersion(version)) {
|
if (version === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,17 +118,9 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion
|
|||||||
throw new Error(`Unable to find any Factorio install in ${factorioDir}`);
|
throw new Error(`Unable to find any Factorio install in ${factorioDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lib.isPartialVersion(targetVersion)) {
|
|
||||||
const sorted = [...versions.keys()].sort(versionOrder);
|
|
||||||
const latest = sorted.find(version => version.startsWith(targetVersion));
|
|
||||||
if (latest) {
|
|
||||||
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetVersion === "latest") {
|
if (targetVersion === "latest") {
|
||||||
const latest = [...versions.keys()].sort(versionOrder)[0];
|
let latest = [...versions.keys()].sort(versionOrder)[0];
|
||||||
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
|
return [path.join(factorioDir, versions.get(latest), "data"), latest];
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unable to find Factorio version ${targetVersion}`);
|
throw new Error(`Unable to find Factorio version ${targetVersion}`);
|
||||||
@@ -1363,40 +1353,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
return this.dataPath("..", "MacOS", "factorio");
|
return this.dataPath("..", "MacOS", "factorio");
|
||||||
}
|
}
|
||||||
const binDir = this.dataPath("..", "bin", "x64");
|
return this.dataPath("..", "bin", "x64", "factorio");
|
||||||
const runPath = path.join(binDir, "factorio-run");
|
|
||||||
if (fs.existsSync(runPath)) {
|
|
||||||
return runPath;
|
|
||||||
}
|
|
||||||
const runPathExe = `${runPath}.exe`;
|
|
||||||
if (fs.existsSync(runPathExe)) {
|
|
||||||
return runPathExe;
|
|
||||||
}
|
|
||||||
const factorioPath = path.join(binDir, "factorio");
|
|
||||||
if (fs.existsSync(factorioPath)) {
|
|
||||||
return factorioPath;
|
|
||||||
}
|
|
||||||
const factorioPathExe = `${factorioPath}.exe`;
|
|
||||||
if (fs.existsSync(factorioPathExe)) {
|
|
||||||
return factorioPathExe;
|
|
||||||
}
|
|
||||||
const directRunPath = path.join(this._factorioDir, "factorio-run");
|
|
||||||
if (fs.existsSync(directRunPath)) {
|
|
||||||
return directRunPath;
|
|
||||||
}
|
|
||||||
const directRunPathExe = `${directRunPath}.exe`;
|
|
||||||
if (fs.existsSync(directRunPathExe)) {
|
|
||||||
return directRunPathExe;
|
|
||||||
}
|
|
||||||
const directFactorioPath = path.join(this._factorioDir, "factorio");
|
|
||||||
if (fs.existsSync(directFactorioPath)) {
|
|
||||||
return directFactorioPath;
|
|
||||||
}
|
|
||||||
const directFactorioPathExe = `${directFactorioPath}.exe`;
|
|
||||||
if (fs.existsSync(directFactorioPathExe)) {
|
|
||||||
return directFactorioPathExe;
|
|
||||||
}
|
|
||||||
return factorioPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,4 +15,3 @@ export * from "./src/subscriptions";
|
|||||||
|
|
||||||
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
|
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
|
||||||
export { default as RateLimiter } from "./src/RateLimiter";
|
export { default as RateLimiter } from "./src/RateLimiter";
|
||||||
export { default as ValueCache } from "./src/ValueCache";
|
|
||||||
@@ -9,7 +9,6 @@ export * from "./src/config";
|
|||||||
export * from "./src/data";
|
export * from "./src/data";
|
||||||
export * from "./src/database";
|
export * from "./src/database";
|
||||||
export * from "./src/errors";
|
export * from "./src/errors";
|
||||||
export * from "./src/external";
|
|
||||||
export * from "./src/factorio";
|
export * from "./src/factorio";
|
||||||
export * from "./src/file_ops";
|
export * from "./src/file_ops";
|
||||||
export * from "./src/hash";
|
export * from "./src/hash";
|
||||||
@@ -32,9 +31,7 @@ export * from "./src/subscriptions";
|
|||||||
export * from "./src/datastore";
|
export * from "./src/datastore";
|
||||||
export * from "./src/load_plugin_list";
|
export * from "./src/load_plugin_list";
|
||||||
export * from "./src/rce_ops";
|
export * from "./src/rce_ops";
|
||||||
export * from "./src/LockFile";
|
|
||||||
|
|
||||||
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
|
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
|
||||||
export { default as ModStore } from "./src/ModStore";
|
export { default as ModStore } from "./src/ModStore";
|
||||||
export { default as RateLimiter } from "./src/RateLimiter";
|
export { default as RateLimiter } from "./src/RateLimiter";
|
||||||
export { default as ValueCache } from "./src/ValueCache";
|
|
||||||
+15
-15
@@ -18,30 +18,30 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "catalog:",
|
"@sinclair/typebox": "^0.30.4",
|
||||||
"set-blocking": "catalog:",
|
|
||||||
"fs-extra": "catalog:",
|
|
||||||
"jszip": "catalog:",
|
|
||||||
"chalk": "catalog:",
|
|
||||||
"ws": "catalog:",
|
|
||||||
"winston": "catalog:",
|
|
||||||
"winston-transport": "catalog:",
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"triple-beam": "^1.4.1",
|
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fs-extra": "^11.3.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"klaw": "^4.1.0",
|
"klaw": "^4.1.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"triple-beam": "^1.4.1",
|
||||||
|
"winston": "^3.17.0",
|
||||||
|
"winston-transport": "^4.9.0",
|
||||||
|
"ws": "^8.18.1",
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/fs-extra": "catalog:",
|
"@types/fs-extra": "^11.0.1",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "^20.17.19",
|
||||||
"@types/ws": "catalog:",
|
"@types/set-blocking": "^2.0.0",
|
||||||
"@types/set-blocking": "catalog:",
|
|
||||||
"@types/triple-beam": "^1.3.2",
|
"@types/triple-beam": "^1.3.2",
|
||||||
|
"@types/ws": "^8.5.14",
|
||||||
"@types/yargs": "^17.0.33",
|
"@types/yargs": "^17.0.33",
|
||||||
"typescript": "catalog:"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import { FileHandle, open, unlink, readFile } from "fs/promises";
|
|
||||||
import { unlinkSync } from "fs";
|
|
||||||
import assert from "assert";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error thrown when the lock file already exists.
|
|
||||||
*/
|
|
||||||
export class LockFileExistsError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly filePath: string
|
|
||||||
) {
|
|
||||||
super(`Cannot acquire lock, a valid lock file exists at ${filePath}`);
|
|
||||||
this.name = "LockFileExistsError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error thrown when the acquire or release called during an invalid state.
|
|
||||||
*/
|
|
||||||
export class LockFileBusyError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = "LockFileBusyError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type LockState = "idle" | "acquiring" | "acquired" | "releasing";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-platform lock file utility for Node.js.
|
|
||||||
*/
|
|
||||||
export class LockFile {
|
|
||||||
private handle: FileHandle | null = null;
|
|
||||||
private state: LockState = "idle";
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly filePath: string
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acquire the lock file.
|
|
||||||
* Throws LockFileExistsError if already locked.
|
|
||||||
* Throws LockFileBusyError if not in idle state.
|
|
||||||
*/
|
|
||||||
async acquire(): Promise<void> {
|
|
||||||
if (this.state !== "idle") {
|
|
||||||
throw new LockFileBusyError(`Cannot acquire lock, current state is "${this.state}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state = "acquiring";
|
|
||||||
|
|
||||||
if (await this.isStale()) {
|
|
||||||
await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.handle = await open(this.filePath, "wx");
|
|
||||||
await this.handle.write(`${process.pid}\n`);
|
|
||||||
this.attachExitHandlers();
|
|
||||||
this.state = "acquired";
|
|
||||||
} catch (err: any) {
|
|
||||||
this.state = "idle";
|
|
||||||
if (err?.code === "EEXIST") {
|
|
||||||
throw new LockFileExistsError(this.filePath);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Release the lock file.
|
|
||||||
* Throws LockFileBusyError if not in acquired state.
|
|
||||||
*/
|
|
||||||
async release(): Promise<void> {
|
|
||||||
if (this.state !== "acquired") {
|
|
||||||
throw new LockFileBusyError(`Cannot release lock, current state is "${this.state}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state = "releasing";
|
|
||||||
this.detachExitHandlers();
|
|
||||||
|
|
||||||
// Ignored: file may have already been removed by another process
|
|
||||||
try { await this.handle?.close(); } catch {}
|
|
||||||
try { await unlink(this.filePath); } catch {}
|
|
||||||
|
|
||||||
this.handle = null;
|
|
||||||
this.state = "idle";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether the lock is currently held by this process.
|
|
||||||
* True only if the lock is acquired and the PID in the file
|
|
||||||
* matches the current process.
|
|
||||||
*/
|
|
||||||
async isHeld(): Promise<boolean> {
|
|
||||||
if (this.state !== "acquired") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pidInLock = Number(await readFile(this.filePath, "utf8"));
|
|
||||||
return pidInLock === process.pid;
|
|
||||||
} catch {
|
|
||||||
// Ignored: file may not exist or be unreadable, so lock is not held
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether an existing lock is stale.
|
|
||||||
*/
|
|
||||||
async isStale(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const pidInLock = Number(await readFile(this.filePath, "utf8"));
|
|
||||||
if (!Number.isFinite(pidInLock)) { return true; }
|
|
||||||
// signal 0 is a special case to test if a process exists
|
|
||||||
process.kill(pidInLock, 0);
|
|
||||||
return false;
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach process handlers, called when transitioning to "acquired"
|
|
||||||
*/
|
|
||||||
private attachExitHandlers(): void {
|
|
||||||
assert(this.state === "acquiring");
|
|
||||||
process.on("SIGINT", this.handleExit);
|
|
||||||
process.on("SIGTERM", this.handleExit);
|
|
||||||
process.on("SIGHUP", this.handleExit);
|
|
||||||
process.on("uncaughtException", this.handleExit);
|
|
||||||
// eslint-disable-next-line node/no-sync
|
|
||||||
process.on("exit", this.handleExitSync);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detach process handlers, called when transitioning to "idle"
|
|
||||||
*/
|
|
||||||
private async detachExitHandlers(): Promise<void> {
|
|
||||||
assert(this.state === "releasing");
|
|
||||||
process.off("SIGINT", this.handleExit);
|
|
||||||
process.off("SIGTERM", this.handleExit);
|
|
||||||
process.off("SIGHUP", this.handleExit);
|
|
||||||
process.off("uncaughtException", this.handleExit);
|
|
||||||
// eslint-disable-next-line node/no-sync
|
|
||||||
process.off("exit", this.handleExitSync);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event handler for async signals / exceptions
|
|
||||||
private handleExit = async (): Promise<void> => {
|
|
||||||
await this.release();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Event handler for synchronous process exit
|
|
||||||
private handleExitSync = (): void => {
|
|
||||||
try {
|
|
||||||
unlinkSync(this.filePath);
|
|
||||||
} catch (err) {
|
|
||||||
// Ignored: file may have already been removed by another process
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
|
|
||||||
/**
|
|
||||||
* Represents an async function which can fetch a value for a ValueCache
|
|
||||||
*/
|
|
||||||
export interface ValueCacheFetch<T> {
|
|
||||||
(): Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory single value cache
|
|
||||||
*
|
|
||||||
* Stores the last time a value was fetched and measures staleness
|
|
||||||
* Concurrent calls will share the same in-flight fetch
|
|
||||||
*/
|
|
||||||
export default class ValueCache<T> {
|
|
||||||
private ongoingFetch: Promise<T> | null = null;
|
|
||||||
private timestamp: number | null = null;
|
|
||||||
private value: T | null = null; // Only valid when timestamp is not null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new in memory single value cache
|
|
||||||
*
|
|
||||||
* @param fetch A function that fetches and returns a fresh value
|
|
||||||
*/
|
|
||||||
constructor(
|
|
||||||
private readonly fetch: ValueCacheFetch<T>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the cached value, will fetch a fresh value if stale or missing
|
|
||||||
*
|
|
||||||
* @param maxAgeMs Maximum allowed cache age in milliseconds.
|
|
||||||
* Defaults to 0, meaning it will always fetch a new value.
|
|
||||||
* @throws TypeError if maxAgeMs is invalid
|
|
||||||
* @throws Propagates errors from underlying fetch method
|
|
||||||
*/
|
|
||||||
get(maxAgeMs: number = 0): Promise<T> {
|
|
||||||
if (maxAgeMs < 0) {
|
|
||||||
throw new TypeError("maxAgeMs must be a non-negative number");
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (this.timestamp && now - this.timestamp < maxAgeMs) {
|
|
||||||
return Promise.resolve(this.value as T);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.ongoingFetch) {
|
|
||||||
return this.ongoingFetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ongoingFetch = this.fetch()
|
|
||||||
.then(value => {
|
|
||||||
this.timestamp = Date.now();
|
|
||||||
this.value = value;
|
|
||||||
return value;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.ongoingFetch = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.ongoingFetch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,8 +24,6 @@ export interface ControllerConfigFields {
|
|||||||
"controller.metrics_timeout": number;
|
"controller.metrics_timeout": number;
|
||||||
"controller.system_metrics_interval": number;
|
"controller.system_metrics_interval": number;
|
||||||
"controller.proxy_stream_timeout": number;
|
"controller.proxy_stream_timeout": number;
|
||||||
"controller.player_proxy_port": number | null;
|
|
||||||
"controller.player_proxy_default_instance_id": number | null;
|
|
||||||
"controller.factorio_username": string | null;
|
"controller.factorio_username": string | null;
|
||||||
"controller.factorio_token": string | null;
|
"controller.factorio_token": string | null;
|
||||||
"controller.share_factorio_credentials_with_hosts": boolean;
|
"controller.share_factorio_credentials_with_hosts": boolean;
|
||||||
@@ -178,20 +176,6 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
|||||||
type: "number",
|
type: "number",
|
||||||
initialValue: 15,
|
initialValue: 15,
|
||||||
},
|
},
|
||||||
"controller.player_proxy_port": {
|
|
||||||
title: "Player Proxy Port",
|
|
||||||
description: "UDP port to accept player connections for the controller proxy, set to null to disable.",
|
|
||||||
restartRequired: true,
|
|
||||||
type: "number",
|
|
||||||
optional: true,
|
|
||||||
initialValue: 34197,
|
|
||||||
},
|
|
||||||
"controller.player_proxy_default_instance_id": {
|
|
||||||
title: "Player Proxy Default Instance",
|
|
||||||
description: "Instance ID to route players to when no routing history is available.",
|
|
||||||
type: "number",
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
"controller.factorio_username": {
|
"controller.factorio_username": {
|
||||||
title: "Factorio Username",
|
title: "Factorio Username",
|
||||||
description: "Username to authenticate with Factorio API with.",
|
description: "Username to authenticate with Factorio API with.",
|
||||||
@@ -423,7 +407,6 @@ export interface InstanceConfigFields {
|
|||||||
"factorio.executable_path": string | null;
|
"factorio.executable_path": string | null;
|
||||||
"factorio.shutdown_timeout": number;
|
"factorio.shutdown_timeout": number;
|
||||||
"factorio.game_port": number | null;
|
"factorio.game_port": number | null;
|
||||||
"factorio.proxy_port": number | null;
|
|
||||||
"factorio.host_assigned_game_port": number | null;
|
"factorio.host_assigned_game_port": number | null;
|
||||||
"factorio.rcon_port": number | null;
|
"factorio.rcon_port": number | null;
|
||||||
"factorio.rcon_password": string | null;
|
"factorio.rcon_password": string | null;
|
||||||
@@ -496,7 +479,6 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
|||||||
restartRequired: true,
|
restartRequired: true,
|
||||||
type: "string",
|
type: "string",
|
||||||
initialValue: "latest",
|
initialValue: "latest",
|
||||||
inputComponent: "target_version",
|
|
||||||
},
|
},
|
||||||
"factorio.executable_path": {
|
"factorio.executable_path": {
|
||||||
description:
|
description:
|
||||||
@@ -520,13 +502,6 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
|||||||
type: "number",
|
type: "number",
|
||||||
optional: true,
|
optional: true,
|
||||||
},
|
},
|
||||||
"factorio.proxy_port": {
|
|
||||||
description:
|
|
||||||
"UDP port to accept client connections on. When set, the host will proxy traffic to " +
|
|
||||||
"the instance game port and start the instance on first packet.",
|
|
||||||
type: "number",
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
"factorio.host_assigned_game_port": {
|
"factorio.host_assigned_game_port": {
|
||||||
access: ["host"],
|
access: ["host"],
|
||||||
type: "number",
|
type: "number",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Type, Static } from "@sinclair/typebox";
|
|||||||
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
|
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
|
||||||
import { levels } from "../logging";
|
import { levels } from "../logging";
|
||||||
import { ControllerConfig, HostConfig } from "../config";
|
import { ControllerConfig, HostConfig } from "../config";
|
||||||
import { ExternalFactorioVersionSchema } from "../external";
|
|
||||||
|
|
||||||
export class ControllerStopRequest {
|
export class ControllerStopRequest {
|
||||||
declare ["constructor"]: typeof ControllerStopRequest;
|
declare ["constructor"]: typeof ControllerStopRequest;
|
||||||
@@ -392,25 +391,3 @@ export class DebugWsMessageEvent {
|
|||||||
return new this(json.direction, json.content);
|
return new this(json.direction, json.content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FactorioVersionsRequest {
|
|
||||||
declare ["constructor"]: typeof FactorioVersionsRequest;
|
|
||||||
static type = "request" as const;
|
|
||||||
static src = "control" as const;
|
|
||||||
static dst = "controller" as const;
|
|
||||||
static permission = "core.external.get_factorio_versions" as const;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes
|
|
||||||
) {}
|
|
||||||
|
|
||||||
static jsonSchema = Type.Object({
|
|
||||||
"maxAgeMs": Type.Number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
|
||||||
return new this(json.maxAgeMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
|
|
||||||
}
|
|
||||||
@@ -976,30 +976,3 @@ export class InstancePlayerUpdateEvent {
|
|||||||
return new this(json.type, json.name, PlayerStats.fromJSON(json.stats), json.reason);
|
return new this(json.type, json.name, PlayerStats.fromJSON(json.stats), json.reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InstancePlayerRouteUpdateEvent {
|
|
||||||
declare ["constructor"]: typeof InstancePlayerRouteUpdateEvent;
|
|
||||||
static type = "event" as const;
|
|
||||||
static src = "instance" as const;
|
|
||||||
static dst = "controller" as const;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public name: string,
|
|
||||||
public instanceId: number,
|
|
||||||
public hostId: number,
|
|
||||||
public gamePort: number,
|
|
||||||
public timestampMs: number,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
static jsonSchema = Type.Object({
|
|
||||||
"name": Type.String(),
|
|
||||||
"instanceId": Type.Integer(),
|
|
||||||
"hostId": Type.Integer(),
|
|
||||||
"gamePort": Type.Integer(),
|
|
||||||
"timestampMs": Type.Number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
|
||||||
return new this(json.name, json.instanceId, json.hostId, json.gamePort, json.timestampMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-69
@@ -1,69 +0,0 @@
|
|||||||
import { Static, Type } from "@sinclair/typebox";
|
|
||||||
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data";
|
|
||||||
|
|
||||||
const ARCHIVE_URL = "https://factorio.com/download/archive";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents the parsed result of a factorio version
|
|
||||||
*/
|
|
||||||
export const ExternalFactorioVersionSchema = Type.Object({
|
|
||||||
stable: Type.Boolean(),
|
|
||||||
version: FullVersionSchema,
|
|
||||||
headlessUrl: Type.String(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all factorio versions that support headless
|
|
||||||
*
|
|
||||||
* @returns A list of all factorio versions and their download URL
|
|
||||||
* @throws Network errors and non-ok status responses
|
|
||||||
*/
|
|
||||||
export async function fetchFactorioVersions(): Promise<ExternalFactorioVersion[]> {
|
|
||||||
let res;
|
|
||||||
|
|
||||||
try {
|
|
||||||
res = await fetch(ARCHIVE_URL);
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: HTTP ${res.status} ${res.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = await res.text();
|
|
||||||
return parseFactorioVersions(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse all versions from the factorio archive webpage
|
|
||||||
*
|
|
||||||
* @param html The HTML content of the archive page
|
|
||||||
* @returns A list of all factorio versions and their download URL
|
|
||||||
*/
|
|
||||||
function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
|
|
||||||
const versions = new Map<string, ExternalFactorioVersion>();
|
|
||||||
|
|
||||||
// Match <a class=" slot-button-inline version-button-stable " href="/download/archive/2.0.72">2.0.72</a>
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+.\d+)"[^>]*>/g;
|
|
||||||
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
const minimumMultiplayerVersion = integerFullVersion("0.12.35");
|
|
||||||
while ((match = anchorRegex.exec(html)) !== null) {
|
|
||||||
const kind = match.groups?.kind;
|
|
||||||
const version = match.groups?.version;
|
|
||||||
|
|
||||||
if (kind && version && isFullVersion(version) && integerFullVersion(version) >= minimumMultiplayerVersion) {
|
|
||||||
versions.set(version, {
|
|
||||||
version: version,
|
|
||||||
stable: kind === "stable",
|
|
||||||
headlessUrl: `www.factorio.com/get-download/${version}/headless/linux64`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...versions.values()];
|
|
||||||
}
|
|
||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
/**
|
|
||||||
* Library for interfacing with external apis
|
|
||||||
* @module lib/external
|
|
||||||
*/
|
|
||||||
export * from "./FactorioVersions";
|
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import zlib from "zlib";
|
import zlib from "zlib";
|
||||||
|
|
||||||
|
type FactorioVersion = [number, number, number, number];
|
||||||
|
|
||||||
class MapReaderState {
|
class MapReaderState {
|
||||||
pos = 0;
|
pos = 0;
|
||||||
last_position = { x: 0, y: 0 };
|
last_position = { x: 0, y: 0 };
|
||||||
/** True when a version greater than 2.0.0 is detected */
|
/** True when a version greater than 2.0.0 is detected */
|
||||||
v2 = false;
|
v2 = false;
|
||||||
|
version: FactorioVersion | null = null;
|
||||||
constructor(
|
constructor(
|
||||||
public buf: Buffer
|
public buf: Buffer
|
||||||
) { }
|
) { }
|
||||||
@@ -110,7 +113,7 @@ function readDict<K, V>(
|
|||||||
return mapping;
|
return mapping;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readVersion(state: MapReaderState) {
|
function readVersion(state: MapReaderState): FactorioVersion {
|
||||||
let major = readUInt16(state);
|
let major = readUInt16(state);
|
||||||
let minor = readUInt16(state);
|
let minor = readUInt16(state);
|
||||||
let patch = readUInt16(state);
|
let patch = readUInt16(state);
|
||||||
@@ -118,6 +121,24 @@ function readVersion(state: MapReaderState) {
|
|||||||
return [major, minor, patch, developer];
|
return [major, minor, patch, developer];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVersionAtLeast(state: MapReaderState, major: number, minor: number, patch: number, developer: number) {
|
||||||
|
if (!state.version) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [vMajor, vMinor, vPatch, vDeveloper] = state.version;
|
||||||
|
if (vMajor !== major) {
|
||||||
|
return vMajor > major;
|
||||||
|
}
|
||||||
|
if (vMinor !== minor) {
|
||||||
|
return vMinor > minor;
|
||||||
|
}
|
||||||
|
if (vPatch !== patch) {
|
||||||
|
return vPatch > patch;
|
||||||
|
}
|
||||||
|
return vDeveloper >= developer;
|
||||||
|
}
|
||||||
|
|
||||||
function readFrequencySizeRichness(state: MapReaderState) {
|
function readFrequencySizeRichness(state: MapReaderState) {
|
||||||
return {
|
return {
|
||||||
frequency: readFloat(state),
|
frequency: readFloat(state),
|
||||||
@@ -145,7 +166,7 @@ function readMapPosition(state: MapReaderState) {
|
|||||||
y = state.last_position.y + y_diff;
|
y = state.last_position.y + y_diff;
|
||||||
}
|
}
|
||||||
state.last_position.x = x;
|
state.last_position.x = x;
|
||||||
state.last_position.y = y;
|
state.last_position.x = y;
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +283,8 @@ function readEnemyEvolution(state: MapReaderState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readEnemyExpansion(state: MapReaderState) {
|
function readEnemyExpansion(state: MapReaderState) {
|
||||||
return {
|
let evolutionGroupSizeFactor: number | null | undefined;
|
||||||
|
const settings = {
|
||||||
enabled: readOptional(state, readBool),
|
enabled: readOptional(state, readBool),
|
||||||
max_expansion_distance: readOptional(state, readUInt32),
|
max_expansion_distance: readOptional(state, readUInt32),
|
||||||
friendly_base_influence_radius: readOptional(state, readUInt32),
|
friendly_base_influence_radius: readOptional(state, readUInt32),
|
||||||
@@ -274,9 +296,36 @@ function readEnemyExpansion(state: MapReaderState) {
|
|||||||
max_colliding_tiles_coefficient: readOptional(state, readDouble),
|
max_colliding_tiles_coefficient: readOptional(state, readDouble),
|
||||||
settler_group_min_size: readOptional(state, readUInt32),
|
settler_group_min_size: readOptional(state, readUInt32),
|
||||||
settler_group_max_size: readOptional(state, readUInt32),
|
settler_group_max_size: readOptional(state, readUInt32),
|
||||||
min_expansion_cooldown: readOptional(state, readUInt32),
|
min_expansion_cooldown: null,
|
||||||
max_expansion_cooldown: readOptional(state, readUInt32),
|
max_expansion_cooldown: null,
|
||||||
|
} as {
|
||||||
|
enabled: boolean | null;
|
||||||
|
max_expansion_distance: number | null;
|
||||||
|
friendly_base_influence_radius: number | null;
|
||||||
|
enemy_building_influence_radius: number | null;
|
||||||
|
building_coefficient: number | null;
|
||||||
|
other_base_coefficient: number | null;
|
||||||
|
neighbouring_chunk_coefficient: number | null;
|
||||||
|
neighbouring_base_chunk_coefficient: number | null;
|
||||||
|
max_colliding_tiles_coefficient: number | null;
|
||||||
|
settler_group_min_size: number | null;
|
||||||
|
settler_group_max_size: number | null;
|
||||||
|
min_expansion_cooldown: number | null;
|
||||||
|
max_expansion_cooldown: number | null;
|
||||||
|
evolution_group_size_factor?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isVersionAtLeast(state, 2, 1, 0, 0)) {
|
||||||
|
evolutionGroupSizeFactor = readOptional(state, readDouble);
|
||||||
|
}
|
||||||
|
settings.min_expansion_cooldown = readOptional(state, readUInt32);
|
||||||
|
settings.max_expansion_cooldown = readOptional(state, readUInt32);
|
||||||
|
|
||||||
|
if (evolutionGroupSizeFactor !== undefined) {
|
||||||
|
settings.evolution_group_size_factor = evolutionGroupSizeFactor ?? 4.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readUnitGroup(state: MapReaderState) {
|
function readUnitGroup(state: MapReaderState) {
|
||||||
@@ -427,6 +476,7 @@ export function readMapExchangeString(exchangeString: string) {
|
|||||||
try {
|
try {
|
||||||
const version = readVersion(state);
|
const version = readVersion(state);
|
||||||
state.v2 = version[0] >= 2;
|
state.v2 = version[0] >= 2;
|
||||||
|
state.version = version;
|
||||||
data = {
|
data = {
|
||||||
version: version,
|
version: version,
|
||||||
unknown: readUInt8(state),
|
unknown: readUInt8(state),
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
|||||||
controller.SystemInfoUpdateEvent,
|
controller.SystemInfoUpdateEvent,
|
||||||
controller.DebugDumpWsRequest,
|
controller.DebugDumpWsRequest,
|
||||||
controller.DebugWsMessageEvent,
|
controller.DebugWsMessageEvent,
|
||||||
controller.FactorioVersionsRequest,
|
|
||||||
|
|
||||||
subscriptions.SubscriptionRequest,
|
subscriptions.SubscriptionRequest,
|
||||||
|
|
||||||
@@ -93,7 +92,6 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
|||||||
instance.InstanceAdminlistUpdateEvent,
|
instance.InstanceAdminlistUpdateEvent,
|
||||||
instance.InstanceWhitelistUpdateEvent,
|
instance.InstanceWhitelistUpdateEvent,
|
||||||
instance.InstancePlayerUpdateEvent,
|
instance.InstancePlayerUpdateEvent,
|
||||||
instance.InstancePlayerRouteUpdateEvent,
|
|
||||||
|
|
||||||
mod.ModPackGetRequest,
|
mod.ModPackGetRequest,
|
||||||
mod.ModPackGetDefaultRequest,
|
mod.ModPackGetDefaultRequest,
|
||||||
|
|||||||
@@ -568,9 +568,3 @@ definePermission({
|
|||||||
title: "Dump WebSocket",
|
title: "Dump WebSocket",
|
||||||
description: "Dump all WebSocket communicatation from the controller.",
|
description: "Dump all WebSocket communicatation from the controller.",
|
||||||
});
|
});
|
||||||
|
|
||||||
definePermission({
|
|
||||||
name: "core.external.get_factorio_versions",
|
|
||||||
title: "Get factorio versions",
|
|
||||||
description: "Get list of all factorio versions from Wube",
|
|
||||||
});
|
|
||||||
@@ -275,6 +275,9 @@ function keyToLabels(key: string) {
|
|||||||
if (key !== "") {
|
if (key !== "") {
|
||||||
for (let pair of key.split(",")) {
|
for (let pair of key.split(",")) {
|
||||||
let [name, value] = pair.split("=", 2);
|
let [name, value] = pair.split("=", 2);
|
||||||
|
if (!name || value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
labels.set(name, value
|
labels.set(name, value
|
||||||
.slice(1, -1)
|
.slice(1, -1)
|
||||||
.replace(/\\"/g, "\"")
|
.replace(/\\"/g, "\"")
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import * as libConfig from "./config";
|
|||||||
import * as libFileOps from "./file_ops";
|
import * as libFileOps from "./file_ops";
|
||||||
import { logger } from "./logging";
|
import { logger } from "./logging";
|
||||||
import * as libHelpers from "./helpers";
|
import * as libHelpers from "./helpers";
|
||||||
import { LockFile } from "./LockFile";
|
|
||||||
|
|
||||||
|
|
||||||
function print(...content: any[]) {
|
function print(...content: any[]) {
|
||||||
@@ -131,12 +130,11 @@ export function configCommand(yargs: any) {
|
|||||||
*
|
*
|
||||||
* @param args - yargs args object.
|
* @param args - yargs args object.
|
||||||
* @param instance - Config instance.
|
* @param instance - Config instance.
|
||||||
* @param lockFile - Lockfile required if attempting to write
|
* @param configPath - Path to configuration file.
|
||||||
*/
|
*/
|
||||||
export async function handleConfigCommand(
|
export async function handleConfigCommand(
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
instance: libConfig.Config<any>,
|
instance: libConfig.Config<any>,
|
||||||
lockFile: LockFile,
|
|
||||||
) {
|
) {
|
||||||
let command = (args._ as string[])[1];
|
let command = (args._ as string[])[1];
|
||||||
|
|
||||||
@@ -157,8 +155,6 @@ export async function handleConfigCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else if (command === "set") {
|
} else if (command === "set") {
|
||||||
await lockFile.acquire();
|
|
||||||
|
|
||||||
if (args.stdin) {
|
if (args.stdin) {
|
||||||
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
|
||||||
|
|
||||||
@@ -176,7 +172,5 @@ export async function handleConfigCommand(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await lockFile.release();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
"./src/api.ts",
|
"./src/api.ts",
|
||||||
"./src/config/*.ts",
|
"./src/config/*.ts",
|
||||||
"./src/data/*.ts",
|
"./src/data/*.ts",
|
||||||
"./src/external/*.ts",
|
|
||||||
"./src/datastore.ts",
|
"./src/datastore.ts",
|
||||||
"./src/errors.ts",
|
"./src/errors.ts",
|
||||||
"./src/factorio/exchange_string.ts",
|
"./src/factorio/exchange_string.ts",
|
||||||
@@ -26,7 +25,6 @@
|
|||||||
"./src/ExponentialBackoff.ts",
|
"./src/ExponentialBackoff.ts",
|
||||||
"./src/RateLimiter.ts",
|
"./src/RateLimiter.ts",
|
||||||
"./src/TypedEventEmitter.ts",
|
"./src/TypedEventEmitter.ts",
|
||||||
"./src/ValueCache.ts",
|
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import App from "./components/App";
|
|||||||
import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin";
|
import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin";
|
||||||
import InputRole from "./components/InputRole";
|
import InputRole from "./components/InputRole";
|
||||||
import InputModPack from "./components/InputModPack";
|
import InputModPack from "./components/InputModPack";
|
||||||
import InputTargetVersion from "./components/InputTargetVersion";
|
|
||||||
import InputPartialVersion from "./components/InputPartialVersion";
|
|
||||||
import { Control, ControlConnector } from "./util/websocket";
|
import { Control, ControlConnector } from "./util/websocket";
|
||||||
|
|
||||||
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
|
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
|
||||||
@@ -111,8 +109,6 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
|
|||||||
|
|
||||||
function inputComponentsFromPlugins(plugins: Map<string, BaseWebPlugin>) {
|
function inputComponentsFromPlugins(plugins: Map<string, BaseWebPlugin>) {
|
||||||
const inputComponents: Record<string, InputComponent> = {
|
const inputComponents: Record<string, InputComponent> = {
|
||||||
"partial_version": InputPartialVersion,
|
|
||||||
"target_version": InputTargetVersion,
|
|
||||||
"mod_pack": InputModPack,
|
"mod_pack": InputModPack,
|
||||||
"role": InputRole,
|
"role": InputRole,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import React, { useContext, useEffect, useState } from "react";
|
|
||||||
import { TreeSelect } from "antd";
|
|
||||||
|
|
||||||
import * as lib from "@clusterio/lib";
|
|
||||||
import { InputComponentProps } from "../BaseWebPlugin";
|
|
||||||
import { useAccount } from "../model/account";
|
|
||||||
import ControlContext from "./ControlContext";
|
|
||||||
|
|
||||||
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
|
|
||||||
|
|
||||||
export default function InputPartialVersion (
|
|
||||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
|
||||||
fieldDefinition?: lib.FieldDefinition;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
|
||||||
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
|
|
||||||
const control = useContext(ControlContext);
|
|
||||||
const account = useAccount();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (account.hasPermission("core.external.get_factorio_versions")) {
|
|
||||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
|
||||||
setVersions(res.map(v => v.version));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [control]);
|
|
||||||
|
|
||||||
// Split the versions into groups based on major minor
|
|
||||||
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
|
|
||||||
|
|
||||||
for (const version of versions) {
|
|
||||||
const [major, minor] = version.split(".");
|
|
||||||
const key = `${major}.${minor}` as lib.PartialVersion;
|
|
||||||
|
|
||||||
if (!groups.has(key)) {
|
|
||||||
groups.set(key, []);
|
|
||||||
}
|
|
||||||
groups.get(key)!.push(version);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the tree to be disabled
|
|
||||||
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
|
|
||||||
{
|
|
||||||
title: majorMinor,
|
|
||||||
value: majorMinor,
|
|
||||||
key: majorMinor,
|
|
||||||
children: patchVersions.map((v) => ({
|
|
||||||
title: v,
|
|
||||||
value: v,
|
|
||||||
key: v,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
));
|
|
||||||
|
|
||||||
return <TreeSelect
|
|
||||||
showSearch
|
|
||||||
style={{ minWidth: 175 }}
|
|
||||||
onChange={(value) => props.onChange(value ?? null)}
|
|
||||||
value={props.value}
|
|
||||||
treeData={tree}
|
|
||||||
allowClear={fieldDefinition.optional}
|
|
||||||
className={props.className}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>;
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import React, { useContext, useEffect, useState } from "react";
|
|
||||||
import { TreeSelect } from "antd";
|
|
||||||
|
|
||||||
import * as lib from "@clusterio/lib";
|
|
||||||
import { InputComponentProps } from "../BaseWebPlugin";
|
|
||||||
import { useAccount } from "../model/account";
|
|
||||||
import ControlContext from "./ControlContext";
|
|
||||||
|
|
||||||
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
|
|
||||||
|
|
||||||
export default function InputTargetVersion (
|
|
||||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
|
||||||
fieldDefinition?: lib.FieldDefinition;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
|
||||||
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
|
|
||||||
const control = useContext(ControlContext);
|
|
||||||
const account = useAccount();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (account.hasPermission("core.external.get_factorio_versions")) {
|
|
||||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
|
||||||
setVersions(res.map(v => v.version));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [control]);
|
|
||||||
|
|
||||||
// Split the versions into groups based on major minor
|
|
||||||
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
|
|
||||||
|
|
||||||
for (const version of versions) {
|
|
||||||
const [major, minor] = version.split(".");
|
|
||||||
const key = `${major}.${minor}` as lib.PartialVersion;
|
|
||||||
|
|
||||||
if (!groups.has(key)) {
|
|
||||||
groups.set(key, []);
|
|
||||||
}
|
|
||||||
groups.get(key)!.push(version);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the tree to be disabled
|
|
||||||
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
|
|
||||||
{
|
|
||||||
title: majorMinor,
|
|
||||||
value: majorMinor,
|
|
||||||
key: majorMinor,
|
|
||||||
children: patchVersions.map((v) => ({
|
|
||||||
title: v,
|
|
||||||
value: v,
|
|
||||||
key: v,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
));
|
|
||||||
|
|
||||||
// Add "latest" option for TargetVersion
|
|
||||||
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
|
|
||||||
|
|
||||||
return <TreeSelect
|
|
||||||
showSearch
|
|
||||||
style={{ minWidth: 175 }}
|
|
||||||
onChange={(value) => props.onChange(value ?? null)}
|
|
||||||
value={props.value}
|
|
||||||
treeData={tree}
|
|
||||||
allowClear={fieldDefinition.optional}
|
|
||||||
className={props.className}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>;
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ import PageHeader from "./PageHeader";
|
|||||||
import PageLayout from "./PageLayout";
|
import PageLayout from "./PageLayout";
|
||||||
import SectionHeader from "./SectionHeader";
|
import SectionHeader from "./SectionHeader";
|
||||||
import ModDetails from "./ModDetails";
|
import ModDetails from "./ModDetails";
|
||||||
import InputPartialVersion from "./InputPartialVersion";
|
|
||||||
|
|
||||||
const { logger } = lib;
|
const { logger } = lib;
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -1289,9 +1288,7 @@ export default function ModPackViewPage() {
|
|||||||
message: "Must be an a.b or a.b.c version number.",
|
message: "Must be an a.b or a.b.c version number.",
|
||||||
}]}
|
}]}
|
||||||
>
|
>
|
||||||
<InputPartialVersion
|
<Input
|
||||||
value={form.getFieldValue("factorioVersion")}
|
|
||||||
onChange={(value: any) => form.setFieldValue("factorioVersion", value)}
|
|
||||||
className={hasChange(changes, { type: "factorioVersion" }) ? "changed" : ""}
|
className={hasChange(changes, { type: "factorioVersion" }) ? "changed" : ""}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import SectionHeader from "./SectionHeader";
|
|||||||
import ModDetails from "./ModDetails";
|
import ModDetails from "./ModDetails";
|
||||||
import { Dropzone } from "./Dropzone";
|
import { Dropzone } from "./Dropzone";
|
||||||
import UploadButton from "./UploadButton";
|
import UploadButton from "./UploadButton";
|
||||||
import InputPartialVersion from "./InputPartialVersion";
|
|
||||||
|
|
||||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||||
|
|
||||||
@@ -129,10 +128,7 @@ function CreateModPackButton() {
|
|||||||
message: "Must be an a.b or a.b.c version number.",
|
message: "Must be an a.b or a.b.c version number.",
|
||||||
}]}
|
}]}
|
||||||
>
|
>
|
||||||
<InputPartialVersion
|
<Input />
|
||||||
value={form.getFieldValue("factorioVersion")}
|
|
||||||
onChange={(value) => form.setFieldValue("factorioVersion", value)}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -69,9 +69,6 @@ export class Control extends lib.Link {
|
|||||||
users = new lib.EventSubscriber(lib.UserUpdatesEvent, this);
|
users = new lib.EventSubscriber(lib.UserUpdatesEvent, this);
|
||||||
roles = new lib.EventSubscriber(lib.RoleUpdatesEvent, this);
|
roles = new lib.EventSubscriber(lib.RoleUpdatesEvent, this);
|
||||||
|
|
||||||
/** Cache for factorio versions to avoid repeat calls to the controller */
|
|
||||||
factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this));
|
|
||||||
|
|
||||||
declare connector: ControlConnector;
|
declare connector: ControlConnector;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -108,10 +105,6 @@ export class Control extends lib.Link {
|
|||||||
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
|
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
requestFactorioVersions() {
|
|
||||||
return this.send(new lib.FactorioVersionsRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) {
|
async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) {
|
||||||
this.accountRoles = event.roles??null;
|
this.accountRoles = event.roles??null;
|
||||||
this.emitAccountUpdate();
|
this.emitAccountUpdate();
|
||||||
|
|||||||
@@ -20,30 +20,25 @@
|
|||||||
"url": "https://github.com/clusterio/clusterio/issues"
|
"url": "https://github.com/clusterio/clusterio/issues"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/ctl": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*"
|
"@clusterio/lib": "workspace:*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
|
||||||
"@sinclair/typebox": "catalog:"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/ctl": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*",
|
"@clusterio/lib": "workspace:*",
|
||||||
"@types/node": "catalog:",
|
"@clusterio/web_ui": "workspace:*",
|
||||||
"@types/react": "catalog:",
|
"@types/node": "^20.17.19",
|
||||||
"typescript": "catalog:",
|
"@types/react": "^18.3.18",
|
||||||
"webpack": "catalog:",
|
"typescript": "^5.7.3",
|
||||||
"webpack-cli": "catalog:",
|
"webpack": "^5.98.0",
|
||||||
"webpack-merge": "catalog:"
|
"webpack-cli": "^5.1.4",
|
||||||
|
"webpack-merge": "^5.9.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@sinclair/typebox": "^0.30.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
"extends": "../../tsconfig.node.json",
|
"extends": "../../tsconfig.node.json",
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../packages/lib/tsconfig.node.json" },
|
{ "path": "../../packages/lib/tsconfig.node.json" },
|
||||||
{ "path": "../../packages/host/tsconfig.node.json" },
|
|
||||||
{ "path": "../../packages/ctl/tsconfig.node.json" },
|
|
||||||
],
|
],
|
||||||
"include": ["./**/*.ts"],
|
"include": ["./**/*.ts"],
|
||||||
"exclude": ["test/*", "./dist/*"],
|
"exclude": ["test/*", "./dist/*"],
|
||||||
|
|||||||
@@ -20,33 +20,28 @@
|
|||||||
"url": "https://github.com/clusterio/factorioClusterio/issues"
|
"url": "https://github.com/clusterio/factorioClusterio/issues"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/controller": "workspace:*",
|
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*"
|
"@clusterio/lib": "workspace:*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "catalog:",
|
"@sinclair/typebox": "^0.30.4",
|
||||||
"fs-extra": "catalog:"
|
"fs-extra": "^8.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/controller": "workspace:*",
|
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*",
|
"@clusterio/lib": "workspace:*",
|
||||||
"@types/fs-extra": "catalog:",
|
"@clusterio/web_ui": "workspace:*",
|
||||||
"@types/node": "catalog:",
|
"@types/fs-extra": "^11.0.1",
|
||||||
"@types/react": "catalog:",
|
"@types/node": "^20.17.19",
|
||||||
"antd": "catalog:",
|
"@types/react": "^18.3.18",
|
||||||
"react": "catalog:",
|
"antd": "^5.24.2",
|
||||||
"react-dom": "catalog:",
|
"react": "^18.2.0",
|
||||||
"typescript": "catalog:",
|
"react-dom": "^18.2.0",
|
||||||
"webpack": "catalog:",
|
"typescript": "^5.7.3",
|
||||||
"webpack-cli": "catalog:",
|
"webpack": "^5.98.0",
|
||||||
"webpack-merge": "catalog:"
|
"webpack-cli": "^5.1.4",
|
||||||
|
"webpack-merge": "^5.9.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
"extends": "../../tsconfig.node.json",
|
"extends": "../../tsconfig.node.json",
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../packages/lib/tsconfig.node.json" },
|
{ "path": "../../packages/lib/tsconfig.node.json" },
|
||||||
{ "path": "../../packages/host/tsconfig.node.json" },
|
|
||||||
{ "path": "../../packages/controller/tsconfig.node.json" },
|
|
||||||
],
|
],
|
||||||
"include": ["./**/*.ts"],
|
"include": ["./**/*.ts"],
|
||||||
"exclude": ["test/*", "./dist/*"],
|
"exclude": ["test/*", "./dist/*"],
|
||||||
|
|||||||
@@ -20,35 +20,30 @@
|
|||||||
"url": "https://github.com/clusterio/clusterio/issues"
|
"url": "https://github.com/clusterio/clusterio/issues"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/controller": "workspace:*",
|
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*"
|
"@clusterio/lib": "workspace:*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "catalog:",
|
"@sinclair/typebox": "^0.30.4",
|
||||||
"jsonwebtoken": "catalog:",
|
"express": "^4.21.2",
|
||||||
"express": "catalog:"
|
"jsonwebtoken": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/controller": "workspace:*",
|
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*",
|
"@clusterio/lib": "workspace:*",
|
||||||
"@types/express": "catalog:",
|
"@clusterio/web_ui": "workspace:*",
|
||||||
"@types/jsonwebtoken": "catalog:",
|
"@types/express": "^4.17.17",
|
||||||
"@types/node": "catalog:",
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/react": "catalog:",
|
"@types/node": "^20.17.19",
|
||||||
"antd": "catalog:",
|
"@types/react": "^18.3.18",
|
||||||
"react": "catalog:",
|
"antd": "^5.24.2",
|
||||||
"react-dom": "catalog:",
|
"react": "^18.2.0",
|
||||||
"typescript": "catalog:",
|
"react-dom": "^18.2.0",
|
||||||
"webpack": "catalog:",
|
"typescript": "^5.7.3",
|
||||||
"webpack-cli": "catalog:",
|
"webpack": "^5.98.0",
|
||||||
"webpack-merge": "catalog:"
|
"webpack-cli": "^5.1.4",
|
||||||
|
"webpack-merge": "^5.9.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
"extends": "../../tsconfig.node.json",
|
"extends": "../../tsconfig.node.json",
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../packages/lib/tsconfig.node.json" },
|
{ "path": "../../packages/lib/tsconfig.node.json" },
|
||||||
{ "path": "../../packages/host/tsconfig.node.json" },
|
|
||||||
{ "path": "../../packages/controller/tsconfig.node.json" },
|
|
||||||
],
|
],
|
||||||
"include": ["./**/*.ts"],
|
"include": ["./**/*.ts"],
|
||||||
"exclude": ["test/*", "./dist/*"],
|
"exclude": ["test/*", "./dist/*"],
|
||||||
|
|||||||
@@ -20,30 +20,25 @@
|
|||||||
"url": "https://github.com/clusterio/clusterio/issues"
|
"url": "https://github.com/clusterio/clusterio/issues"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/controller": "workspace:*",
|
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*"
|
"@clusterio/lib": "workspace:*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "catalog:",
|
"@sinclair/typebox": "^0.30.4",
|
||||||
"fs-extra": "catalog:"
|
"fs-extra": "^8.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/controller": "workspace:*",
|
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*",
|
"@clusterio/lib": "workspace:*",
|
||||||
"@types/fs-extra": "catalog:",
|
"@clusterio/web_ui": "workspace:*",
|
||||||
"@types/node": "catalog:",
|
"@types/fs-extra": "^11.0.1",
|
||||||
"@types/react": "catalog:",
|
"@types/node": "^20.17.19",
|
||||||
"typescript": "catalog:",
|
"@types/react": "^18.3.18",
|
||||||
"webpack": "catalog:",
|
"typescript": "^5.7.3",
|
||||||
"webpack-cli": "catalog:",
|
"webpack": "^5.98.0",
|
||||||
"webpack-merge": "catalog:"
|
"webpack-cli": "^5.1.4",
|
||||||
|
"webpack-merge": "^5.9.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
"extends": "../../tsconfig.node.json",
|
"extends": "../../tsconfig.node.json",
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../packages/lib/tsconfig.node.json" },
|
{ "path": "../../packages/lib/tsconfig.node.json" },
|
||||||
{ "path": "../../packages/host/tsconfig.node.json" },
|
|
||||||
{ "path": "../../packages/controller/tsconfig.node.json" },
|
|
||||||
],
|
],
|
||||||
"include": ["./**/*.ts"],
|
"include": ["./**/*.ts"],
|
||||||
"exclude": ["test/*", "./dist/*"],
|
"exclude": ["test/*", "./dist/*"],
|
||||||
|
|||||||
@@ -20,23 +20,20 @@
|
|||||||
"url": "https://github.com/clusterio/clusterio/issues"
|
"url": "https://github.com/clusterio/clusterio/issues"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*"
|
"@clusterio/lib": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*",
|
"@clusterio/lib": "workspace:*",
|
||||||
"@types/node": "catalog:",
|
"@clusterio/web_ui": "workspace:*",
|
||||||
"@types/react": "catalog:",
|
"@types/node": "^20.17.19",
|
||||||
"typescript": "catalog:",
|
"@types/react": "^18.3.18",
|
||||||
"webpack": "catalog:",
|
"typescript": "^5.7.3",
|
||||||
"webpack-cli": "catalog:",
|
"webpack": "^5.98.0",
|
||||||
"webpack-merge": "catalog:"
|
"webpack-cli": "^5.1.4",
|
||||||
|
"webpack-merge": "^5.9.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"extends": "../../tsconfig.node.json",
|
"extends": "../../tsconfig.node.json",
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../packages/lib/tsconfig.node.json" },
|
{ "path": "../../packages/lib/tsconfig.node.json" },
|
||||||
{ "path": "../../packages/host/tsconfig.node.json" },
|
|
||||||
],
|
],
|
||||||
"include": ["./**/*.ts"],
|
"include": ["./**/*.ts"],
|
||||||
"exclude": ["test/*", "./dist/*"],
|
"exclude": ["test/*", "./dist/*"],
|
||||||
|
|||||||
@@ -20,34 +20,29 @@
|
|||||||
"url": "https://github.com/clusterio/clusterio/issues"
|
"url": "https://github.com/clusterio/clusterio/issues"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@clusterio/controller": "workspace:*",
|
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*"
|
"@clusterio/lib": "workspace:*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "catalog:",
|
"@sinclair/typebox": "^0.30.4",
|
||||||
"fs-extra": "catalog:"
|
"fs-extra": "^8.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/controller": "workspace:*",
|
|
||||||
"@clusterio/web_ui": "workspace:*",
|
|
||||||
"@clusterio/host": "workspace:*",
|
|
||||||
"@clusterio/lib": "workspace:*",
|
"@clusterio/lib": "workspace:*",
|
||||||
"@types/express": "catalog:",
|
"@clusterio/web_ui": "workspace:*",
|
||||||
"@types/fs-extra": "catalog:",
|
"@types/express": "^4.17.17",
|
||||||
"@types/node": "catalog:",
|
"@types/fs-extra": "^11.0.1",
|
||||||
"@types/react": "catalog:",
|
"@types/node": "^20.17.19",
|
||||||
"antd": "catalog:",
|
"@types/react": "^18.3.18",
|
||||||
"react": "catalog:",
|
"antd": "^5.24.2",
|
||||||
"react-dom": "catalog:",
|
"react": "^18.2.0",
|
||||||
"typescript": "catalog:",
|
"react-dom": "^18.2.0",
|
||||||
"webpack": "catalog:",
|
"typescript": "^5.7.3",
|
||||||
"webpack-cli": "catalog:",
|
"webpack": "^5.98.0",
|
||||||
"webpack-merge": "catalog:"
|
"webpack-cli": "^5.1.4",
|
||||||
|
"webpack-merge": "^5.9.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
"extends": "../../tsconfig.node.json",
|
"extends": "../../tsconfig.node.json",
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../packages/lib/tsconfig.node.json" },
|
{ "path": "../../packages/lib/tsconfig.node.json" },
|
||||||
{ "path": "../../packages/host/tsconfig.node.json" },
|
|
||||||
{ "path": "../../packages/controller/tsconfig.node.json" },
|
|
||||||
],
|
],
|
||||||
"include": ["./**/*.ts"],
|
"include": ["./**/*.ts"],
|
||||||
"exclude": ["test/*", "./dist/*"],
|
"exclude": ["test/*", "./dist/*"],
|
||||||
|
|||||||
@@ -5,36 +5,4 @@ packages:
|
|||||||
- "external_plugins/*/*"
|
- "external_plugins/*/*"
|
||||||
- "test/"
|
- "test/"
|
||||||
|
|
||||||
catalog:
|
|
||||||
"@types/node": ^20.17.19
|
|
||||||
"@types/fs-extra": ^11.0.1
|
|
||||||
"@types/express": ^4.17.17
|
|
||||||
"@types/finalhandler": ^1.2.0
|
|
||||||
"@types/jsonwebtoken": ^9.0.9
|
|
||||||
"@types/set-blocking": ^2.0.0
|
|
||||||
"@types/react": ^18.3.18
|
|
||||||
"@types/ws": ^8.5.14
|
|
||||||
"@sinclair/typebox": ^0.30.4
|
|
||||||
"@swc/core": ^1.13.3
|
|
||||||
"antd": ^5.24.2
|
|
||||||
"react": ^18.2.0
|
|
||||||
"react-dom": ^18.2.0
|
|
||||||
"react-router-dom": ^6.29.0
|
|
||||||
"typescript": ^5.7.3
|
|
||||||
"webpack": ^5.98.0
|
|
||||||
"webpack-cli": ^5.1.4
|
|
||||||
"webpack-merge": ^5.9.0
|
|
||||||
"fs-extra": ^8.1.0
|
|
||||||
"express": ^5.1.0
|
|
||||||
"winston": ^3.17.0
|
|
||||||
"winston-transport": ^4.9.0
|
|
||||||
"winston-daily-rotate-file": ^4.7.1
|
|
||||||
"jsonwebtoken": ^9.0.2
|
|
||||||
"set-blocking": ^2.0.0
|
|
||||||
"jszip": ^3.10.1
|
|
||||||
"chalk": ^4.1.2
|
|
||||||
"phin": ^3.7.0
|
|
||||||
"ws": ^8.18.1
|
|
||||||
|
|
||||||
catalogMode: strict
|
|
||||||
linkWorkspacePackages: true
|
linkWorkspacePackages: true
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -13,8 +13,8 @@ const { wait } = lib;
|
|||||||
const testStrings = require("../lib/factorio/test_strings");
|
const testStrings = require("../lib/factorio/test_strings");
|
||||||
const {
|
const {
|
||||||
TestControl, TestControlConnector, url, controlToken, slowTest,
|
TestControl, TestControlConnector, url, controlToken, slowTest,
|
||||||
execCtl, execCtlProcess, execController, execHost, sendRcon, getControl,
|
exec, execCtl, execCtlProcess, execHost, sendRcon, getControl,
|
||||||
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
|
spawnNode, instancesDir, factorioDir,
|
||||||
} = require("./index");
|
} = require("./index");
|
||||||
|
|
||||||
|
|
||||||
@@ -109,59 +109,16 @@ describe("Integration of Clusterio", function() {
|
|||||||
describe("clusteriocontroller", function() {
|
describe("clusteriocontroller", function() {
|
||||||
describe("bootstrap generate-user-token", function() {
|
describe("bootstrap generate-user-token", function() {
|
||||||
it("work for existing user", async function() {
|
it("work for existing user", async function() {
|
||||||
await execController("bootstrap generate-user-token test");
|
await exec("node ../../packages/controller bootstrap generate-user-token test");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails if user does not exist", async function() {
|
it("fails if user does not exist", async function() {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
execController("bootstrap generate-user-token invalid")
|
exec("node ../../packages/controller bootstrap generate-user-token invalid")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("bootstrap create-admin", function() {
|
|
||||||
it("should refuse to modify the database files when locked", async function() {
|
|
||||||
await assert.rejects(
|
|
||||||
execController("bootstrap create-admin BootstrapAdminTest")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it("should modify the database files when locked if bypass option is given", async function() {
|
|
||||||
const lockFilePath = `${controllerConfigPath}.lock`;
|
|
||||||
await fs.copyFile(lockFilePath, "temp.lock");
|
|
||||||
try {
|
|
||||||
await execController("bootstrap create-admin BootstrapAdminTest2 --bypass-lock-file");
|
|
||||||
} finally {
|
|
||||||
await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await fs.readJSON(path.join(databaseDir, "users.json"));
|
|
||||||
assert.equal(Object.values(json).some(user => user.name === "BootstrapAdminTest2"), true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("config", function() {
|
|
||||||
it("can read the config file", async function() {
|
|
||||||
await execController("config list");
|
|
||||||
});
|
|
||||||
it("should refuse to modify the config file while locked", async function() {
|
|
||||||
await assert.rejects(
|
|
||||||
execController("config set controller.name ConfigEditTest")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it("should modify the config file while locked if bypass option is given", async function() {
|
|
||||||
const lockFilePath = `${controllerConfigPath}.lock`;
|
|
||||||
await fs.copyFile(lockFilePath, "temp.lock");
|
|
||||||
try {
|
|
||||||
await execController("config set controller.name ConfigEditTest2 --bypass-lock-file");
|
|
||||||
} finally {
|
|
||||||
await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await fs.readJSON(path.join(controllerConfigPath));
|
|
||||||
assert.equal(json["controller.name"], "ConfigEditTest2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("run", function() {
|
describe("run", function() {
|
||||||
it("should handle resume of an active connection", async function() {
|
it("should handle resume of an active connection", async function() {
|
||||||
slowTest(this);
|
slowTest(this);
|
||||||
@@ -186,38 +143,6 @@ describe("Integration of Clusterio", function() {
|
|||||||
await events.once(connectorB, "resume");
|
await events.once(connectorB, "resume");
|
||||||
await connectorB.close(1000, "");
|
await connectorB.close(1000, "");
|
||||||
});
|
});
|
||||||
it("should refuse to start a second process while locked", async function() {
|
|
||||||
await assert.rejects(execController("run"));
|
|
||||||
});
|
|
||||||
it("should start a second process while locked if bypass option is given", async function() {
|
|
||||||
slowTest(this);
|
|
||||||
const lockFilePath = `${controllerConfigPath}.lock`;
|
|
||||||
await fs.copyFile(lockFilePath, "temp.lock");
|
|
||||||
try {
|
|
||||||
await execController("run --bypass-lock-file");
|
|
||||||
} catch (err) {
|
|
||||||
assert.equal(/Error: Server listening failed/.test(err.stderr), true);
|
|
||||||
}
|
|
||||||
await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
|
|
||||||
});
|
|
||||||
it("should refuse to start when no users are loaded", async function() {
|
|
||||||
const dir = path.join("temp", "test", "empty_controller");
|
|
||||||
await fs.emptyDir(dir);
|
|
||||||
await assert.rejects(execController("run", { cwd: dir }));
|
|
||||||
});
|
|
||||||
it("should start when no users are loaded if bypass option given", async function() {
|
|
||||||
slowTest(this);
|
|
||||||
const dir = path.join("temp", "test", "empty_controller");
|
|
||||||
await fs.emptyDir(dir);
|
|
||||||
const child = await spawnNode(
|
|
||||||
"altController",
|
|
||||||
"../../../packages/controller run --no-check-user-count",
|
|
||||||
/Started controller/,
|
|
||||||
{ cwd: dir },
|
|
||||||
);
|
|
||||||
assert.ok(child.kill());
|
|
||||||
assert.equal(child.killed, true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("queryLogRequestHandler", function() {
|
describe("queryLogRequestHandler", function() {
|
||||||
@@ -320,67 +245,6 @@ describe("Integration of Clusterio", function() {
|
|||||||
await stopAltHost(hostProcess);
|
await stopAltHost(hostProcess);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
describe("config", function() {
|
|
||||||
it("can read the config file", async function() {
|
|
||||||
await execHost("config list");
|
|
||||||
});
|
|
||||||
it("should refuse to modify the config file while locked", async function() {
|
|
||||||
slowTest(this);
|
|
||||||
let hostProcess; // Alt host used since the process could get killed
|
|
||||||
const config = "alt-host-config.json";
|
|
||||||
try {
|
|
||||||
hostProcess = await startAltHost();
|
|
||||||
await assert.rejects(
|
|
||||||
execHost(`--config ${config} config set host.name ConfigEditTest`)
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await stopAltHost(hostProcess);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
it("should modify the config file while locked if bypass option is given", async function() {
|
|
||||||
slowTest(this);
|
|
||||||
let hostProcess; // Alt host used since the process could get killed
|
|
||||||
const config = "alt-host-config.json";
|
|
||||||
try {
|
|
||||||
hostProcess = await startAltHost();
|
|
||||||
await execHost(`--config ${config} config set host.name ConfigEditTest2 --bypass-lock-file`);
|
|
||||||
} finally {
|
|
||||||
await stopAltHost(hostProcess);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await fs.readJSON(path.join("temp", "test", config));
|
|
||||||
assert.equal(json["host.name"], "ConfigEditTest2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe("run", function() {
|
|
||||||
it("should refuse to start a second process while locked", async function() {
|
|
||||||
slowTest(this);
|
|
||||||
let hostProcess; // Alt host used since the process could get killed
|
|
||||||
const config = "alt-host-config.json";
|
|
||||||
try {
|
|
||||||
hostProcess = await startAltHost();
|
|
||||||
await assert.rejects(
|
|
||||||
execHost(`--config ${config} run`)
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await stopAltHost(hostProcess);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
it("should start a second process while locked if bypass option is given", async function() {
|
|
||||||
slowTest(this);
|
|
||||||
let hostProcessA, hostProcessB; // Alt host used since the process could get killed
|
|
||||||
const config = "alt-host-config.json";
|
|
||||||
try {
|
|
||||||
hostProcessA = await startAltHost();
|
|
||||||
hostProcessB = await spawnNode(
|
|
||||||
"alt-host-b:", `../../packages/host run --config ${config} --bypass-lock-file`, /Started host/
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await stopAltHost(hostProcessA);
|
|
||||||
await stopAltHost(hostProcessB);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("clusterioctl", function() {
|
describe("clusterioctl", function() {
|
||||||
|
|||||||
+12
-14
@@ -173,26 +173,26 @@ let controllerConfigPath = path.join("temp", "test", "config-controller.json");
|
|||||||
let hostConfigPath = path.join("temp", "test", "config-host.json");
|
let hostConfigPath = path.join("temp", "test", "config-host.json");
|
||||||
let controlConfigPath = path.join("temp", "test", "config-control.json");
|
let controlConfigPath = path.join("temp", "test", "config-control.json");
|
||||||
|
|
||||||
function exec(command, options = {}) {
|
async function exec(command, options = {}) {
|
||||||
// Uncomment to show commands run in tests
|
// Uncomment to show commands run in tests
|
||||||
// console.log(command);
|
// console.log(command);
|
||||||
options = { cwd: path.join("temp", "test"), ...options };
|
options = { cwd: path.join("temp", "test"), ...options };
|
||||||
return util.promisify(child_process.exec)(command, options);
|
return await util.promisify(child_process.exec)(command, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
function execController(...args) {
|
async function execController(...args) {
|
||||||
args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`;
|
args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`;
|
||||||
return exec(...args);
|
return await exec(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
function execHost(...args) {
|
async function execHost(...args) {
|
||||||
args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`;
|
args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`;
|
||||||
return exec(...args);
|
return await exec(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
function execCtlProcess(...args) {
|
async function execCtlProcess(...args) {
|
||||||
args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`;
|
args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`;
|
||||||
return exec(...args);
|
return await exec(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
let inExecCtl = false;
|
let inExecCtl = false;
|
||||||
@@ -229,8 +229,7 @@ function getControl() {
|
|||||||
return control;
|
return control;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @returns {Promise<child_process.ChildProcess>} */
|
function spawn(name, cmd, waitFor) {
|
||||||
function spawn(name, cmd, waitFor, options = {}) {
|
|
||||||
|
|
||||||
const silent = process.env.SILENT_TEST;
|
const silent = process.env.SILENT_TEST;
|
||||||
const bootstrap = !controllerProcess || !hostProcess;
|
const bootstrap = !controllerProcess || !hostProcess;
|
||||||
@@ -243,7 +242,7 @@ function spawn(name, cmd, waitFor, options = {}) {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
log(cmd);
|
log(cmd);
|
||||||
let parts = cmd.split(" ");
|
let parts = cmd.split(" ");
|
||||||
let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test"), ...options });
|
let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test") });
|
||||||
let stdout = new LineSplitter({ readableObjectMode: true });
|
let stdout = new LineSplitter({ readableObjectMode: true });
|
||||||
let stderr = new LineSplitter({ readableObjectMode: true });
|
let stderr = new LineSplitter({ readableObjectMode: true });
|
||||||
let onDataOut = line => {
|
let onDataOut = line => {
|
||||||
@@ -267,8 +266,8 @@ function spawn(name, cmd, waitFor, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnNode(name, cmd, waitFor, options) {
|
async function spawnNode(name, cmd, waitFor) {
|
||||||
return spawn(name, `node --enable-source-maps ${cmd}`, waitFor, options);
|
return await spawn(name, `node --enable-source-maps ${cmd}`, waitFor);
|
||||||
}
|
}
|
||||||
|
|
||||||
before(async function() {
|
before(async function() {
|
||||||
@@ -395,7 +394,6 @@ module.exports = {
|
|||||||
TestControl,
|
TestControl,
|
||||||
TestControlConnector,
|
TestControlConnector,
|
||||||
TestHostConnector,
|
TestHostConnector,
|
||||||
execController,
|
|
||||||
execCtlProcess,
|
execCtlProcess,
|
||||||
slowTest,
|
slowTest,
|
||||||
get,
|
get,
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
const assert = require("assert").strict;
|
const assert = require("assert").strict;
|
||||||
const lib = require("@clusterio/lib");
|
const lib = require("@clusterio/lib");
|
||||||
|
|
||||||
const { testMatrix } = require("../common");
|
|
||||||
const {
|
const {
|
||||||
slowTest, exec, execCtl, execCtlProcess, sendRcon, getControl,
|
slowTest, exec, execCtl, execCtlProcess, sendRcon, getControl,
|
||||||
} = require("./index");
|
} = require("./index");
|
||||||
|
|
||||||
const instId = 48;
|
const instId = 48;
|
||||||
const instName = "Integration";
|
const instName = "Integration";
|
||||||
const instSetConfig = `instance config set ${instName}`;
|
|
||||||
const instAltId = 49;
|
const instAltId = 49;
|
||||||
const instAltName = "IntegrationAlt";
|
const instAltName = "IntegrationAlt";
|
||||||
|
|
||||||
@@ -54,6 +52,7 @@ describe("Clusterio Instance", function() {
|
|||||||
// Exclude alt test instance from start-all to prevent interference
|
// Exclude alt test instance from start-all to prevent interference
|
||||||
await execCtl(`instance config set ${instAltName} instance.exclude_from_start_all true`);
|
await execCtl(`instance config set ${instAltName} instance.exclude_from_start_all true`);
|
||||||
await execCtl(`instance start ${instAltName}`);
|
await execCtl(`instance start ${instAltName}`);
|
||||||
|
await sendRcon(instAltId, "/sc disable achievements");
|
||||||
});
|
});
|
||||||
after(async function() {
|
after(async function() {
|
||||||
this.timeout(20000);
|
this.timeout(20000);
|
||||||
@@ -62,42 +61,25 @@ describe("Clusterio Instance", function() {
|
|||||||
await execCtl(`instance stop ${instAltName}`);
|
await execCtl(`instance stop ${instAltName}`);
|
||||||
await execCtl(`instance delete ${instAltName}`);
|
await execCtl(`instance delete ${instAltName}`);
|
||||||
});
|
});
|
||||||
for (const [savePatchingEnabled, scriptCommandsEnabled] of testMatrix([true, false], [true, false])) {
|
for (const savePatchingEnabled of [true, false]) {
|
||||||
const strSavePatching = `save patching: ${savePatchingEnabled ? "Enabled" : "Disabled"}`;
|
describe(`Integration ${savePatchingEnabled ? "with" : "without"} save patching`, function() {
|
||||||
const strScriptCommands = `script commands: ${scriptCommandsEnabled ? "Enabled" : "Disabled"}`;
|
|
||||||
async function allowSendingScriptCommands() {
|
|
||||||
if (!scriptCommandsEnabled) {
|
|
||||||
// Enable command to prevent "Attempted to use script command when disabled"
|
|
||||||
// This works because we only disable achievements on startup, this will break if we make it live update
|
|
||||||
await execCtl(`${instSetConfig} factorio.enable_script_commands true`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
describe(`Integration - ${strSavePatching}, ${strScriptCommands}`, function() {
|
|
||||||
before(async function() {
|
before(async function() {
|
||||||
this.timeout(20000);
|
this.timeout(20000);
|
||||||
// Create and start a patched save
|
// Create and start a patched save
|
||||||
await execCtl(`${instSetConfig} factorio.enable_save_patching ${savePatchingEnabled}`);
|
await execCtl(`instance config set ${instName} factorio.enable_save_patching ${savePatchingEnabled}`);
|
||||||
await execCtl(`${instSetConfig} factorio.enable_script_commands ${scriptCommandsEnabled}`);
|
await execCtl(`instance save create ${instName} ${savePatchingEnabled ? "patched" : "unpatched"}`);
|
||||||
await execCtl(`instance save create ${instName}`);
|
await execCtl(`instance start ${instName} --save ${savePatchingEnabled ? "patched" : "unpatched"}.zip`);
|
||||||
await execCtl(`instance start ${instName}`);
|
await sendRcon(instId, "/sc disable achievements");
|
||||||
});
|
});
|
||||||
after(async function() {
|
after(async function() {
|
||||||
// Stop the instance
|
// Stop the instance
|
||||||
await execCtl(`instance stop ${instName}`);
|
await execCtl(`instance stop ${instName}`);
|
||||||
});
|
});
|
||||||
describe("Disable achievements", function() {
|
describe("player event", function() {
|
||||||
it("should only disable achievements when script commands are enabled", async function() {
|
|
||||||
await allowSendingScriptCommands();
|
|
||||||
const res = await sendRcon(instId, "/sc rcon.print('Disabled')");
|
|
||||||
assert.equal(res === "Disabled\n", scriptCommandsEnabled);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe("Player events", function() {
|
|
||||||
if (savePatchingEnabled) {
|
if (savePatchingEnabled) {
|
||||||
// IPC expects save patching to be enabled
|
// IPC expects save patching to be enabled
|
||||||
it("should do nothing for an unknown type", async function() {
|
it("should do nothing for an unknown type", async function() {
|
||||||
await assert.rejects(getUser("DoesNotExist"), "User should not exist");
|
await assert.rejects(getUser("DoesNotExist"), "User should not exist");
|
||||||
await allowSendingScriptCommands();
|
|
||||||
await sendRcon(instId,
|
await sendRcon(instId,
|
||||||
`/sc ${requireApi} api.send_json("player_event",` +
|
`/sc ${requireApi} api.send_json("player_event",` +
|
||||||
"{ type='invalid type', name='DoesNotExist' })"
|
"{ type='invalid type', name='DoesNotExist' })"
|
||||||
@@ -105,7 +87,6 @@ describe("Clusterio Instance", function() {
|
|||||||
await assert.rejects(getUser("DoesNotExist"), "User was created");
|
await assert.rejects(getUser("DoesNotExist"), "User was created");
|
||||||
});
|
});
|
||||||
it("should respond to a player join and leave event", async function() {
|
it("should respond to a player join and leave event", async function() {
|
||||||
await allowSendingScriptCommands();
|
|
||||||
await sendRcon(instId,
|
await sendRcon(instId,
|
||||||
`/sc ${requireApi} api.send_json("player_event",` +
|
`/sc ${requireApi} api.send_json("player_event",` +
|
||||||
"{ type='join', name='JoiningPlayer' })"
|
"{ type='join', name='JoiningPlayer' })"
|
||||||
@@ -121,7 +102,7 @@ describe("Clusterio Instance", function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
it("should respond to a player ban and unban event", async function() {
|
it("should respond to a player ban and unban event", async function() {
|
||||||
await execCtl(`${instSetConfig} factorio.sync_banlist bidirectional`);
|
await execCtl(`instance config set ${instName} factorio.sync_banlist bidirectional`);
|
||||||
await sendRcon(instId, "/ban BannedPlayer");
|
await sendRcon(instId, "/ban BannedPlayer");
|
||||||
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
|
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
|
||||||
const userBanned = await getUser("BannedPlayer");
|
const userBanned = await getUser("BannedPlayer");
|
||||||
@@ -138,7 +119,7 @@ describe("Clusterio Instance", function() {
|
|||||||
});
|
});
|
||||||
it("should respond to a player promote and demote event", async function() {
|
it("should respond to a player promote and demote event", async function() {
|
||||||
// Because there is no admin list command this test will be different to the other two
|
// Because there is no admin list command this test will be different to the other two
|
||||||
await execCtl(`${instSetConfig} factorio.sync_adminlist bidirectional`);
|
await execCtl(`instance config set ${instName} factorio.sync_adminlist bidirectional`);
|
||||||
await sendRcon(instId, "/promote AdminPlayer");
|
await sendRcon(instId, "/promote AdminPlayer");
|
||||||
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
|
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
|
||||||
const userPromote = await getUser("AdminPlayer");
|
const userPromote = await getUser("AdminPlayer");
|
||||||
@@ -160,7 +141,7 @@ describe("Clusterio Instance", function() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
it("should respond to a player whitelist add and remove event", async function() {
|
it("should respond to a player whitelist add and remove event", async function() {
|
||||||
await execCtl(`${instSetConfig} factorio.sync_whitelist bidirectional`);
|
await execCtl(`instance config set ${instName} factorio.sync_whitelist bidirectional`);
|
||||||
await sendRcon(instId, "/whitelist add WhitelistPlayer");
|
await sendRcon(instId, "/whitelist add WhitelistPlayer");
|
||||||
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
|
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
|
||||||
const userAdded = await getUser("WhitelistPlayer");
|
const userAdded = await getUser("WhitelistPlayer");
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
/* eslint-disable node/no-sync */
|
|
||||||
"use strict";
|
|
||||||
const assert = require("assert").strict;
|
|
||||||
const fs = require("fs-extra");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const { LockFile } = require("@clusterio/lib");
|
|
||||||
|
|
||||||
const baseDir = path.join("temp", "test", "lock_file");
|
|
||||||
|
|
||||||
describe("class LockFile", function () {
|
|
||||||
/** @type {string} */
|
|
||||||
let filePath;
|
|
||||||
/** @type {LockFile} */
|
|
||||||
let lock;
|
|
||||||
|
|
||||||
before(async function () {
|
|
||||||
await fs.ensureDir(baseDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(function() {
|
|
||||||
filePath = path.join(baseDir, `${Date.now()}-${Math.random()}.lock`);
|
|
||||||
lock = new LockFile(filePath);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async function () {
|
|
||||||
await fs.unlink(filePath).catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("acquire()", function () {
|
|
||||||
it("should create the lock file when acquiring", async function () {
|
|
||||||
await lock.acquire();
|
|
||||||
assert.ok(fs.existsSync(filePath));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw LockFileBusyError if called when not idle", async function () {
|
|
||||||
await lock.acquire();
|
|
||||||
await assert.rejects(lock.acquire(), { name: "LockFileBusyError" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove stale lock file and acquire it", async function () {
|
|
||||||
await fs.writeFile(filePath, "999999\n"); // stale lock
|
|
||||||
await lock.acquire();
|
|
||||||
assert.ok(fs.existsSync(filePath));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw if existing lock file is not stale", async function () {
|
|
||||||
await fs.writeFile(filePath, `${process.pid}\n`);
|
|
||||||
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("release()", function () {
|
|
||||||
it("should remove the lock file when releasing", async function () {
|
|
||||||
await lock.acquire();
|
|
||||||
await lock.release();
|
|
||||||
assert.ok(!fs.existsSync(filePath));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw LockFileBusyError if called when not acquired", async function () {
|
|
||||||
await assert.rejects(lock.release(), { name: "LockFileBusyError" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isHeld()", function () {
|
|
||||||
it("should return true only when lock file contains current PID", async function () {
|
|
||||||
await lock.acquire();
|
|
||||||
const held = await lock.isHeld();
|
|
||||||
assert.equal(held, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false when lock file does not exist", async function () {
|
|
||||||
const held = await lock.isHeld();
|
|
||||||
assert.equal(held, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false if lock file contains different PID", async function () {
|
|
||||||
await fs.writeFile(filePath, "999999\n"); // simulate another process
|
|
||||||
const held = await lock.isHeld();
|
|
||||||
assert.equal(held, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false if lock file was deleted while held", async function() {
|
|
||||||
await lock.acquire();
|
|
||||||
await fs.unlink(filePath);
|
|
||||||
const held = await lock.isHeld();
|
|
||||||
assert.equal(held, false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isStale()", function () {
|
|
||||||
it("should return true if lock file does not exist", async function () {
|
|
||||||
const stale = await lock.isStale();
|
|
||||||
assert.equal(stale, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true for invalid PID in lock file", async function () {
|
|
||||||
await fs.writeFile(filePath, "abc\n"); // non-numeric PID
|
|
||||||
const stale = await lock.isStale();
|
|
||||||
assert.equal(stale, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true for non-existent process PID", async function () {
|
|
||||||
await fs.writeFile(filePath, "999999\n"); // assume PID does not exist
|
|
||||||
const stale = await lock.isStale();
|
|
||||||
assert.equal(stale, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for current process PID", async function () {
|
|
||||||
await fs.writeFile(filePath, `${process.pid}\n`);
|
|
||||||
const stale = await lock.isStale();
|
|
||||||
assert.equal(stale, false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("attach/detach exit handlers", function () {
|
|
||||||
it("should attach exit handler on acquire and detach on release", async function () {
|
|
||||||
await lock.acquire();
|
|
||||||
const listenersBefore = process.listeners("exit").slice();
|
|
||||||
assert.ok(listenersBefore.includes(lock.handleExitSync));
|
|
||||||
|
|
||||||
await lock.release();
|
|
||||||
const listenersAfter = process.listeners("exit").slice();
|
|
||||||
assert.ok(!listenersAfter.includes(lock.handleExitSync));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("handleExitSync()", function () {
|
|
||||||
it("should not throw if file already removed", function () {
|
|
||||||
assert.doesNotThrow(() => lock.handleExitSync()); // simulate non-existent file
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
const assert = require("assert").strict;
|
|
||||||
const { ValueCache, wait } = require("@clusterio/lib");
|
|
||||||
|
|
||||||
describe("ValueCache", function () {
|
|
||||||
describe("constructor", function () {
|
|
||||||
it("constructs with a fetch function", function() {
|
|
||||||
const fetcher = async () => 42;
|
|
||||||
const cache = new ValueCache(fetcher);
|
|
||||||
assert.ok(cache);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("get", function() {
|
|
||||||
it("throws TypeError for negative maxAgeMs", async function() {
|
|
||||||
const fetcher = async () => 42;
|
|
||||||
const cache = new ValueCache(fetcher);
|
|
||||||
|
|
||||||
assert.throws(() => cache.get(-1), err => {
|
|
||||||
assert.equal(err instanceof TypeError, true);
|
|
||||||
assert.match(String(err.message), /maxAgeMs/);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it("uses a default of 0 for maxAgeMs", async function() {
|
|
||||||
let callCount = 0;
|
|
||||||
const fetcher = async () => {
|
|
||||||
callCount += 1;
|
|
||||||
return callCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cache = new ValueCache(fetcher);
|
|
||||||
|
|
||||||
const v1 = await cache.get();
|
|
||||||
const v2 = await cache.get();
|
|
||||||
|
|
||||||
assert.equal(v1, 1);
|
|
||||||
assert.equal(v2, 2);
|
|
||||||
assert.equal(callCount, 2);
|
|
||||||
});
|
|
||||||
it("returns cached value when within maxAgeMs", async function() {
|
|
||||||
let callCount = 0;
|
|
||||||
const fetcher = async () => {
|
|
||||||
callCount += 1;
|
|
||||||
return callCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cache = new ValueCache(fetcher);
|
|
||||||
|
|
||||||
const v1 = await cache.get(1000);
|
|
||||||
const v2 = await cache.get(1000);
|
|
||||||
|
|
||||||
assert.equal(v1, 1);
|
|
||||||
assert.equal(v2, 1);
|
|
||||||
assert.equal(callCount, 1);
|
|
||||||
});
|
|
||||||
it("refreshes the cache when older than maxAgeMs", async function() {
|
|
||||||
let callCount = 0;
|
|
||||||
const fetcher = async () => {
|
|
||||||
callCount += 1;
|
|
||||||
return callCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cache = new ValueCache(fetcher);
|
|
||||||
|
|
||||||
const v1 = await cache.get(50);
|
|
||||||
const v2 = await cache.get(1000); // Use cached value
|
|
||||||
await wait(100);
|
|
||||||
const v3 = await cache.get(50); // Stale after 100ms wait
|
|
||||||
const v4 = await cache.get(1000); // Use cached value
|
|
||||||
|
|
||||||
assert.equal(v1, 1);
|
|
||||||
assert.equal(v2, 1);
|
|
||||||
assert.equal(v3, 2);
|
|
||||||
assert.equal(v4, 2);
|
|
||||||
assert.equal(callCount, 2);
|
|
||||||
});
|
|
||||||
it("shares in-flight fetch between concurrent callers", async function() {
|
|
||||||
let callCount = 0;
|
|
||||||
let resolveFetcher;
|
|
||||||
const fetcher = () => (
|
|
||||||
new Promise(resolve => {
|
|
||||||
callCount += 1;
|
|
||||||
resolveFetcher = resolve;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const cache = new ValueCache(fetcher);
|
|
||||||
|
|
||||||
const p1 = cache.get(1000);
|
|
||||||
const p2 = cache.get(1000);
|
|
||||||
|
|
||||||
assert.equal(callCount, 1);
|
|
||||||
|
|
||||||
resolveFetcher(42);
|
|
||||||
const [v1, v2] = await Promise.all([p1, p2]);
|
|
||||||
|
|
||||||
assert.equal(v1, 42);
|
|
||||||
assert.equal(v2, 42);
|
|
||||||
assert.equal(callCount, 1);
|
|
||||||
});
|
|
||||||
it("propagates errors from the fetcher and clears ongoing fetch", async function() {
|
|
||||||
let callCount = 0;
|
|
||||||
const error = new Error("fetch failed");
|
|
||||||
const fetcher = async () => {
|
|
||||||
callCount += 1;
|
|
||||||
throw error;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cache = new ValueCache(fetcher);
|
|
||||||
|
|
||||||
await assert.rejects(cache.get(), err => {
|
|
||||||
assert.equal(err, error);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(callCount, 1);
|
|
||||||
|
|
||||||
// After a failure, a new attempt to fetch should be made
|
|
||||||
await assert.rejects(cache.get(), err => {
|
|
||||||
assert.equal(err, error);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(callCount, 2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-134
@@ -1,134 +0,0 @@
|
|||||||
const assert = require("assert").strict;
|
|
||||||
const { readFile } = require("fs-extra");
|
|
||||||
|
|
||||||
const { fetchFactorioVersions } = require("@clusterio/lib");
|
|
||||||
const { slowTest } = require("../../integration");
|
|
||||||
|
|
||||||
describe("FactorioVersions", function() {
|
|
||||||
describe("fetchFactorioVersions", function() {
|
|
||||||
const originalFetch = global.fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
global.fetch = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
function mockFetch(html) {
|
|
||||||
const mockResponse = {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
text: async () => html,
|
|
||||||
};
|
|
||||||
|
|
||||||
global.fetch = async () => mockResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("fetches and parsed versions with stable / experimental flags", async function() {
|
|
||||||
mockFetch(`
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<a class="
|
|
||||||
slot-button-inline
|
|
||||||
version-button-experimental
|
|
||||||
" href="/download/archive/2.0.73">
|
|
||||||
2.0.73
|
|
||||||
</a>
|
|
||||||
</body>
|
|
||||||
<body>
|
|
||||||
<a class="
|
|
||||||
slot-button-inline
|
|
||||||
version-button-stable
|
|
||||||
" href="/download/archive/2.0.72">
|
|
||||||
2.0.72
|
|
||||||
</a>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
|
|
||||||
const versions = await fetchFactorioVersions();
|
|
||||||
assert.deepEqual(versions, [
|
|
||||||
{
|
|
||||||
stable: false,
|
|
||||||
version: "2.0.73",
|
|
||||||
headlessUrl: "www.factorio.com/get-download/2.0.73/headless/linux64",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
stable: true,
|
|
||||||
version: "2.0.72",
|
|
||||||
headlessUrl: "www.factorio.com/get-download/2.0.72/headless/linux64",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it("deduplicates versions by last occurrence", async function() {
|
|
||||||
mockFetch(`
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<a class="
|
|
||||||
slot-button-inline
|
|
||||||
version-button-experimental
|
|
||||||
" href="/download/archive/2.0.73">
|
|
||||||
2.0.73
|
|
||||||
</a>
|
|
||||||
</body>
|
|
||||||
<body>
|
|
||||||
<a class="
|
|
||||||
slot-button-inline
|
|
||||||
version-button-stable
|
|
||||||
" href="/download/archive/2.0.73">
|
|
||||||
2.0.73
|
|
||||||
</a>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
|
|
||||||
const versions = await fetchFactorioVersions();
|
|
||||||
assert.deepEqual(versions, [
|
|
||||||
{
|
|
||||||
stable: true,
|
|
||||||
version: "2.0.73",
|
|
||||||
headlessUrl: "www.factorio.com/get-download/2.0.73/headless/linux64",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it("thorws on HTTP error status", async function() {
|
|
||||||
const mockResponse = {
|
|
||||||
ok: false,
|
|
||||||
status: 500,
|
|
||||||
statusText: "Server Error",
|
|
||||||
text: async () => "",
|
|
||||||
};
|
|
||||||
|
|
||||||
global.fetch = async () => mockResponse;
|
|
||||||
|
|
||||||
await assert.rejects(fetchFactorioVersions(), /HTTP 500 Server Error/);
|
|
||||||
});
|
|
||||||
it("thorws on network error status", async function() {
|
|
||||||
global.fetch = async () => {
|
|
||||||
throw new Error("Network Error");
|
|
||||||
};
|
|
||||||
|
|
||||||
await assert.rejects(fetchFactorioVersions(), /Network Error/);
|
|
||||||
});
|
|
||||||
it("parses versions from saved html file", async function() {
|
|
||||||
mockFetch(await readFile("test/file/html/factorio-download-archive.html"));
|
|
||||||
|
|
||||||
const versions = await fetchFactorioVersions();
|
|
||||||
assert.equal(versions.length, 105);
|
|
||||||
assert.equal(versions.filter(v => v.stable).length, 48);
|
|
||||||
assert.equal(versions.filter(v => !v.stable).length, 57);
|
|
||||||
});
|
|
||||||
it("parses versions from live api", async function() {
|
|
||||||
slowTest(this);
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
|
|
||||||
const versions = await fetchFactorioVersions();
|
|
||||||
assert.ok(versions.length >= 105);
|
|
||||||
assert.ok(versions.filter(v => v.stable).length >= 48);
|
|
||||||
assert.ok(versions.filter(v => !v.stable).length >= 57);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -34,44 +34,4 @@ describe("messages/controller", function() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("External", function() {
|
|
||||||
it("runs", async function() {
|
|
||||||
let callCount = 0;
|
|
||||||
const versions = [
|
|
||||||
{ version: "1.2.3", stable: true, headlessUrl: "path/to/download" },
|
|
||||||
{ version: "1.2.4", stable: false, headlessUrl: "path/to/download" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mock the cache to avoid needless fetches
|
|
||||||
controller.factorioVersions = new lib.ValueCache(async () => {
|
|
||||||
callCount += 1;
|
|
||||||
return versions;
|
|
||||||
});
|
|
||||||
|
|
||||||
// First call should result in a fetch
|
|
||||||
const versions1 = await controlConnection.handleFactorioVersionsRequest(
|
|
||||||
new lib.FactorioVersionsRequest()
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(versions1, versions);
|
|
||||||
assert.equal(callCount, 1);
|
|
||||||
|
|
||||||
// Second call should not fetch due to default maxAgeMs being greater than 0
|
|
||||||
const versions2 = await controlConnection.handleFactorioVersionsRequest(
|
|
||||||
new lib.FactorioVersionsRequest()
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(versions2, versions);
|
|
||||||
assert.equal(callCount, 1);
|
|
||||||
|
|
||||||
// Setting maxAgeMs to 0 should result in a new fetch
|
|
||||||
const versions3 = await controlConnection.handleFactorioVersionsRequest(
|
|
||||||
new lib.FactorioVersionsRequest(0)
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(versions3, versions);
|
|
||||||
assert.equal(callCount, 2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user