mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Merge branch 'master' into feature/datastore
This commit is contained in:
102 files changed
+1252
-366
No files matched your search
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,4 @@
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,4 @@
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,4 @@
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "host.ts", "package.json", "src/**/*.ts", "src/**/*.js"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,5 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -4,7 +4,4 @@
|
||||
{ "path": "../lib/tsconfig.browser.json" },
|
||||
],
|
||||
"include": [ "src/**/*.tsx", "src/**/*.ts", "package.json", "global.d.ts" ],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/browser",
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,4 @@
|
||||
],
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,4 @@
|
||||
],
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,4 @@
|
||||
],
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist/node",
|
||||
},
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -2,4 +2,5 @@ packages:
|
||||
- "packages/*"
|
||||
- "plugins/*"
|
||||
- "external_plugins/*"
|
||||
- "external_plugins/*/*"
|
||||
- "test/"
|
||||
@@ -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")
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
if (module === require.main) {
|
||||
process.stdout.write(JSON.stringify(process.argv.slice(2)));
|
||||
}
|
||||
@@ -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: "" }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
@ECHO off
|
||||
REM this is how npm and package.json bin entries forwards arguments on Windows
|
||||
node.exe %*
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
# this is how package.json bin entries forwards arguments on Linux
|
||||
exec node "$@"
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in new issue
Block a user