Merge branch 'master' into feature/datastore

This commit is contained in:
Hornwitser authored and GitHub committed 2024-09-04 00:08:52 +02:00
commit a2ea2f2604
102 files changed
+1252 -366

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
{
"image":"mcr.microsoft.com/devcontainers/universal:2",
"postCreateCommand": "wget https://factorio.com/get-download/1.1.107/headless/linux64 && tar -xf linux64 && pnpm install",
}
+10 -9
View File
@@ -16,15 +16,15 @@ jobs:
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.4.0
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: pnpm i --no-optional --no-frozen-lockfile
- run: pnpm i --no-frozen-lockfile
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- name: Run tests
if: ${{ matrix.node-version != '18.x' }}
@@ -34,19 +34,20 @@ jobs:
run: pnpm run ci-cover
- name : Upload coverage to Codecov
if: ${{ matrix.node-version == '18.x' }}
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.4.0
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 18.x
- run: pnpm i --no-optional --no-frozen-lockfile
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
+22
View File
@@ -28,18 +28,40 @@ Many thanks to the following for contributing to this release:
### Features
- Added Datastore and Datastore Provider classes to the library to support savable Maps [#629](https://github.com/clusterio/clusterio/pull/629).
- Added clean script to speed up publishing and debugging of build steps [#640](https://github.com/clusterio/clusterio/pull/640).
- Added sub-tile option on plugin page headers [#642](https://github.com/clusterio/clusterio/pull/642).
- Greatly improved plugin and module template generation [#641](https://github.com/clusterio/clusterio/pull/641).
### Changes
- Added prompt for the HTTP port to host the controller to the installer [#619](https://github.com/clusterio/clusterio/pull/619).
- Added tests for sendEvent and sendRequest throwing the correct error messages [#638](https://github.com/clusterio/clusterio/pull/638).
- Updated all web ui pages to use correct page header component [#643](https://github.com/clusterio/clusterio/pull/643).
- Fixed IPC errors reporting undefined when no stack trace was present [#624](https://github.com/clusterio/clusterio/pull/624).
- Fixed loopback routing for instances reporting the wrong error message [#625](https://github.com/clusterio/clusterio/pull/625).
- Fixed docs linking to non-existing js files after ts migration [#633](https://github.com/clusterio/clusterio/pull/633).
- Fixed CI workflows using deprecated version of nodejs [#637](https://github.com/clusterio/clusterio/pull/637).
- Fixed test running on windows that should be linux only [#628](https://github.com/clusterio/clusterio/pull/628).
- Fixed log events being sent from a host over an invalid websocket [#639](https://github.com/clusterio/clusterio/pull/639).
- Bumped Typescript version to 5.5 and implemented `${configDir}` in base configs for "outDir" [#648](https://github.com/clusterio/clusterio/pull/648).
- Fixed spaces passed in arguments to the installer causing it to break [#620](https://github.com/clusterio/clusterio/issues/620)
- Fixed numeric admin name breaking login [#536](https://github.com/clusterio/clusterio/issues/536)
- Added factorio.executable_path option which allows overriding the default path to the Factorio executable run.
- Added instance version display [#573](https://github.com/clusterio/clusterio/pull/573)
### Breaking Changes
- Previously silent errors for controller.sendEvent now throw exceptions [#625](https://github.com/clusterio/clusterio/pull/625).
- @clusterio/controller export InstanceInfo has added factorioVersion parameter.
Many thanks to the following for contributing to this release:
[@CCpersonguy](https://github.com/CCpersonguy)
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
[@Laar](https://github.com/Laar)
[@psihius](https://github.com/psihius)
## Version 2.0.0-alpha.18
+116
View File
@@ -0,0 +1,116 @@
/**
* Clean the repository of build and execution artifacts.
*
* clean: A quick clean that removes all build artifacts making the output safe to publish
* clean-tests: Removes all build and test artifacts should not be required if tests are setup correctly.
* clean-all: Removes all build, test, and execution artifacts. Including cluster config and logs!
*/
"use strict";
const path = require("path");
const fs = require("fs-extra");
const yargs = require("yargs");
const npmPackage = require("./package.json");
const workspaces = npmPackage.workspaces
.map(p => p.slice(0, -2))
.filter(p => !p.includes("external_plugins"));
let DRY = false;
/**
* Attempt to remove a file / directory if it exists.
*
* @param {string} filePath The path to remove.
*/
async function tryRemove(filePath) {
if (await fs.exists(filePath)) {
// eslint-disable-next-line no-console
console.log(filePath);
}
if (!DRY) {
await fs.remove(filePath);
}
}
/**
* Removes all build artifacts
*/
async function removeBuildArtifacts() {
const tasks = [];
for (const workspace of workspaces) {
const basePath = path.resolve(workspace);
const packages = await fs.readdir(basePath);
for (const packageName of packages) {
tasks.push(
tryRemove(path.join(basePath, packageName, "dist"))
);
}
}
await Promise.all(tasks);
}
/**
* Removes all test artifacts
*/
async function removeTestArtifacts() {
const artifacts = [
".nyc_output", "coverage", "temp", "static",
];
await Promise.all(artifacts.map(artifact => tryRemove(path.resolve(artifact))));
}
/**
* Removes all execution artifacts
*/
async function removeExecutionArtifacts() {
const artifacts = [
"database", "instances", "logs", "mods",
"config-control.json", "config-controller.json", "config-host.json",
"FactorioAdminToken.txt", "plugin-list.json",
];
await Promise.all(artifacts.map(artifact => tryRemove(path.resolve(artifact))));
}
/**
* Main function for this script
*/
async function main() {
await yargs
.scriptName("clean")
.usage("$0 <command> [options]")
.option("dry", {
nargs: 0,
describe: "Will not delete any files",
default: false,
type: "boolean",
})
.command("all", "Remove all artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
removeTestArtifacts();
removeExecutionArtifacts();
})
.command("tests", "Remove all build and test artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
removeTestArtifacts();
})
.command("fast", "Remove all build artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
})
.demandCommand(1, "You need to specify a command to run")
.strict()
.parse();
}
// Run main if started from command line
if (module === require.main) {
main();
}
+1 -1
View File
@@ -30,7 +30,7 @@ There's also the virtual connector which lets two links in the same program be c
## Message
There's two types of messages currently implemented apart from the Link builtin close message and that's Request and Event.
The builtin messages are defined in [packages/lib/link/messages.js](/packages/lib/link/messages.js) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
The builtin messages are defined in [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
The messages define which links they are valid on and only attaches handlers for those links.
There's also a forwarding mechanism for when a request or event is to be forwarded another hop.
+1 -1
View File
@@ -6,7 +6,7 @@ Clusterio uses a field based configuration system to manage settings for the con
Plugins can add their own config fields for the controller and instances configs.
The fields are defined early in the startup, and fields for disabled plugins will still be created.
The built-in config fields are defined in [packages/lib/config/definitions.js](/packages/lib/config/definitions.js) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
+1 -1
View File
@@ -319,6 +319,6 @@ You will need commit access to the repository as well publish access to the @clu
4. Publish the packages.
```sh
rm -r ./{plugins,packages}/*/dist
pnpm clean
pnpm -r --filter='./{plugins,packages}/*' publish
```
+3 -3
View File
@@ -1,7 +1,7 @@
# Protocol
Clusterio communicates using a simple homebrew message passing protocol based on JSON over WebSocket with limited support for reliable transport of messages.
The protocol is implemented in [packages/lib/link](/packages/lib/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
The protocol is implemented in [packages/lib/src/link](/packages/lib/src/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
Two general kinds of messages are supported: events and requests.
Events are messages sent by one party and received by the other and offer no notification for the sender on whether it was actually received.
@@ -166,7 +166,7 @@ Events are messages send in one direction over the link, that the receiver is ex
By convention message types ending in `*_event` are events.
The data payload of an event consists solely of event specific properties.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core events and their contents.
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core events and their contents.
Plugins may defined their own events as well.
#### `*_event`
@@ -181,7 +181,7 @@ They function a lot like HTTP requests except that both parties of a connection
By convention message types ending in `*_request` are requests, and is expected to be replied to with a corresponding `*_response` message.
If an error occured while processing the request an error response containing an error property in the data payload is sent instead.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core requests and their contents.
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core requests and their contents.
Plugins may define their own requests as well.
#### `*_request`
+14 -3
View File
@@ -1,6 +1,7 @@
{
"name": "root",
"private": true,
"files": [],
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
@@ -11,6 +12,9 @@
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
"lint": "eslint packages plugins test",
"lint-fix": "eslint --fix packages plugins test",
"clean": "node ./clean.js fast",
"clean-tests": "node ./clean.js tests",
"clean-all": "node ./clean.js all",
"docs": "typedoc",
"bundle-dependencies": "bundle-dependencies"
},
@@ -23,12 +27,18 @@
"engines": {
"node": ">=18"
},
"workspaces": [
"packages/*",
"plugins/*",
"external_plugins/*",
"external_plugins/*/*"
],
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@swc/core": "^1.4.0",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
@@ -42,7 +52,8 @@
"mocha": "^10.2.0",
"nyc": "^15.1.0",
"phin": "^3.7.0",
"typedoc": "^0.24.8",
"typescript": "^5.1.6"
"typedoc": "^0.26.3",
"typescript": "^5.5.3",
"yargs": "^17.7.2"
}
}
+6 -2
View File
@@ -248,8 +248,12 @@ async function initialize(): Promise<InitializeParameters> {
.command("config", "Manage Controller config", lib.configCommand)
.command("bootstrap", "Bootstrap access to cluster", yargs => {
yargs
.command("create-admin <name>", "Create a cluster admin")
.command("generate-user-token <name>", "Generate authentication token for the given user")
.command("create-admin <name>", "Create a cluster admin", yargs => {
yargs.positional("name", { describe: "Name of the admin user", type: "string" });
})
.command("generate-user-token <name>", "Generate authentication token for the given user", yargs => {
yargs.positional("name", { describe: "Name of the user", type: "string" });
})
.command("generate-host-token <id>", "Generate authentication token for the given host", yargs => {
yargs.positional("id", { describe: "ID of the host", type: "number" });
})
+3 -3
View File
@@ -29,7 +29,6 @@
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.1",
"set-blocking": "^2.0.0",
"webpack-cli": "^5.1.4",
"winston": "^3.10.0",
"winston-daily-rotate-file": "^4.7.1",
"ws": "^8.13.0",
@@ -51,10 +50,11 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.14",
"typescript": "^5.1.6",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"webpack-dev-middleware": "^6.1.1",
"webpack-merge": "^5.9.0"
"webpack-merge": "^5.9.0",
"webpack-cli": "^5.1.4"
},
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -35,7 +35,7 @@ export default class BaseConnection extends lib.Link {
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
this._controller.subscriptions.unsubscribe(this);
this._controller.subscriptions.unsubscribeLink(this);
});
}
+7 -2
View File
@@ -84,7 +84,7 @@ export default class Controller {
public shouldRestart: boolean = false;
_fallbackedRequests: Map<lib.RequestClass<unknown, unknown>, lib.RequestHandler<unknown, unknown>> = new Map();
_registeredRequests: Map<lib.RequestClass<unknown, unknown>, lib.RequestHandler<unknown, unknown>> = new Map();
_registeredEvents = new Map();
_registeredEvents = new Map<lib.EventClass<unknown>, lib.EventHandler<unknown>>();
_snoopedEvents = new Map();
devMiddleware: any | null = null;
@@ -808,7 +808,7 @@ export default class Controller {
};
instanceConfig.set("factorio.settings", settings);
let instance = new InstanceInfo(instanceConfig, "unassigned", undefined, Date.now());
let instance = new InstanceInfo(instanceConfig, "unassigned", undefined, undefined, Date.now());
this.instances.set(instance);
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance);
this.addInstanceHooks(instance);
@@ -935,6 +935,11 @@ export default class Controller {
}
instanceDetailsUpdated(instances: InstanceInfo[]) {
for (const instance of instances) {
if (instance.status === "stopped") {
this.subscriptions.unsubscribeAddress(lib.Address.fromShorthand({instanceId: instance.id}));
}
}
const updates = instances.map(instance => instance.toInstanceDetails());
this.subscriptions.broadcast(new lib.InstanceDetailsUpdatesEvent(updates));
}
@@ -185,6 +185,7 @@ export default class HostConnection extends BaseConnection {
let prev = instance.status;
instance.status = request.status;
instance.gamePort = request.gamePort;
instance.factorioVersion = request.factorioVersion;
this._controller.instances.set(instance);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
@@ -221,6 +222,7 @@ export default class HostConnection extends BaseConnection {
let prev = controllerInstance.status;
controllerInstance.status = instanceData.status;
controllerInstance.gamePort = instanceData.gamePort;
controllerInstance.factorioVersion = instanceData.factorioVersion;
instanceUpdates.push(controllerInstance);
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
await lib.invokeHook(
@@ -235,6 +237,7 @@ export default class HostConnection extends BaseConnection {
instanceConfig,
instanceData.status,
instanceData.gamePort,
instanceData.factorioVersion,
Date.now(),
);
instanceUpdates.push(newInstance);
+4
View File
@@ -11,6 +11,7 @@ export default class InstanceInfo {
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public factorioVersion?: string,
public updatedAtMs = 0,
) {
this.config = config;
@@ -22,6 +23,7 @@ export default class InstanceInfo {
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
@@ -29,6 +31,7 @@ export default class InstanceInfo {
lib.InstanceConfig.fromJSON(json.config, location),
json.status,
json.gamePort,
json.factorioVersion,
json.updatedAtMs,
);
}
@@ -40,6 +43,7 @@ export default class InstanceInfo {
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.factorioVersion,
this.updatedAtMs,
);
}
@@ -7,6 +7,5 @@
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
"compilerOptions": {
"rootDir": "..",
"outDir": "dist/browser",
}
}
-3
View File
@@ -4,7 +4,4 @@
{ "path": "../lib/tsconfig.node.json" },
],
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+20 -8
View File
@@ -16,6 +16,7 @@ const { copyPluginTemplates } = require("./template");
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const finished = util.promisify(stream.finished);
const { escapeArg } = require("./escape_arg");
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
@@ -106,15 +107,25 @@ async function safeOutputFile(file, data, options={}) {
}
async function execFile(cmd, args) {
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
const escaped = args.map(escapeArg);
logger.verbose(`executing ${cmd} ${escaped.join(" ")}`);
return new Promise((resolve, reject) => {
let child = child_process.execFile(cmd, args, { shell: true }, (err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve({ stdout, stderr });
let child = child_process.execFile(
cmd,
escaped,
{
shell: true,
// eslint-disable-next-line node/no-process-env
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
},
(err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve({ stdout, stderr });
}
}
});
);
let stdout = new LineSplitter({ readableObjectMode: true });
stdout.on("data", line => { logger.verbose(line.toString()); });
child.stdout.pipe(stdout);
@@ -699,6 +710,7 @@ async function inquirerMissingArgs(args) {
{ name: "Command Line", value: "ctl" },
{ name: "Web UI", value: "web" },
{ name: "No Typescript", value: "js" },
{ name: "No Config", value: "no_config" },
],
},
{
@@ -892,7 +904,7 @@ async function main() {
let hostToken = result.stdout.split("\n").slice(-2)[0];
// Default to localhost on correct port for host in standalone mode
await execHost(["config", "set", "host.controller_url", `http://localhost:${answers.httpPort}`]);
await execHost(["config", "set", "host.controller_url", `http://localhost:${answers.httpPort}/`]);
await execHost(["config", "set", "host.controller_token", hostToken]);
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
+62
View File
@@ -0,0 +1,62 @@
"use strict";
function escapeArg(arg) {
// When shell: true is used execFile does not escaping of arguments, they are
// simply joined by space and then passed to the shell.
// For Windows the command is cmd.exe /d /s /c "command arg arg ..."
// The parsing and escaping on windows is cursed. The following references are not for mortals:
// https://daviddeley.com/autohotkey/parameters/parameters.htm#WIN
// https://stackoverflow.com/a/4095133
// https://ss64.com/nt/syntax-esc.html
if (process.platform === "win32") {
if (!/[\t "%&<>\\^|]/.test(arg)) {
return arg;
}
const escaped = [];
let pos = 0;
while (pos < arg.length) {
if (arg[pos] === "\\") {
// If a backslash is followed by a quote or the end of the string, then
// double the number of backslashes because why not!
let peek = pos + 1;
while (peek !== arg.length && arg[peek] === "\\") {
peek += 1;
}
if (peek === arg.length || arg[peek] === '"') {
const count = peek - pos;
escaped.push("\\".repeat(count * 2));
pos = peek;
continue;
} else {
escaped.push("\\");
}
} else if (arg[pos] === '"') {
escaped.push('""');
} else if (arg[pos] === "%") {
// Expect a pct environment variable to contain %, because cmd does variable substitutions
// before parsing string quotes, and there's no mechanism for escaping % symbols, and this
// is a half sane workaround. The less sane workaround is to replace % with %%cd:~,%.
escaped.push("%pct%");
} else {
escaped.push(arg[pos]);
}
pos += 1;
}
return `"${escaped.join("")}"`;
}
// For all other platforms the command is /bin/sh -c <command arg arg ...>
// The command and its arguments are passed as a single argument to the shell.
// See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02
// eslint-disable-next-line no-lonely-if
if (!/[|&;<>()$`\\"' \t*?\[#~=%]/.test(arg)) {
return arg;
}
return `"${arg.replace(/[$`"\\]/g, "\\$&")}"`;
}
module.exports = {
escapeArg,
};
+28 -5
View File
@@ -76,8 +76,15 @@ async function copyPluginTemplates(pluginName, templates) {
const files = new Map();
const prepare = [];
// Some basic flags for selecting files to include
const javascriptOnly = templates.includes("js");
const webpack = templates.includes("controller") || templates.includes("web");
const config = !templates.includes("no_config") && templates.some(value => (
["controller", "host", "instance", "ctl"].includes(value)
));
const webpack = config || templates.includes("controller") || templates.includes("web");
// A count of the number of isolated contexts the plugin runs under
let pluginContexts = 0;
// Get the file extension and path to the templates
const ext = javascriptOnly ? "js" : "ts";
@@ -88,7 +95,6 @@ async function copyPluginTemplates(pluginName, templates) {
files.set(".gitignore", path.join(commonPath, ".gitignore"));
files.set(".npmignore", path.join(commonPath, ".npmignore"));
files.set("package.json", path.join(commonPath, "package.json"));
files.set(`messages.${ext}`, path.join(templatePath, `messages.${ext}`));
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
// Files and dependencies to support typescript
@@ -105,24 +111,30 @@ async function copyPluginTemplates(pluginName, templates) {
if (webpack) {
prepare.push("webpack-cli --env production");
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
files.set(`web/index.${ext}x`, path.join(templatePath,
templates.includes("web") ? `web/plugin.${ext}x` : `web/no_plugin.${ext}x`
));
if (templates.includes("web")) {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
pluginContexts += 1;
} else {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/no_plugin.${ext}x`));
}
}
// Files for the controller
if (templates.includes("controller")) {
files.set(`controller.${ext}`, path.join(templatePath, `controller.${ext}`));
pluginContexts += 1;
}
// Files for hosts
if (templates.includes("host")) {
files.set(`host.${ext}`, path.join(templatePath, `host.${ext}`));
pluginContexts += 1;
}
// Files for instances
if (templates.includes("instance")) {
files.set(`instance.${ext}`, path.join(templatePath, `instance.${ext}`));
pluginContexts += 1;
}
// Files for lua modules
@@ -133,16 +145,26 @@ async function copyPluginTemplates(pluginName, templates) {
files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua"));
if (templates.includes("instance")) {
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
} else {
files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`));
pluginContexts += 1;
}
}
// Files for the control tool
if (templates.includes("ctl")) {
files.set(`ctl.${ext}`, path.join(templatePath, `ctl.${ext}`));
pluginContexts += 1;
}
// If there are more than one contexts then include the message file
if (pluginContexts > 1) {
files.set(`messages.${ext}`, path.join(templatePath, `messages.${ext}`));
}
// Properties that will control the replacements in the templates
const properties = {
config: config,
typescript: !javascriptOnly,
controller: templates.includes("controller"),
host: templates.includes("host"),
@@ -150,6 +172,7 @@ async function copyPluginTemplates(pluginName, templates) {
module: templates.includes("module"),
ctl: templates.includes("ctl"),
web: templates.includes("web"),
multi_context: pluginContexts > 1,
plugin_name: pluginName,
prepare: prepare.join(" && "),
ext: ext,
@@ -6,8 +6,10 @@ local MyModule = {
on_nth_tick = {},
}
--- global is 'synced' between players, you should use your plugin name to avoid conflicts
-- setupGlobalData should either be removed or called during clusterio_api.events.on_server_startup
--- global is 'synced' between players, you should use your plugin name to avoid name conflicts
-- The following helper function is desync safe and avoids name conflicts after being called during on_server_startup
-- There are edge cases in the following events: pre player left, player left, console command, and config changed
-- If you use any of these events and access globalData then make sure to call this function first
local globalData = {}
local function setupGlobalData()
if global["__plugin_name__"] == nil then
@@ -15,6 +17,12 @@ local function setupGlobalData()
-- starting values go here
}
end
MyModule.on_load()
end
--- The on_load function is called independently for each client when they first load the map
-- It should be used to restore global aliases and metatables not registered with script.register_metatable
function MyModule.on_load()
globalData = global["__plugin_name__"]
end
@@ -28,7 +36,7 @@ local function bar()
game.print("bar")
end
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_load
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_init
MyModule.events[clusterio_api.events.on_server_startup] = function(event)
setupGlobalData()
game.print(game.table_to_json(event))
@@ -20,7 +20,7 @@
"devDependencies": {
//%// Typescript and type declarations
//%if typescript
"typescript": "^5.1.6",
"typescript": "^5.5.3",
"@types/node": "^20.4.5",
//%endif
//%if typescript & web
@@ -1,13 +1,17 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseControllerPlugin } = require("@clusterio/controller");
//%if multi_context // Subscribing requires multi context
const {
PluginExampleEvent, PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
} = require("./messages");
//%endif
class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
@@ -15,14 +19,18 @@ class ControllerPlugin extends BaseControllerPlugin {
storageDirty = false;
//%endif
//%if multi_context // Subscribing requires multi context
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
//%endif
//%if multi_context // Subscribing requires multi context
}
//%endif
async onControllerConfigFieldChanged(field, curr, prev) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
@@ -45,6 +53,7 @@ class ControllerPlugin extends BaseControllerPlugin {
async onPlayerEvent(instance, event) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
@@ -57,6 +66,7 @@ class ControllerPlugin extends BaseControllerPlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
//%if controller & web // Subscribing requires web content and the controller
async handleExampleSubscription(request) {
@@ -1,6 +1,7 @@
"use strict";
const { BaseCtlPlugin } = require("@clusterio/ctl");
const { CommandTree, Command } = require("@clusterio/lib");
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
/* eslint-disable no-console */
@@ -1,13 +1,17 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseHostPlugin } = require("@clusterio/host");
//%if multi_context
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
//%endif
class HostPlugin extends BaseHostPlugin {
//%if multi_context
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%endif
async onHostConfigFieldChanged(field, curr, prev) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
@@ -16,6 +20,7 @@ class HostPlugin extends BaseHostPlugin {
async onShutdown() {
this.logger.info("host::onShutdown");
}
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
@@ -28,6 +33,7 @@ class HostPlugin extends BaseHostPlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
module.exports = {
+30 -13
View File
@@ -1,5 +1,6 @@
"use strict";
const lib = require("@clusterio/lib");
//%if multi_context
const Messages = require("./messages");
lib.definePermission({
@@ -13,6 +14,7 @@ lib.definePermission({
title: "Example permission request",
description: "Example Description. Request. Change me in index.js",
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
@@ -34,9 +36,13 @@ const plugin = {
name: "__plugin_name__",
title: "__plugin_name__",
description: "Example Description. Plugin. Change me in index.js",
//%if controller
//%if controller | host & !config | instance & !config | ctl & !config // Blank line for formatting
controllerEntrypoint: "./controller",
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
"__plugin_name__.myControllerField": {
title: "My Controller Field",
@@ -46,9 +52,13 @@ const plugin = {
},
},
//%endif
//%if host
//%if host & config // Blank line for formatting
hostEntrypoint: "./host",
//%endif
//%if host
hostEntrypoint: "./dist/node/host",
//%endif
//%if host & config
hostConfigFields: {
"__plugin_name__.myHostField": {
title: "My Host Field",
@@ -58,9 +68,13 @@ const plugin = {
},
},
//%endif
//%if instance
//%if instance & config | module & config // Blank line for formatting
instanceEntrypoint: "./instance",
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance",
//%endif
//%if instance & config
instanceConfigFields: {
"__plugin_name__.myInstanceField": {
title: "My Instance Field",
@@ -70,9 +84,13 @@ const plugin = {
},
},
//%endif
//%if ctl
//%if ctl & config // Blank line for formatting
ctlEntrypoint: "./ctl",
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl",
//%endif
//%if ctl & config
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
@@ -82,14 +100,18 @@ const plugin = {
},
},
//%endif
//%if multi_context // Subscribing requires multi context
messages: [
Messages.PluginExampleEvent,
Messages.PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
Messages.ExampleSubscribableUpdate,
//%endif
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
@@ -97,11 +119,6 @@ const plugin = {
"/__plugin_name__",
],
//%endif
//%if controller & !web // The controller always includes web entry even if there is no content
webEntrypoint: "./web",
routes: [],
//%endif
};
module.exports = {
@@ -1,16 +1,23 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseInstancePlugin } = require("@clusterio/host");
//%if multi_context
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
//%endif
class InstancePlugin extends BaseInstancePlugin {
//%if multi_context | module
async init() {
//%if multi_context
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%endif
//%if multi_context | module
}
//%endif
async onInstanceConfigFieldChanged(field, curr, prev) {
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
@@ -26,13 +33,16 @@ class InstancePlugin extends BaseInstancePlugin {
async onPlayerEvent(event) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
//%if module
this.sendRcon("/sc __plugin_name__.foo()");
//%endif
}
async handlePluginExampleRequest(request) {
@@ -42,6 +52,7 @@ class InstancePlugin extends BaseInstancePlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
//%if module
async handlePluginExampleIPC(event) {
@@ -0,0 +1,11 @@
"use strict";
const { BaseInstancePlugin } = require("@clusterio/host");
class InstancePlugin extends BaseInstancePlugin {
// This class is empty because an instance plugin must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
}
module.exports = {
InstancePlugin,
};
@@ -1,6 +1,6 @@
import React, {
useContext, useEffect, useState,
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
useCallback, useSyncExternalStore,
//%endif
} from "react";
@@ -10,35 +10,39 @@ import React, {
// } from "antd";
import {
BaseWebPlugin, PageLayout, Control, ControlContext, notifyErrorHandler,
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Subscribing requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate,
//%endif
//%if controller // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
} from "../messages";
//%endif
import * as lib from "@clusterio/lib";
function MyTemplatePage() {
let control = useContext(ControlContext);
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
const plugin = control.plugins.get("__plugin_name__");
const [subscribableData, synced] = plugin.useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
<h2>__plugin_name__</h2>
//%if controller & web // Subscribing requires web content and the controller
<PageHeader title="__plugin_name__" />
//%if controller // Subscribing requires web content and the controller
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
//%endif
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
//%endif
@@ -47,15 +51,24 @@ export class WebPlugin extends BaseWebPlugin {
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
permission: "__plugin_name__.page.view",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%if controller // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: undefined, // "__plugin_name__.example.permission.request",
//%endif
content: <MyTemplatePage/>,
},
];
//%if multi_context
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller & web // Subscribing requires web content and the controller
//%if controller// Subscribing requires web content and the controller
useSubscribableData() {
const control = useContext(ControlContext);
@@ -63,6 +76,7 @@ export class WebPlugin extends BaseWebPlugin {
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
@@ -75,4 +89,5 @@ export class WebPlugin extends BaseWebPlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
@@ -1,12 +1,16 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
//%if multi_context // Subscribing requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
} from "./messages";
//%endif
export class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
@@ -14,14 +18,18 @@ export class ControllerPlugin extends BaseControllerPlugin {
storageDirty = false;
//%endif
//%if multi_context // Subscribing requires multi context
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
//%endif
//%if multi_context // Subscribing requires multi context
}
//%endif
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
@@ -44,6 +52,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
@@ -56,6 +65,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
//%if controller & web // Subscribing requires web content and the controller
async handleExampleSubscription(request: lib.SubscriptionRequest) {
+2 -2
View File
@@ -1,6 +1,6 @@
type Control = any; // import type { Control } from "@clusterio/ctl";
import { BaseCtlPlugin } from "@clusterio/ctl";
import { BaseCtlPlugin, type Control } from "@clusterio/ctl";
import { CommandTree, Command } from "@clusterio/lib";
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
/* eslint-disable no-console */
@@ -1,12 +1,16 @@
import * as lib from "@clusterio/lib";
import { BaseHostPlugin } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
//%endif
export class HostPlugin extends BaseHostPlugin {
//%if multi_context
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%endif
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
@@ -15,6 +19,7 @@ export class HostPlugin extends BaseHostPlugin {
async onShutdown() {
this.logger.info("host::onShutdown");
}
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
@@ -27,4 +32,5 @@ export class HostPlugin extends BaseHostPlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
+35 -13
View File
@@ -1,4 +1,5 @@
import * as lib from "@clusterio/lib";
//%if multi_context
import * as Messages from "./messages";
lib.definePermission({
@@ -12,6 +13,7 @@ lib.definePermission({
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
@@ -28,37 +30,46 @@ lib.definePermission({
description: "Example Description. View. Change me in index.ts",
});
//%endif
//%if config
declare module "@clusterio/lib" {
//%if controller
//%endif
//%if controller & config
export interface ControllerConfigFields {
"__plugin_name__.myControllerField": string;
}
//%endif
//%if host
//%if host & config
export interface HostConfigFields {
"__plugin_name__.myHostField": string;
}
//%endif
//%if instance
//%if instance & config
export interface InstanceConfigFields {
"__plugin_name__.myInstanceField": string;
}
//%endif
//%if ctl
//%if ctl & config
export interface ControlConfigFields {
"__plugin_name__.myControlField": string;
}
//%endif
//%if config
}
//%endif
export const plugin: lib.PluginDeclaration = {
name: "__plugin_name__",
title: "__plugin_name__",
description: "Example Description. Plugin. Change me in index.ts",
//%if controller
//%// There are a lot of statements here to make the formatting look good with and without 'config'
//%if controller | host & !config | instance & !config | ctl & !config // Blank line for formatting
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
"__plugin_name__.myControllerField": {
title: "My Controller Field",
@@ -68,9 +79,13 @@ export const plugin: lib.PluginDeclaration = {
},
},
//%endif
//%if host
//%if host & config // Blank line for formatting
//%endif
//%if host
hostEntrypoint: "./dist/node/host",
//%endif
//%if host & config
hostConfigFields: {
"__plugin_name__.myHostField": {
title: "My Host Field",
@@ -80,9 +95,13 @@ export const plugin: lib.PluginDeclaration = {
},
},
//%endif
//%if instance
//%if instance & config | module & config // Blank line for formatting
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance",
//%endif
//%if instance & config
instanceConfigFields: {
"__plugin_name__.myInstanceField": {
title: "My Instance Field",
@@ -92,9 +111,13 @@ export const plugin: lib.PluginDeclaration = {
},
},
//%endif
//%if ctl
//%if ctl & config // Blank line for formatting
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl",
//%endif
//%if ctl & config
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
@@ -104,14 +127,18 @@ export const plugin: lib.PluginDeclaration = {
},
},
//%endif
//%if multi_context // Subscribing requires multi context
messages: [
Messages.PluginExampleEvent,
Messages.PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
Messages.ExampleSubscribableUpdate,
//%endif
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
@@ -119,9 +146,4 @@ export const plugin: lib.PluginDeclaration = {
"/__plugin_name__",
],
//%endif
//%if controller & !web // The controller always includes web entry even if there is no content
webEntrypoint: "./web",
routes: [],
//%endif
};
@@ -1,6 +1,8 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
//%endif
//%if module
type PuginExampleIPC = {
@@ -10,13 +12,19 @@ type PuginExampleIPC = {
//%endif
export class InstancePlugin extends BaseInstancePlugin {
//%if multi_context | module
async init() {
//%endif
//%if multi_context
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%endif
//%if multi_context | module
}
//%endif
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
@@ -33,9 +41,12 @@ export class InstancePlugin extends BaseInstancePlugin {
async onPlayerEvent(event: lib.PlayerEvent) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
this.sendRcon("/sc __plugin_name__.foo()");
if (this.instance.status === "running") {
this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
@@ -48,6 +59,7 @@ export class InstancePlugin extends BaseInstancePlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
//%if module
async handlePluginExampleIPC(event: PuginExampleIPC) {
@@ -0,0 +1,7 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
export class InstancePlugin extends BaseInstancePlugin {
// This class is empty because an instance plugin must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
}
@@ -5,7 +5,4 @@
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
"compilerOptions": {
"outDir": "dist/browser",
}
}
@@ -5,7 +5,4 @@
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"outDir": "dist/node",
},
}
@@ -10,35 +10,39 @@ import React, {
// } from "antd";
import {
BaseWebPlugin, PageLayout, Control, ControlContext, notifyErrorHandler,
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Subscribing requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
} from "../messages";
//%endif
import * as lib from "@clusterio/lib";
function MyTemplatePage() {
const control = useContext(ControlContext);
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
const plugin = control.plugins.get("__plugin_name__") as WebPlugin;
const [subscribableData, synced] = plugin.useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
<h2>__plugin_name__</h2>
//%if controller & web // Subscribing requires web content and the controller
<PageHeader title="__plugin_name__" />
//%if controller // Subscribing requires web content and the controller
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
//%endif
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
//%endif
@@ -49,20 +53,22 @@ export class WebPlugin extends BaseWebPlugin {
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller | !web
//%if !controller
permission: undefined, // "__plugin_name__.example.permission.request",
//%endif
content: <MyTemplatePage/>,
},
];
//%if multi_context
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller & web // Subscribing requires web content and the controller
//%if controller // Subscribing requires web content and the controller
useSubscribableData() {
const control = useContext(ControlContext);
@@ -70,6 +76,7 @@ export class WebPlugin extends BaseWebPlugin {
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
@@ -82,4 +89,5 @@ export class WebPlugin extends BaseWebPlugin {
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
+1 -1
View File
@@ -35,6 +35,6 @@
"@types/node": "^20.4.5",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.24",
"typescript": "^5.1.6"
"typescript": "^5.5.3"
}
}
-3
View File
@@ -4,7 +4,4 @@
{ "path": "../lib/tsconfig.node.json" },
],
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+1 -1
View File
@@ -43,6 +43,6 @@
"@types/semver": "^7.5.0",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.24",
"typescript": "^5.1.6"
"typescript": "^5.5.3"
}
}
+5 -1
View File
@@ -667,6 +667,9 @@ export default class Host extends lib.Link {
instanceId,
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection
? instanceConnection.instance.server.version
: instanceInfo.config.get("factorio.version"),
)
);
@@ -736,7 +739,7 @@ export default class Host extends lib.Link {
}
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) {
if (id === instanceId) {
continue;
@@ -1060,6 +1063,7 @@ export default class Host extends lib.Link {
instanceInfo.config.toRemote("controller"),
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection ? instanceConnection.instance.server.version : undefined,
));
}
await this.send(new lib.InstancesUpdateRequest(list));
+3
View File
@@ -169,6 +169,7 @@ export default class Instance extends lib.Link {
let serverOptions = {
logger: this.logger,
version: this.config.get("factorio.version"),
executablePath: this.config.get("factorio.executable_path") ?? undefined,
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
rconPassword: this.config.get("factorio.rcon_password") ?? undefined,
@@ -453,6 +454,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
this.id,
status,
this.server.gamePort,
status === "running"? this.server.version : this.config.get("factorio.version"),
),
);
}
@@ -469,6 +471,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
notifyExit() {
this._loadedSave = null;
this.notifyStatus("stopped");
this.connector.emit("close");
this.config.off("fieldChanged", this._configFieldChanged);
clearTimeout(this._playerCheckInterval);
+1 -1
View File
@@ -64,7 +64,7 @@ async function generateExportMod(server: FactorioServer) {
sourceDir: path.join(__dirname, "..", "..", "..", "lua", "export"),
outputDir: server.writePath("mods"),
bumpPatch: false,
factorioVersion: server.version.replace(/\.\d+$/, ""),
factorioVersion: server.version?.replace(/\.\d+$/, ""),
dependencies,
});
}
+15 -6
View File
@@ -407,6 +407,8 @@ export interface FactorioServerOptions {
* the latest version found in `factorioDir`.
*/
version?: string,
/** Path to executable to invoke when starting the server */
executablePath?: string;
/** UDP port to host game on. */
gamePort?: number,
/** TCP port to use for RCON. */
@@ -450,6 +452,8 @@ export interface FactorioServerOptions {
* @extends events.EventEmitter
*/
export class FactorioServer extends events.EventEmitter {
/** Path to executable to invoke when starting the server */
executablePath?: string;
/** UDP port used for hosting the Factorio game server on */
gamePort: number;
/** TCP port used for RCON on the Factorio game server */
@@ -504,6 +508,7 @@ export class FactorioServer extends events.EventEmitter {
this._logger = options.logger || lib.logger;
this._targetVersion = options.version || "latest";
this.executablePath = options.executablePath;
/** UDP port used for hosting the Factorio game server on */
this.gamePort = options.gamePort || randomDynamicPort();
/** TCP port used for RCON on the Factorio game server */
@@ -725,12 +730,12 @@ export class FactorioServer extends events.EventEmitter {
}
/**
* The version of Factorio in use. This will be the actual version if
* "latest" (the default) was specified as `factorioVersion` to the
* constructor.
* The version of Factorio in use. This will be the actual version if
* "latest" (the default) was specified as `factorioVersion` to the constructor.
* This will be undefined before the server is initialized, or if init failed.
*/
get version() {
return this._version!;
get version(): string | undefined {
return this._version ?? undefined;
}
/**
@@ -1155,11 +1160,15 @@ export class FactorioServer extends events.EventEmitter {
/**
* Get Factorio binary path
*
* Get the path to the factorio binary depending on the platform (MacOS support)
* Get the path to the factorio binary depending on the configuration
* and or the platform (MacOS support)
*
* @returns Path to factorio binary
*/
binaryPath() {
if (this.executablePath) {
return this.dataPath("..", this.executablePath);
}
if (process.platform === "darwin") {
return this.dataPath("..", "MacOS", "factorio");
}
-3
View File
@@ -4,7 +4,4 @@
{ "path": "../lib/tsconfig.node.json" },
],
"include": ["index.ts", "host.ts", "package.json", "src/**/*.ts", "src/**/*.js"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+1 -1
View File
@@ -41,6 +41,6 @@
"@types/triple-beam": "^1.3.2",
"@types/ws": "^8.5.5",
"@types/yargs": "^17.0.24",
"typescript": "^5.1.6"
"typescript": "^5.5.3"
}
}
+9
View File
@@ -261,6 +261,7 @@ export interface InstanceConfigFields {
"instance.auto_start": boolean;
"factorio.version": string;
"factorio.executable_path": string | null;
"factorio.game_port": number | null;
"factorio.host_assigned_game_port": number | null;
"factorio.rcon_port": number | null;
@@ -311,6 +312,14 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "string",
initialValue: "latest",
},
"factorio.executable_path": {
description:
"Relative path from the Factorio installation directory to the executable to run. " +
"Defaults to auto detect the path, only needed in special setups.",
restartRequired: true,
type: "string",
optional: true,
},
"factorio.game_port": {
description: "UDP port to run game on, uses a port in host.factorio_port_range if null",
restartRequired: true,
+9 -2
View File
@@ -42,6 +42,7 @@ export class InstanceDetails {
public assignedHost: number | undefined,
public gamePort: number | undefined,
public status: InstanceStatus,
public factorioVersion: string | undefined,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
) { }
@@ -55,6 +56,7 @@ export class InstanceDetails {
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
]),
"factorioVersion": Type.Optional(Type.String()),
"updatedAtMs": Type.Optional(Type.Number()),
});
@@ -65,6 +67,7 @@ export class InstanceDetails {
json.assignedHost,
json.gamePort,
json.status,
json.factorioVersion,
json.updatedAtMs,
);
}
@@ -717,6 +720,7 @@ export class HostInstanceUpdate {
public config: Static<typeof InstanceConfig.jsonSchema>,
public status: InstanceStatus,
public gamePort: undefined | number,
public factorioVersion: undefined | string,
) { }
static jsonSchema = Type.Object({
@@ -725,10 +729,11 @@ export class HostInstanceUpdate {
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]),
"gamePort": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.config, json.status, json.gamePort);
return new this(json.config, json.status, json.gamePort, json.factorioVersion);
}
}
@@ -822,6 +827,7 @@ export class InstanceStatusChangedEvent {
public instanceId: number,
public status: InstanceStatus,
public gamePort?: number,
public factorioVersion?: string,
) { }
static jsonSchema = Type.Object({
@@ -830,10 +836,11 @@ export class InstanceStatusChangedEvent {
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]),
"gamePort": Type.Optional(Type.Integer()),
"factorioVersion": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.instanceId, json.status, json.gamePort);
return new this(json.instanceId, json.status, json.gamePort, json.factorioVersion);
}
}
+19
View File
@@ -34,6 +34,7 @@ export abstract class BaseConnector extends events.EventEmitter {
}
protected abstract send(message: libData.MessageRoutable): void;
abstract get valid(): boolean;
forward(message: libData.MessageRoutable) {
const seq = this._seq;
@@ -346,6 +347,14 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
get hasSession() {
return ["connected", "resuming"].includes(this._state);
}
/**
* True if the connector is valid and can accept new messages,
* only needs to be checked on fringe cases during setup and tear down.
*/
get valid() {
return this.hasSession;
}
}
/**
@@ -723,4 +732,14 @@ export class VirtualConnector extends BaseConnector {
send(message: libData.Message) {
this.other.emit("message", message);
}
/**
* True if the connector is valid and can accept new messages,
* only needs to be checked on fringe cases during setup and tear down.
*
* For a virtual connector this is always true.
*/
get valid() {
return true;
}
}
+2 -2
View File
@@ -538,7 +538,7 @@ export class Link {
if (!entry) {
throw new Error(`Attempt to send unregistered Request ${request.constructor.name}`);
}
if (this.connector.src.equals(dst)) {
if (this.connector.src.addressedTo(dst)) {
throw new Error(`Message would return back to sender ${dst}.`);
}
if (this.validateSent) {
@@ -575,7 +575,7 @@ export class Link {
if (!entry) {
throw new Error(`Attempt to send unregistered Event ${event.constructor.name}`);
}
if (this.connector.src.equals(dst)) {
if (this.connector.src.addressedTo(dst)) {
throw new Error(`Message would return back to sender ${dst}.`);
}
if (this.validateSent) {
+3 -1
View File
@@ -207,7 +207,9 @@ export class LinkTransport extends Transport {
}
try {
this.link.send(new libData.LogMessageEvent(info));
if (this.link.connector.valid) {
this.link.send(new libData.LogMessageEvent(info));
}
} catch (err) {
// Ignore session lost errors.
if (!(err instanceof libErrors.SessionLost)) {
+108 -48
View File
@@ -1,11 +1,12 @@
import { Type, Static } from "@sinclair/typebox";
import { Link, Event, EventClass, RequestHandler, WebSocketClientConnector, WebSocketBaseConnector } from "./link";
import { logger } from "./logging";
import { Address, MessageRequest, IControllerUser } from "./data";
import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link";
import { Address, MessageRequest, IControllerUser, plainJson, JsonBoolean } from "./data";
import isDeepStrictEqual from "./is_deep_strict_equal";
import { RequestError, SessionLost } from "./errors";
import { logger } from "./logging";
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
export type EventSubscriberCallback<T> = (updates: T[], synced: boolean) => void
export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => void
/**
* A subscription request sent by a subscriber, this updates what events the subscriber will be sent
@@ -15,7 +16,7 @@ export type EventSubscriberCallback<T> = (updates: T[], synced: boolean) => void
export class SubscriptionRequest {
declare ["constructor"]: typeof SubscriptionRequest;
static type = "request" as const;
static src = ["control", "instance"] as const;
static src = ["control", "host", "instance"] as const;
static dst = "controller" as const;
static permission(user: IControllerUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
@@ -31,6 +32,9 @@ export class SubscriptionRequest {
}
}
/** Indicates if updates have been sent */
static Response = JsonBoolean;
constructor(
public eventName: string,
public subscribe: boolean,
@@ -56,9 +60,14 @@ export class SubscriptionRequest {
}
}
type Subscriber = {
link: Link,
dst: Address,
}
type EventData = {
subscriptionUpdate?: SubscriptionRequestHandler<unknown>,
subscriptions: Set<Link>,
subscriptions: Array<Subscriber>,
};
/**
@@ -85,7 +94,7 @@ export class SubscriptionController {
}
this._events.set(entry.name, {
subscriptionUpdate: subscriptionUpdate,
subscriptions: new Set(),
subscriptions: [],
});
}
@@ -102,13 +111,28 @@ export class SubscriptionController {
if (!eventData) {
throw new Error(`Event ${entry.name} is not a registered as subscribable`);
}
for (let link of eventData.subscriptions) {
if ((link.connector as WebSocketBaseConnector).closing) {
eventData.subscriptions.delete(link);
const toRemove: Subscriber[] = [];
for (const subscriber of eventData.subscriptions) {
if ((subscriber.link.connector as WebSocketBaseConnector).closing) {
toRemove.push(subscriber);
} else {
link.send(event);
subscriber.link.sendTo(subscriber.dst, event);
}
}
eventData.subscriptions = eventData.subscriptions.filter(subscriber => !toRemove.includes(subscriber));
}
/**
* Unsubscribe from all events of a given address.
* Used when an instance is stopped to stop all active subscriptions.
* @param address - Address to stop sending events to.
*/
unsubscribeAddress(address: Address) {
for (let eventData of this._events.values()) {
eventData.subscriptions = eventData.subscriptions.filter(
subscriber => !subscriber.dst.addressedTo(address)
);
}
}
/**
@@ -116,9 +140,11 @@ export class SubscriptionController {
* Used when a link is closed to stop all active subscriptions.
* @param link - Link to stop sending events to.
*/
unsubscribe(link: Link) {
unsubscribeLink(link: Link) {
for (let eventData of this._events.values()) {
eventData.subscriptions.delete(link);
eventData.subscriptions = eventData.subscriptions.filter(
subscriber => subscriber.link !== link
);
}
}
@@ -138,16 +164,23 @@ export class SubscriptionController {
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
}
if (event.subscribe === false) {
eventData.subscriptions.delete(link);
const index = eventData.subscriptions.findLastIndex(subscriber => subscriber.dst.addressedTo(src));
if (index >= 0) {
eventData.subscriptions.splice(index, 1);
}
} else {
eventData.subscriptions.add(link);
if (!eventData.subscriptions.some(subscriber => subscriber.dst.addressedTo(src))) {
eventData.subscriptions.push({ link: link, dst: src });
}
if (eventData.subscriptionUpdate) {
const eventReplay = await eventData.subscriptionUpdate(event, src, dst);
if (eventReplay) {
link.send(eventReplay);
link.sendTo(src, eventReplay);
return true;
}
}
}
return false;
}
}
@@ -157,9 +190,9 @@ export interface SubscribableValue {
isDeleted: boolean,
}
export interface EventSubscribable<T, V extends SubscribableValue> extends Event<T> {
export type EventSubscribable<T, V extends SubscribableValue> = Event<T> & Partial<{
updates: V[],
}
}>
/**
* Component for subscribing to and tracking updates of a remote resource
@@ -167,43 +200,61 @@ export interface EventSubscribable<T, V extends SubscribableValue> extends Event
*/
export class EventSubscriber<
T extends EventSubscribable<T, V>,
K extends string | number = T["updates"][number]["id"],
V extends SubscribableValue = T["updates"][number],
K extends string | number = NonNullable<T["updates"]>[number]["id"],
V extends SubscribableValue = NonNullable<T["updates"]>[number],
> {
_callbacks = new Array<EventSubscriberCallback<V>>();
/** The time at which an event was last received, is less than 0 when there have been no events */
lastResponseTimeMs = -1;
/** Values of the subscribed resource */
values = new Map<K, V>();
/** True if this subscriber is currently synced with the source */
synced = false;
_snapshot?: readonly [ReadonlyMap<K, Readonly<V>>, boolean];
_snapshotLastUpdatedMs = 0;
lastResponseTimeMs = -1;
/** True if this subscriber is expecting to receive updates from the source */
_syncing = false;
/** Repeat calls to getSnapshot will return the same readonly copy unless values has updated */
_snapshot: readonly [ReadonlyMap<K, Readonly<V>>, boolean] = [new Map<K, V>(), false];
_snapshotLastUpdatedMs = -1;
/** Callbacks will be called when an event is received or the synced state changes */
_callbacks = new Array<EventSubscriberCallback<T>>();
constructor(
private Event: EventClass<T>,
public control: Link,
) {
control.handle(Event, this._handle.bind(this));
control.handle(Event, this._handleEvent.bind(this));
control.connector.on("connect", () => {
this._updateSubscription();
this.handleConnectionEvent("connect");
});
control.connector.on("close", () => {
if (this.synced) {
this.synced = false;
for (let callback of this._callbacks) {
callback([], this.synced);
}
}
this.handleConnectionEvent("close");
});
}
/**
* Handle connection events from this connector or another in the chain to the controller
* This should be called by an instance plugin during onControllerConnectionEvent
* @param event - event type that occurred
*/
handleConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
if (event === "connect" || event === "resume") {
this._updateSubscription();
} else if (this.synced) {
this.synced = false;
for (let callback of this._callbacks) {
callback(null, false);
}
}
}
/**
* Handle incoming events and distribute it to the correct callbacks
* @param event - event from subscribed resource
* @internal
*/
async _handle(event: EventSubscribable<T, V>) {
for (const value of event.updates) {
async _handleEvent(event: T) {
// We support the automatic maintaining of a map for events with the updates property
const updates = event.updates ?? [];
for (const value of updates) {
this.lastResponseTimeMs = Math.max(this.lastResponseTimeMs, value.updatedAtMs);
// Warn about updates which changes content but don't update updatedAtMs
const existing = this.values.get(value.id as K);
@@ -219,8 +270,13 @@ export class EventSubscriber<
this.values.set(value.id as K, value);
}
}
for (let callback of this._callbacks) {
callback(event.updates, this.synced);
// Event handling logic
if (this._syncing && !this.synced) {
this._snapshotLastUpdatedMs = -1;
this.synced = true;
}
for (const callback of this._callbacks) {
callback(event, this.synced);
}
}
@@ -228,10 +284,10 @@ export class EventSubscriber<
* Subscribe to receive all event notifications
* @param handler -
* callback invoked whenever the subscribed resource changes or the
* synced property changes, in which case the updates will be empty.
* synced property changes, in which case the event will be null.
* @returns function that will unsubscribe from notifications
*/
subscribe(handler: EventSubscriberCallback<V>) {
subscribe(handler: EventSubscriberCallback<T>) {
this._callbacks.push(handler);
if (this._callbacks.length === 1) {
this._updateSubscription();
@@ -277,26 +333,30 @@ export class EventSubscriber<
* Update the subscription with the controller based on current handler counts
*/
async _updateSubscription() {
if (!(this.control.connector as WebSocketClientConnector).connected) {
if (!this.control.connector.valid) {
return;
}
const entry = Link._eventsByClass.get(this.Event)!;
try {
await this.control.send(new SubscriptionRequest(
this._syncing = this._callbacks.length > 0;
const updatesSent = await this.control.sendTo("controller", new SubscriptionRequest(
entry.name,
this._callbacks.length > 0,
this._syncing,
this.lastResponseTimeMs
));
this.synced = this._callbacks.length > 0;
this._snapshotLastUpdatedMs = 0;
for (let callback of this._callbacks) {
callback([], this.synced);
if (!updatesSent) {
this.synced = this._syncing;
for (const callback of this._callbacks) {
callback(null, this.synced);
}
}
} catch (err: any) {
logger.error(
`Unexpected error updating ${entry.name} subscription:\n${err.stack}`
);
if (!(err instanceof RequestError) || err.code !== "SessionLost") {
logger.error(
`Unexpected error updating ${entry.name} subscription:\n${err.stack}`
);
}
}
}
}
-1
View File
@@ -26,6 +26,5 @@
],
"compilerOptions": {
"rootDir": ".",
"outDir": "dist/browser",
}
}
-1
View File
@@ -2,7 +2,6 @@
"extends": "../../tsconfig.node.json",
"include": ["index.ts", "browser.ts", "build_mod.js", "src/**/*.ts", "src/**/*.js"],
"compilerOptions": {
"outDir": "dist/node",
"allowJs": true,
},
}
+1 -1
View File
@@ -40,7 +40,7 @@
"style-loader": "^3.3.3",
"swc-loader": "^0.2.6",
"terser-webpack-plugin": "^5.3.9",
"typescript": "^5.1.6",
"typescript": "^5.5.3",
"util": "^0.12.5",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
@@ -9,7 +9,7 @@ import { useAccount } from "../model/account";
import { useHosts } from "../model/host";
import InstanceStatusTag from "./InstanceStatusTag";
import StartStopInstanceButton from "./StartStopInstanceButton";
import { InstanceDetails } from "@clusterio/lib";
import { InstanceDetails, integerFactorioVersion } from "@clusterio/lib";
import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -46,6 +46,13 @@ export default function InstanceList(props: InstanceListProps) {
return `${host.publicAddress}:${instance.gamePort}`;
}
function integerFactorioVersionOrDefault(instance: InstanceDetails) {
if (instance.factorioVersion === undefined) {
return -1;
}
return integerFactorioVersion(instance.factorioVersion);
}
let columns: ColumnsType<InstanceDetails> = [
{
title: "Name",
@@ -86,6 +93,13 @@ export default function InstanceList(props: InstanceListProps) {
sorter: (a, b) => strcmp(instancePublicAddress(a), instancePublicAddress(b)),
responsive: ["lg"],
},
{
title: "Factorio Version",
key: "version",
render: (_, instance) => instance.factorioVersion ?? "unknown",
sorter: (a, b) => integerFactorioVersionOrDefault(a) - integerFactorioVersionOrDefault(b),
responsive: ["xl"],
},
{
title: "Status",
key: "status",
@@ -1,7 +1,6 @@
import React, { useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Descriptions, Dropdown, Menu, MenuProps, Modal, Space, Spin, Typography } from "antd";
import type { ItemType } from "antd/es/menu/hooks/useItems";
import { Alert, Button, Descriptions, Dropdown, MenuProps, Modal, Space, Spin, Typography } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import DownOutlined from "@ant-design/icons/DownOutlined";
@@ -25,6 +24,7 @@ import { useHost } from "../model/host";
import InstanceStatusTag from "./InstanceStatusTag";
import Link from "./Link";
type MenuItem = Required<MenuProps>["items"][number];
const { Title } = Typography;
type InstanceDescriptionProps = {
@@ -59,6 +59,9 @@ function InstanceDescription(props: InstanceDescriptionProps) {
buttonContent={assigned ? "Reassign" : "Assign"}
/>}
</Descriptions.Item>
<Descriptions.Item label="Version">
{instance.factorioVersion ?? "unknown"}
</Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance.status!} /></Descriptions.Item>
</Descriptions>;
}
@@ -71,7 +74,7 @@ function InstanceButtons(props: { instance: lib.InstanceDetails }) {
let instance = props.instance;
let instanceId = instance.id!;
let instanceButtonMenuItems: ItemType[] = [];
let instanceButtonMenuItems: MenuItem[] = [];
if (account.hasPermission("core.instance.export_data")) {
instanceButtonMenuItems.push({
disabled: exportingData || instance.status !== "stopped",
+48 -55
View File
@@ -236,62 +236,55 @@ export default function ModsPage() {
action={`${webRoot}api/upload-mod`}
showUploadList={false}
>
<div
style={{
position: "relative",
zIndex: "1010",
<Dropzone />
<Table
columns={[
{
title: "Name",
dataIndex: "title",
defaultSortOrder: "ascend",
sorter: (a, b) => (
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
),
},
{
title: "Version",
key: "version",
align: "right",
render: (_, mod) => <>
{`${mod.version} `}
<Typography.Text type="secondary">{`/ ${mod.factorioVersion}`}</Typography.Text>
</>,
},
{
title: "Filename",
dataIndex: "filename",
responsive: ["xl"],
sorter: (a, b) => strcmp(a.filename, b.filename),
},
{
title: "Size",
key: "size",
responsive: ["lg"],
render: (_, mod) => lib.formatBytes(mod.size),
align: "right",
sorter: (a, b) => a.size - b.size,
},
{
title: "Action",
key: "action",
responsive: ["lg"],
render: (_, mod) => actions(mod),
},
]}
expandable={{
expandedRowRender: (mod: lib.ModInfo) => <ModDetails mod={mod} actions={actions} />,
expandedRowClassName: () => "no-expanded-padding",
}}
>
<Dropzone />
<Table
columns={[
{
title: "Name",
dataIndex: "title",
defaultSortOrder: "ascend",
sorter: (a, b) => (
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
),
},
{
title: "Version",
key: "version",
align: "right",
render: (_, mod) => <>
{`${mod.version} `}
<Typography.Text type="secondary">{`/ ${mod.factorioVersion}`}</Typography.Text>
</>,
},
{
title: "Filename",
dataIndex: "filename",
responsive: ["xl"],
sorter: (a, b) => strcmp(a.filename, b.filename),
},
{
title: "Size",
key: "size",
responsive: ["lg"],
render: (_, mod) => lib.formatBytes(mod.size),
align: "right",
sorter: (a, b) => a.size - b.size,
},
{
title: "Action",
key: "action",
responsive: ["lg"],
render: (_, mod) => actions(mod),
},
]}
expandable={{
expandedRowRender: (mod: lib.ModInfo) => <ModDetails mod={mod} actions={actions} />,
expandedRowClassName: () => "no-expanded-padding",
}}
dataSource={[...mods.values()]}
pagination={false}
rowKey={mod => mod.id}
/>
</div>
dataSource={[...mods.values()]}
pagination={false}
rowKey={mod => mod.id}
/>
</Upload.Dragger>
<PluginExtra component="ModsPage" />
</PageLayout>;
@@ -1,13 +1,19 @@
import React from "react";
import { Space, Typography } from "antd";
const { Text } = Typography;
type PageHeaderProps = {
title: string|React.ReactElement;
title: string | React.ReactElement;
subTitle?: string | React.ReactElement;
extra?: React.JSX.Element;
};
export default function PageHeader(props: PageHeaderProps): React.JSX.Element {
return <div className="page-header">
<h2 className="page-header-title">{props.title}</h2>
<Space>
<h2 className="page-header-title">{props.title}</h2>
{props.subTitle && <Text type="secondary">{props.subTitle}</Text>}
</Space>
{props.extra && <div className="page-header-extra">{props.extra}</div>}
</div>;
}
@@ -6,6 +6,7 @@ import type { PluginWebApi } from "@clusterio/lib";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PageHeader from "./PageHeader";
import notify from "../util/notify";
export default function PluginViewPage() {
@@ -39,7 +40,7 @@ export default function PluginViewPage() {
let pluginMeta = pluginList.find(p => p.name === pluginName);
if (!pluginMeta) {
return <PageLayout nav={nav}>
<h2>Plugin not found</h2>
<PageHeader title="Plugin not found" />
<p>Plugin with name {pluginName} was not found on the controller.</p>
</PageLayout>;
}
@@ -9,6 +9,7 @@ import type { PluginWebApi } from "@clusterio/lib";
import notify from "../util/notify";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PageHeader from "./PageHeader";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -47,7 +48,7 @@ export default function PluginsPage() {
}
return <PageLayout nav={[{ name: "Plugins" }]}>
<h2>Plugins</h2>
<PageHeader title="Plugins" />
<Table
columns={[
{
+2 -9
View File
@@ -357,15 +357,8 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
{
account.hasPermission("core.instance.save.upload")
? <Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
<div
style={{
position: "relative",
zIndex: "1010",
}}
>
<Dropzone disabled={hostOffline} />
{saveTable}
</div>
<Dropzone disabled={hostOffline} />
{saveTable}
</Upload.Dragger>
: saveTable
}
@@ -11,6 +11,8 @@ import ControlContext from "./ControlContext";
import { pages } from "../pages";
import { DraggingContext } from "../model/is_dragging";
type MenuItem = Required<MenuProps>["items"][number];
const { Header, Sider } = Layout;
function isActiveDropzone(element: HTMLElement | null): boolean {
@@ -63,7 +65,7 @@ export default function SiteLayout() {
combinedPages.push(...plugin.pages);
}
let menuItems = [];
let menuItems: MenuItem[] = [];
let menuGroups = new Map();
for (let { sidebarName, sidebarGroup, permission, path } of combinedPages) {
if (
+1 -1
View File
@@ -1,4 +1,4 @@
/// <reference path="../global.d.ts" />
/// <reference path="../global.d.ts" preserve="true" />
export { default as bootstrap } from "./bootstrap";
export { default as notify, notifyErrorHandler } from "./util/notify";
export { default as BaseWebPlugin } from "./BaseWebPlugin";
-3
View File
@@ -4,7 +4,4 @@
{ "path": "../lib/tsconfig.browser.json" },
],
"include": [ "src/**/*.tsx", "src/**/*.ts", "package.json", "global.d.ts" ],
"compilerOptions": {
"outDir": "dist/browser",
}
}
+1 -1
View File
@@ -25,7 +25,7 @@
"@clusterio/web_ui": "workspace:*",
"@types/node": "^20.4.5",
"@types/react": "^18.2.21",
"typescript": "^5.1.6",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
@@ -5,7 +5,4 @@
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
"compilerOptions": {
"outDir": "dist/browser",
}
}
-3
View File
@@ -5,7 +5,4 @@
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+1 -1
View File
@@ -33,7 +33,7 @@
"antd": "^5.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.1.6",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
@@ -5,7 +5,4 @@
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
"compilerOptions": {
"outDir": "dist/browser",
}
}
@@ -5,7 +5,4 @@
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useState } from "react";
import { BaseWebPlugin, PageLayout, ControlContext } from "@clusterio/web_ui";
import { BaseWebPlugin, PageLayout, PageHeader, ControlContext } from "@clusterio/web_ui";
import { DatabaseStatsRequest, DatabaseStatsResponse } from "../messages";
import "./style.css";
@@ -18,7 +18,7 @@ function InventoryPage() {
}, []);
return <PageLayout nav={[{ name: "Inventory sync" }]}>
<h2>Inventory sync</h2>
<PageHeader title="Inventory sync" />
{statsData && <>
<p>Database size: {Math.round(statsData.databaseSize / 1000)}kB</p>
<p>Database entries: {statsData.databaseEntries}</p>
+1 -1
View File
@@ -36,7 +36,7 @@
"phin": "^3.7.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.1.6",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
@@ -5,7 +5,4 @@
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
"compilerOptions": {
"outDir": "dist/browser",
}
}
-3
View File
@@ -5,7 +5,4 @@
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+1 -1
View File
@@ -30,7 +30,7 @@
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.4.5",
"@types/react": "^18.2.21",
"typescript": "^5.1.6",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
@@ -5,7 +5,4 @@
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
"compilerOptions": {
"outDir": "dist/browser",
}
}
-3
View File
@@ -5,7 +5,4 @@
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+1 -1
View File
@@ -25,7 +25,7 @@
"@clusterio/web_ui": "workspace:*",
"@types/node": "^20.4.5",
"@types/react": "^18.2.21",
"typescript": "^5.1.6",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
@@ -5,7 +5,4 @@
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
"compilerOptions": {
"outDir": "dist/browser",
}
}
@@ -5,7 +5,4 @@
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+1 -1
View File
@@ -34,7 +34,7 @@
"antd": "^5.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.1.6",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0"
@@ -5,7 +5,4 @@
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
"compilerOptions": {
"outDir": "dist/browser",
}
}
@@ -5,7 +5,4 @@
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { Input, Table, Typography } from "antd";
import * as lib from "@clusterio/lib";
import {
BaseWebPlugin, PageLayout, Control, ControlContext,
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext,
notifyErrorHandler, useItemMetadata, useLocale,
} from "@clusterio/web_ui";
import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages";
@@ -60,7 +60,7 @@ function StoragePage() {
let numberFormat = new Intl.NumberFormat("en-US");
return <PageLayout nav={[{ name: "Storage" }]}>
<h2>Storage</h2>
<PageHeader title="Storage" />
<Paragraph>
<Input
placeholder="Search"
+1
View File
@@ -2,4 +2,5 @@ packages:
- "packages/*"
- "plugins/*"
- "external_plugins/*"
- "external_plugins/*/*"
- "test/"
+132 -3
View File
@@ -1,14 +1,36 @@
"use strict";
const assert = require("assert").strict;
const { Controller } = require("@clusterio/controller");
const { ControllerConfig } = require("@clusterio/lib");
const { ControllerConfig, Address, RequestError } = require("@clusterio/lib");
const { EventEmitter } = require("stream");
class MockEvent {}
class MockInstanceConfig extends EventEmitter {
constructor(data = new Map()) {
super();
this._data = data;
}
get(field) {
return this._data.get(field);
}
set(field, value) {
return this._data.set(field, value);
}
}
describe("controller/src/Controller", function() {
describe("class Controller", function() {
/** @type {Controller} */
let controller;
before(function() {
let controller, mockInstanceConfig;
before(async function() {
controller = new Controller({}, [], "", new ControllerConfig("controller"));
mockInstanceConfig = new MockInstanceConfig(new Map([
["instance.id", 100], ["instance.name", "test"], ["factorio.settings", []],
]));
await controller.instanceCreate(mockInstanceConfig);
});
function check(list, ip, present = true) {
if (present === false) {
@@ -50,5 +72,112 @@ describe("controller/src/Controller", function() {
check(list, "192.168.0.18", false);
});
});
describe(".sendRequest()", function() {
it("should error on loopback", function() {
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand("controller")),
new Error(`Message would return back to sender ${Address.fromShorthand("controller")}.`)
);
});
it("should error on invalid controller", function() {
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ controlId: 99 })),
new RequestError("Target control connection does not exist.")
);
});
it("should error on invalid instance", function() {
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ instanceId: 99 })),
new RequestError("Instance with ID 99 does not exist")
);
});
it("should error on unassigned instance", function() {
mockInstanceConfig.set("instance.assigned_host", null);
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
new RequestError("Instance is not assigned to a host")
);
});
it("should error on instance with disconnected host", function() {
mockInstanceConfig.set("instance.assigned_host", 99);
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
new RequestError("Host containing instance is not connected")
);
});
it("should error on invalid host", function() {
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ hostId: 99 })),
new RequestError("Host is not connected")
);
});
it("should error on invalid broadcast", function() {
// This is different to sendEvent because requests do not support broadcasting
assert.throws(
() => controller.sendRequest(new MockEvent(), new Address(Address.broadcast, 99)),
new Error(`Unknown address type ${Address.broadcast}`)
);
});
it("should error on invalid address", function() {
assert.throws(
() => controller.sendRequest(new MockEvent(), new Address(99, 99)),
new Error("Unknown address type 99")
);
});
});
describe(".sendEvent()", function() {
it("should error on loopback", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand("controller")),
new Error(`Message would return back to sender ${Address.fromShorthand("controller")}.`)
);
});
it("should error on invalid controller", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ controlId: 99 })),
new Error("Target control connection does not exist.")
);
});
it("should error on invalid instance", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ instanceId: 99 })),
new Error("Instance with ID 99 does not exist")
);
});
it("should error on unassigned instance", function() {
mockInstanceConfig.set("instance.assigned_host", null);
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
new Error("Instance is not assigned to a host")
);
});
it("should error on instance with disconnected host", function() {
mockInstanceConfig.set("instance.assigned_host", 99);
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
new Error("Host containing instance is not connected")
);
});
it("should error on invalid host", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ hostId: 99 })),
new Error("Host is not connected")
);
});
it("should error on invalid broadcast", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), new Address(Address.broadcast, 99)),
new Error("Unexpected broadcast target 99")
);
});
it("should error on invalid address", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), new Address(99, 99)),
new Error("Unknown address type 99")
);
});
});
});
});
+4
View File
@@ -0,0 +1,4 @@
"use strict";
if (module === require.main) {
process.stdout.write(JSON.stringify(process.argv.slice(2)));
}
+71
View File
@@ -0,0 +1,71 @@
"use strict";
const child_process = require("child_process");
const util = require("util");
const assert = require("assert").strict;
const path = require("path");
const { escapeArg } = require("../../packages/create/escape_arg");
const execFile = util.promisify(child_process.execFile);
async function exec(file, args) {
const { stdout, stderr } = await execFile(
file,
args.map(escapeArg),
// eslint-disable-next-line node/no-process-env
{ shell: true, cwd: __dirname, env: { ...process.env, pct: "%"} }
);
return { stdout: JSON.parse(stdout), stderr };
}
const strings = [
'"a"',
"a sentence of text.",
" irregular \tspaces ",
'"C:\\path\\"',
"C:\\path\\",
'\\ "\\" \\\\ "\\\\" \\\\\\ "\\\\\\" \\\\\\\\ "\\\\\\\\" \\\\\\\\\\ "\\\\\\\\\\" \\\\\\\\\\\ "\\\\\\\\\\\\"',
'"C:\\path"',
"C:\\path",
"%OS%",
"!OS!",
"😮",
];
for (let i=1; i < 128; i++) {
const char = String.fromCodePoint(i);
// Skip newline and carriage return because those are difficult to escape
// Skip most alphanumeric characters as they don't behave differently to each other.
if (/[\n\r1-9B-Zb-z]/.test(char)) {
continue;
}
strings.push(char, `${char}${char}`, `${char}"`);
}
// Test 100 strings at a time.
const tests = [[]];
for (const string of strings) {
if (tests[tests.length - 1].length >= 100) {
tests.push([]);
}
tests[tests.length - 1].push(string);
}
describe("create/escape_arg", function() {
it("should correctly escape strings when args are forwarded", async function() {
const ext = process.platform === "win32" ? ".cmd" : ".sh";
for (const args of tests) {
assert.deepEqual(
await exec(`.${path.sep}forward_args${ext}`, ["echo_args.js", ...args]),
{ stdout: args, stderr: "" }
);
}
});
it("should correctly escape strings when invoking node directly", async function() {
for (const args of tests) {
assert.deepEqual(
await exec("node", ["echo_args.js", ...args]),
{ stdout: args, stderr: "" }
);
}
});
});
+3
View File
@@ -0,0 +1,3 @@
@ECHO off
REM this is how npm and package.json bin entries forwards arguments on Windows
node.exe %*
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# this is how package.json bin entries forwards arguments on Linux
exec node "$@"
+6
View File
@@ -206,6 +206,12 @@ describe("Integration of Clusterio", function() {
});
});
it("should auto start instances with auto_start enabled", async function() {
// On windows there's currently no way to automate graceful shutdown of the host
// process as CTRL+C is some weird terminal thing and SIGINT isn't a thing.
if (process.platform === "win32") {
this.skip();
}
slowTest(this);
let hostProcess;
+1 -1
View File
@@ -45,7 +45,7 @@ describe("Integration of create tool", function () {
assert.equal(hostConfig["host.name"], "local");
assert.equal(hostConfig["host.public_address"], "localhost");
assert.equal(hostConfig["host.factorio_directory"], "test/factorio");
assert.equal(hostConfig["host.controller_url"], "http://localhost:8099");
assert.equal(hostConfig["host.controller_url"], "http://localhost:8099/");
}).timeout(1200000);
it("should create a new controller installation", async function () {
this.skip();
+3 -1
View File
@@ -5,12 +5,14 @@ const path = require("path");
const { _exportLocale } = require("@clusterio/host/dist/node/src/export");
const { FactorioServer } = require("@clusterio/host/dist/node/src/server");
const { factorioDir } = require("./index");
describe("Integration of lib/factorio/export", function() {
describe("exportLocale()", function() {
it("returns a nested Map with base game locale information", async function() {
let writePath = path.join("temp", "test", "server");
let testServer = new FactorioServer("factorio", writePath, {});
let testServer = new FactorioServer(factorioDir, writePath, {});
await testServer.init();
let locale = await _exportLocale(testServer, new Map(), ["base"], "en");
+49 -5
View File
@@ -126,15 +126,35 @@ async function get(urlPath) {
return res;
}
function loadJSON(filePath) {
try {
// eslint-disable-next-line node/no-sync
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch (err) {
if (err.code === "ENOENT") {
return undefined;
}
throw err;
}
}
function getFactorioDir(hostConfig) {
const factorioDir = hostConfig?.["host.factorio_directory"] ?? "factorio";
return path.join(factorioDir);
}
let controllerProcess;
let hostProcess;
let control;
const baseHostConfig = loadJSON("config-host.json");
let url = "https://localhost:4443/";
let controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64"));
let instancesDir = path.join("temp", "test", "instances");
let modsDir = path.join("temp", "test", "mods");
let databaseDir = path.join("temp", "test", "database");
let factorioDir = getFactorioDir(baseHostConfig);
let pluginListPath = path.join("temp", "test", "plugin-list.json");
let controllerConfigPath = path.join("temp", "test", "config-controller.json");
let hostConfigPath = path.join("temp", "test", "config-host.json");
@@ -161,20 +181,30 @@ function getControl() {
}
function spawn(name, cmd, waitFor) {
// eslint-disable-next-line node/no-process-env
const silent = process.env.SILENT_TEST;
return new Promise((resolve, reject) => {
console.log(cmd);
let parts = cmd.split(" ");
let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test") });
let stdout = new LineSplitter({ readableObjectMode: true });
stdout.on("data", line => {
let stderr = new LineSplitter({ readableObjectMode: true });
let onDataOut = line => {
line = line.toString("utf8");
if (waitFor.test(line)) {
if (silent) {
stdout.off("data", onDataOut);
stderr.off("data", onDataErr);
}
resolve(process);
}
console.log(name, line);
});
let stderr = new LineSplitter({ readableObjectMode: true });
stderr.on("data", line => { console.log(name, line.toString("utf8")); });
};
let onDataErr = line => {
console.log(name, line.toString("utf8"));
};
stdout.on("data", onDataOut);
stderr.on("data", onDataErr);
process.stdout.pipe(stdout);
process.stderr.pipe(stderr);
});
@@ -183,10 +213,17 @@ function spawn(name, cmd, waitFor) {
before(async function() {
this.timeout(40000);
// eslint-disable-next-line node/no-process-env
const silent = process.env.SILENT_TEST;
if (silent) {
console.log("SILENT_TEST is present in env, loggers after bootstrap will be muted.");
}
// Some integration tests may cause log events
logger.add(new ConsoleTransport({
level: "info",
format: new lib.TerminalFormat(),
filter: () => !silent,
}));
// If fast test is enabled then output that it is
@@ -206,6 +243,7 @@ before(async function() {
await fs.ensureDir(path.join("temp", "test"));
console.log("Building Mods");
await fs.ensureDir(modsDir);
await lib.build({
build: true,
@@ -232,6 +270,7 @@ before(async function() {
}
await fs.copyFile(`mods/${ssZip}`, `${modsDir}/${ssZip}`);
console.log("Setting Controller Config");
await exec("node ../../packages/controller config set controller.auth_secret TestSecretDoNotUse");
await exec("node ../../packages/controller config set controller.http_port 8880");
await exec("node ../../packages/controller config set controller.https_port 4443");
@@ -240,12 +279,14 @@ before(async function() {
await exec("node ../../packages/controller config set controller.tls_certificate ../../test/file/tls/cert.pem");
await exec("node ../../packages/controller config set controller.tls_private_key ../../test/file/tls/key.pem");
console.log("Setting Controller Plugins");
await exec("node ../../packages/ctl plugin add ../../plugins/global_chat");
await exec("node ../../packages/ctl plugin add ../../plugins/research_sync");
await exec("node ../../packages/ctl plugin add ../../plugins/statistics_exporter");
await exec("node ../../packages/ctl plugin add ../../plugins/subspace_storage");
await exec("node ../../packages/ctl plugin add ../../plugins/player_auth");
console.log("Bootstrapping");
await exec("node ../../packages/controller bootstrap create-admin test");
await exec("node ../../packages/controller bootstrap create-ctl-config test");
await exec("node ../../packages/ctl control-config set control.tls_ca ../../test/file/tls/cert.pem");
@@ -253,7 +294,9 @@ before(async function() {
controllerProcess = await spawn("controller:", "node ../../packages/controller run", /Started controller/);
await execCtl("host create-config --id 4 --name host --generate-token");
await exec(`node ../../packages/host config set host.factorio_directory ${path.join("..", "..", "factorio")}`);
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
await exec(`node ../../packages/host config set host.factorio_directory ${relativeFactorioDir}`);
await exec("node ../../packages/host config set host.tls_ca ../../test/file/tls/cert.pem");
hostProcess = await spawn("host:", "node ../../packages/host run", /Started host/);
@@ -317,6 +360,7 @@ module.exports = {
instancesDir,
modsDir,
databaseDir,
factorioDir,
controllerConfigPath,
hostConfigPath,
controlConfigPath,
+3 -3
View File
@@ -8,13 +8,13 @@ const lib = require("@clusterio/lib");
const { FactorioServer, _getVersion } = require("@clusterio/host/dist/node/src/server");
const { logger } = lib;
const { slowTest } = require("./index");
const { slowTest, factorioDir } = require("./index");
describe("Integration of host/src/server", function() {
describe("_getVersion()", function() {
it("should get a version from factorio's changelog.txt", async function() {
let version = await _getVersion(path.join("factorio", "data", "changelog.txt"));
let version = await _getVersion(path.join(factorioDir, "data", "changelog.txt"));
if (!/^\d+\.\d+\.\d+$/.test(version)) {
assert.fail(`Detected version '${version}' does not followed the format x.y.z`);
}
@@ -23,7 +23,7 @@ describe("Integration of host/src/server", function() {
describe("class FactorioServer", function() {
let writePath = path.join("temp", "test", "integration");
let server = new FactorioServer("factorio", writePath, {});
let server = new FactorioServer(factorioDir, writePath, {});
let logFile;
before(async function() {
+83
View File
@@ -0,0 +1,83 @@
"use strict";
const assert = require("assert").strict;
const lib = require("@clusterio/lib");
const { Address } = lib;
describe("lib/data/messages_core", function() {
describe("class Address", function() {
const controller = new Address(Address.controller, 0);
const host1 = new Address(Address.host, 1);
const host2 = new Address(Address.host, 2);
const instance1 = new Address(Address.instance, 1);
const instance2 = new Address(Address.instance, 2);
const control1 = new Address(Address.control, 1);
const control2 = new Address(Address.control, 2);
const hostBr = new Address(Address.broadcast, Address.host);
const instanceBr = new Address(Address.broadcast, Address.instance);
const controlBr = new Address(Address.broadcast, Address.control);
it("Should correctly resolve addressedTo", function() {
assert(controller.addressedTo(controller));
assert(!controller.addressedTo(host1));
assert(!controller.addressedTo(instance1));
assert(!controller.addressedTo(control1));
assert(!controller.addressedTo(hostBr));
assert(!controller.addressedTo(instanceBr));
assert(!controller.addressedTo(controlBr));
assert(!host1.addressedTo(controller));
assert(host1.addressedTo(host1));
assert(!host1.addressedTo(host2));
assert(!host1.addressedTo(instance1));
assert(!host1.addressedTo(control1));
assert(!host1.addressedTo(hostBr));
assert(!host1.addressedTo(instanceBr));
assert(!host1.addressedTo(controlBr));
assert(!instance1.addressedTo(controller));
assert(!instance1.addressedTo(host1));
assert(instance1.addressedTo(instance1));
assert(!instance1.addressedTo(instance2));
assert(!instance1.addressedTo(control1));
assert(!instance1.addressedTo(hostBr));
assert(!instance1.addressedTo(instanceBr));
assert(!instance1.addressedTo(controlBr));
assert(!control1.addressedTo(controller));
assert(!control1.addressedTo(host1));
assert(!control1.addressedTo(instance1));
assert(control1.addressedTo(control1));
assert(!control1.addressedTo(control2));
assert(!control1.addressedTo(hostBr));
assert(!control1.addressedTo(instanceBr));
assert(!control1.addressedTo(controlBr));
assert(!hostBr.addressedTo(controller));
assert(hostBr.addressedTo(host1));
assert(hostBr.addressedTo(host2));
assert(!hostBr.addressedTo(instance1));
assert(!hostBr.addressedTo(control1));
assert(!hostBr.addressedTo(hostBr));
assert(!hostBr.addressedTo(instanceBr));
assert(!hostBr.addressedTo(controlBr));
assert(!instanceBr.addressedTo(controller));
assert(!instanceBr.addressedTo(host1));
assert(instanceBr.addressedTo(instance1));
assert(instanceBr.addressedTo(instance2));
assert(!instanceBr.addressedTo(control1));
assert(!instanceBr.addressedTo(hostBr));
assert(!instanceBr.addressedTo(instanceBr));
assert(!instanceBr.addressedTo(controlBr));
assert(!controlBr.addressedTo(controller));
assert(!controlBr.addressedTo(host1));
assert(!controlBr.addressedTo(instance1));
assert(controlBr.addressedTo(control1));
assert(controlBr.addressedTo(control2));
assert(!controlBr.addressedTo(hostBr));
assert(!controlBr.addressedTo(instanceBr));
assert(!controlBr.addressedTo(controlBr));
});
});
});
+109 -52
View File
@@ -101,13 +101,13 @@ describe("lib/subscriptions", function() {
const connectorSetupDate = [{
id: 0,
request: new lib.SubscriptionRequest(RegisteredEvent.name, true),
src: addr({ controlId: 0 }),
dst: addr("controller"),
dst: addr({ controlId: 0 }),
src: addr("controller"),
}, {
id: 1,
request: new lib.SubscriptionRequest(RegisteredEvent.name, true),
src: addr({ controlId: 1 }),
dst: addr("controller"),
dst: addr({ controlId: 1 }),
src: addr("controller"),
}];
function getLink(id) {
@@ -163,7 +163,7 @@ describe("lib/subscriptions", function() {
subscriptions.handle(RegisteredEvent);
for (let connectorData of connectorSetupDate) {
subscriptions.handleRequest(
getLink(connectorData.id), connectorData.request, connectorData.src, connectorData.dst
getLink(connectorData.id), connectorData.request, connectorData.dst, connectorData.src
);
}
});
@@ -190,7 +190,7 @@ describe("lib/subscriptions", function() {
const connectorData = connectorSetupDate[0];
const request = new lib.SubscriptionRequest(RegisteredEvent.name, false);
await subscriptions.handleRequest(
getLink(connectorData.id), request, connectorData.src, connectorData.dst,
getLink(connectorData.id), request, connectorData.dst, connectorData.src
);
subscriptions.broadcast(new RegisteredEvent());
await onceConnectorSend(1);
@@ -212,14 +212,22 @@ describe("lib/subscriptions", function() {
subscriptions.handle(RegisteredEvent);
for (let connectorData of connectorSetupDate) {
subscriptions.handleRequest(
getLink(connectorData.id), connectorData.request, connectorData.src, connectorData.dst
getLink(connectorData.id), connectorData.request, connectorData.dst, connectorData.src
);
}
});
it("should remove an active subscription", async function() {
it("should remove an active subscription link", async function() {
const link = getLink(0);
subscriptions.unsubscribe(link);
subscriptions.unsubscribeLink(link);
subscriptions.broadcast(new RegisteredEvent());
await onceConnectorSend(1);
assertNoEvent(0);
assertLastEvent(1, RegisteredEvent);
});
it("should remove an active subscription address", async function() {
const link = getLink(0);
subscriptions.unsubscribeAddress(connectorSetupDate[0].dst);
subscriptions.broadcast(new RegisteredEvent());
await onceConnectorSend(1);
assertNoEvent(0);
@@ -251,7 +259,7 @@ describe("lib/subscriptions", function() {
it("should accept a subscription", async function() {
const request = new lib.SubscriptionRequest(RegisteredEvent.name, true);
await subscriptions.handleRequest(
getLink(0), request, connectorSetupDate[0].src, connectorSetupDate[0].dst
getLink(0), request, connectorSetupDate[0].dst, connectorSetupDate[0].src
);
subscriptions.broadcast(new RegisteredEvent());
await onceConnectorSend(0);
@@ -260,7 +268,7 @@ describe("lib/subscriptions", function() {
it("should accept an unsubscription", async function() {
const request = new lib.SubscriptionRequest(RegisteredEvent.name, true);
await subscriptions.handleRequest(
getLink(0), request, connectorSetupDate[0].src, connectorSetupDate[0].dst
getLink(0), request, connectorSetupDate[0].dst, connectorSetupDate[0].src
);
subscriptions.broadcast(new RegisteredEvent());
await onceConnectorSend(0);
@@ -271,7 +279,7 @@ describe("lib/subscriptions", function() {
const unsubRequest = new lib.SubscriptionRequest(RegisteredEvent.name, false);
await subscriptions.handleRequest(
getLink(0), unsubRequest, connectorSetupDate[0].src, connectorSetupDate[0].dst
getLink(0), unsubRequest, connectorSetupDate[0].dst, connectorSetupDate[0].src
);
subscriptions.broadcast(new RegisteredEvent());
assertNoEvent(0);
@@ -283,7 +291,7 @@ describe("lib/subscriptions", function() {
const request = new lib.SubscriptionRequest(StringPermissionEvent.name, true);
await subscriptions.handleRequest(
getLink(0), request, connectorSetupDate[0].src, connectorSetupDate[0].dst
getLink(0), request, connectorSetupDate[0].dst, connectorSetupDate[0].src
);
await onceConnectorSend(0);
assertLastEvent(0, StringPermissionEvent);
@@ -301,7 +309,7 @@ describe("lib/subscriptions", function() {
const request = new lib.SubscriptionRequest(StringPermissionEvent.name, true);
await subscriptions.handleRequest(
getLink(0), request, connectorSetupDate[0].src, connectorSetupDate[0].dst
getLink(0), request, connectorSetupDate[0].dst, connectorSetupDate[0].src
);
await new Promise(resolve => setImmediate(resolve));
assertNoEvent(0);
@@ -319,12 +327,12 @@ describe("lib/subscriptions", function() {
registeredEvent = new lib.EventSubscriber(RegisteredEvent, mockControl);
});
function assertLastRequest(request) {
const Request = request.constructor;
function assertLastMessage(message) {
const Message = message.constructor;
const lastMessage = mockControl.connector.sentMessages.at(-1);
const name = Request.plugin ? `${Request.plugin}:${Request.name}` : Request.name;
const name = Message.plugin ? `${Message.plugin}:${Message.name}` : Message.name;
assert.equal(lastMessage.name, name);
assert.deepEqual(lastMessage.data, request);
assert.deepEqual(lastMessage.data, message);
}
function assertNoMessageSent() {
@@ -348,19 +356,19 @@ describe("lib/subscriptions", function() {
let calledWith = null;
let calledWithTwo = null;
const item = { id: 1, updatedAtMs: 1, isDeleted: false };
const event = new RegisteredEvent([item]);
registeredEvent.subscribe(function(updates, synced) { calledWith = [updates, synced]; });
registeredEvent.subscribe(function(updates, synced) { calledWithTwo = [updates, synced]; });
await registeredEvent._handle(event);
assert.deepEqual(calledWith, [[item], false]);
assert.deepEqual(calledWithTwo, [[item], false]);
const eventUpdate = new RegisteredEvent([item]);
registeredEvent.subscribe(function(event, synced) { calledWith = [event, synced]; });
registeredEvent.subscribe(function(event, synced) { calledWithTwo = [event, synced]; });
await registeredEvent._handleEvent(eventUpdate);
assert.deepEqual(calledWith, [eventUpdate, true]);
assert.deepEqual(calledWithTwo, [eventUpdate, true]);
});
it("should allow unsubscribing from an event", async function() {
let called = false;
let event = new RegisteredEvent();
function callback() { called = true; }
const unsubscribe = registeredEvent.subscribe(callback);
await registeredEvent._handle(event);
await registeredEvent._handleEvent(event);
assert.deepEqual(called, true);
called = false;
@@ -368,7 +376,7 @@ describe("lib/subscriptions", function() {
unsubscribe();
// Wait for unsubscribe to happen.
await new Promise(resolve => setImmediate(resolve));
await registeredEvent._handle(event);
await registeredEvent._handleEvent(event);
assert.equal(called, false);
});
it("should not throw error on incorrectly unsubscribing multiple times", async function() {
@@ -390,13 +398,13 @@ describe("lib/subscriptions", function() {
const update3 = [{ id: 1, updatedAtMs: 4, isDeleted: true }];
const snap0 = registeredEvent.getSnapshot();
assert.deepEqual(snap0, [new Map(), false]);
await registeredEvent._handle(new RegisteredEvent(update1));
await registeredEvent._handleEvent(new RegisteredEvent(update1));
const snap1 = registeredEvent.getSnapshot();
assert.deepEqual(snap1, [new Map([[1, update1[0]]]), false]);
await registeredEvent._handle(new RegisteredEvent(update2));
await registeredEvent._handleEvent(new RegisteredEvent(update2));
const snap2 = registeredEvent.getSnapshot();
assert.deepEqual(snap2, [new Map([[1, update2[0]], [2, update2[1]]]), false]);
await registeredEvent._handle(new RegisteredEvent(update3));
await registeredEvent._handleEvent(new RegisteredEvent(update3));
const snap3 = registeredEvent.getSnapshot();
assert.deepEqual(snap3, [new Map([[2, update2[1]]]), false]);
// validate previous snapshots were not modified
@@ -406,59 +414,108 @@ describe("lib/subscriptions", function() {
});
});
describe("handleConnectionEvent", function() {
it("should be called on connector connect event", async function() {
registeredEvent.subscribe(() => {});
await events.once(mockControl.connector, "send");
mockControl.connector.sentMessages.pop();
registeredEvent.control.connector.emit("connect");
await events.once(mockControl.connector, "send");
assertLastMessage(new lib.SubscriptionRequest(RegisteredEvent.name, true, -1));
mockControl.connector.sentMessages.pop();
registeredEvent.handleConnectionEvent("connect");
await events.once(mockControl.connector, "send");
assertLastMessage(new lib.SubscriptionRequest(RegisteredEvent.name, true, -1));
mockControl.connector.sentMessages.pop();
registeredEvent.handleConnectionEvent("resume");
await events.once(mockControl.connector, "send");
assertLastMessage(new lib.SubscriptionRequest(RegisteredEvent.name, true, -1));
});
it("should set synced to false on connection close", async function() {
registeredEvent.synced = true;
registeredEvent.control.connector.emit("close");
assert(!registeredEvent.synced, "synced was not set to false during close event");
registeredEvent.synced = true;
registeredEvent.handleConnectionEvent("drop");
assert(!registeredEvent.synced, "synced was not set to false during drop");
registeredEvent.synced = true;
registeredEvent.handleConnectionEvent("close");
assert(!registeredEvent.synced, "synced was not set to false during close");
});
it("should invoke callbacks on connector close", async function() {
let calledWith;
registeredEvent.subscribe((values, synced) => { calledWith = [values, synced]; });
const [msg] = await events.once(mockControl.connector, "send");
mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src,
lib.SubscriptionRequest.Response.fromJSON(false)
));
await new Promise(resolve => setImmediate(resolve));
assert(registeredEvent.synced, "synced was not set to true before close");
calledWith = undefined;
registeredEvent.control.connector.emit("close");
assert(!registeredEvent.synced, "synced was not set to false");
assert(calledWith !== undefined, "callback not called");
assert.deepEqual(calledWith, [null, false]);
});
});
describe("_updateSubscription()", function() {
it("should do nothing if not connected", async function() {
registeredEvent.control.connector.connected = false;
it("should do nothing if connection is not valid", async function() {
registeredEvent.control.connector.valid = false;
registeredEvent.subscribe(() => true);
await new Promise(resolve => setImmediate(resolve));
assertNoMessageSent();
});
it("should correctly request a subscription", async function() {
const expected = new lib.SubscriptionRequest(RegisteredEvent.name, true, -1);
registeredEvent.subscribe(() => true);
await events.once(mockControl.connector, "send");
assertLastRequest(expected);
assertLastMessage(new lib.SubscriptionRequest(RegisteredEvent.name, true, -1));
});
it("should correctly request no subscriptions", async function() {
const expected = new lib.SubscriptionRequest(RegisteredEvent.name, false, -1);
it("should correctly request an unsubscription", async function() {
function callback() {}
const unsubscribe = registeredEvent.subscribe(callback);
await events.once(mockControl.connector, "send");
unsubscribe();
await events.once(mockControl.connector, "send");
assertLastRequest(expected);
assertLastMessage(new lib.SubscriptionRequest(RegisteredEvent.name, false, -1));
});
it("should set synced and invoke callbacks when request is responded to", async function() {
let calledWith;
registeredEvent.subscribe((values, synced) => { calledWith = [values, synced]; });
const [msg] = await events.once(mockControl.connector, "send");
assert(!registeredEvent.synced, "synced was set before response");
mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src));
mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src,
lib.SubscriptionRequest.Response.fromJSON(false)
));
await new Promise(resolve => setImmediate(resolve));
assert(calledWith !== undefined, "callback not called");
assert.deepEqual(calledWith, [[], true]);
assert.deepEqual(calledWith, [null, true]);
assert(registeredEvent.synced, "synced was not set");
});
it("should be called on connector connect event", async function() {
const expected = new lib.SubscriptionRequest(RegisteredEvent.name, true, -1);
registeredEvent._callbacks.push(() => {});
registeredEvent.control.connector.emit("connect");
await events.once(mockControl.connector, "send");
assertLastRequest(expected);
});
it("should revert synced and invoke callbacks on connector close event", async function() {
it("should set synced and invoke callbacks when request is responded to with data", async function() {
let calledWith;
registeredEvent.subscribe((values, synced) => { calledWith = [values, synced]; });
const [msg] = await events.once(mockControl.connector, "send");
mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src));
assert(!registeredEvent.synced, "synced was set before response");
mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src,
lib.SubscriptionRequest.Response.fromJSON(true)
));
await new Promise(resolve => setImmediate(resolve));
assert(registeredEvent.synced, "synced was not set before close");
calledWith = undefined;
registeredEvent.control.connector.emit("close");
assert(!registeredEvent.synced, "synced was set before update");
assert(calledWith === undefined, "callback called before update");
const eventUpdate = new RegisteredEvent();
registeredEvent._handleEvent(eventUpdate);
assert(registeredEvent.synced, "synced was not set");
assert(calledWith !== undefined, "callback not called");
assert.deepEqual(calledWith, [[], false]);
assert(!registeredEvent.synced, "synced was not reset");
assert.deepEqual(calledWith, [eventUpdate, true]);
});
});
});
+1
View File
@@ -72,6 +72,7 @@ class MockConnector extends lib.BaseConnector {
constructor(src, dst) {
super(src, dst);
this.valid = true;
this.connected = true;
this.hasSession = true;
this.sentMessages = [];
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more