mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3f4d445f | ||
|
|
00823262d5 | ||
|
|
a5bd43637e | ||
|
|
3ae486143d | ||
|
|
b4856bdd55 | ||
|
|
80f244fddd | ||
|
|
296d1dce99 | ||
|
|
fa2883d74e | ||
|
|
3079426712 | ||
|
|
7d421847f5 | ||
|
|
885de50625 | ||
|
|
c724f144e7 | ||
|
|
96dddb1526 | ||
|
|
3c2ee3e372 | ||
|
|
9bd6effd40 | ||
|
|
a82af49c05 | ||
|
|
1eea756ab3 | ||
|
|
2fb5300ec5 | ||
|
|
11e067a933 | ||
|
|
9aa232e2cd | ||
|
|
82b7c16dba | ||
|
|
ebdbb1fe56 | ||
|
|
6f70ccbf85 | ||
|
|
188de979b6 | ||
|
|
9ab36151bd | ||
|
|
942773c66c | ||
|
|
4d4c509722 | ||
|
|
bf847b8c74 | ||
|
|
5c92a44e65 | ||
|
|
ebedf6b3b0 | ||
|
|
f40113e01c | ||
|
|
a4db2dc460 | ||
|
|
e94abe51c9 | ||
|
|
3227179613 | ||
|
|
35125a28ab | ||
|
|
1a03e66479 | ||
|
|
54e9340e61 | ||
|
|
1c7c00b033 | ||
|
|
ffe82f81d8 | ||
|
|
e36a76acc5 | ||
|
|
bda5c5a8c0 | ||
|
|
86829d50ba | ||
|
|
903e2e0ab6 | ||
|
|
abb04f3258 | ||
|
|
3a81cfd94b | ||
|
|
ee097bfa6f | ||
|
|
d7fc3aaec4 | ||
|
|
5355a0170a | ||
|
|
e16d0bc410 | ||
|
|
a6d2d48e48 |
No files matched your search
@@ -31,17 +31,15 @@ runs:
|
||||
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
|
||||
restore-keys: pnpm-store-${{ runner.os }}-
|
||||
|
||||
# The prepare scripts run tsc --build and webpack, both incremental when
|
||||
# their previous output is present. Every run saves its output and the
|
||||
# next run starts from the most recent one.
|
||||
# The prepare scripts run tsc --build, which is incremental when its
|
||||
# previous output is present. Every run saves its output and the next run
|
||||
# starts from the most recent one.
|
||||
- if: inputs.ignore-scripts != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
packages/*/dist
|
||||
plugins/*/dist
|
||||
packages/*/node_modules/.cache
|
||||
plugins/*/node_modules/.cache
|
||||
key: build-${{ runner.os }}-${{ github.sha }}
|
||||
restore-keys: build-${{ runner.os }}-
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish --no-git-checks
|
||||
env:
|
||||
NODE_ENV: production
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: pnpm-lock.yaml
|
||||
|
||||
+1
-3
@@ -23,9 +23,6 @@ coverage
|
||||
build/Release
|
||||
dist/
|
||||
|
||||
# Autogenerated docs output
|
||||
/docs/api
|
||||
|
||||
node_modules
|
||||
|
||||
/factorio*
|
||||
@@ -35,6 +32,7 @@ node_modules
|
||||
/plugin-list.json
|
||||
/config-*.json
|
||||
/config-*.json.lock
|
||||
/npm-operation.lock*
|
||||
pnpm-lock.yaml
|
||||
mod_cache.json
|
||||
.luarc.json
|
||||
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
// Formatting rules come from @stylistic as Oxlint does not implement them.
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": [
|
||||
"node"
|
||||
],
|
||||
"jsPlugins": [
|
||||
"@stylistic/eslint-plugin"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"env": {
|
||||
"builtin": true,
|
||||
"node": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/dist/",
|
||||
"dist/"
|
||||
],
|
||||
"rules": {
|
||||
"@stylistic/indent": [
|
||||
"error",
|
||||
"tab",
|
||||
{
|
||||
"ignoredNodes": [
|
||||
"JSXFragment"
|
||||
],
|
||||
"ignoreComments": true,
|
||||
"SwitchCase": 1
|
||||
}
|
||||
],
|
||||
"accessor-pairs": "error",
|
||||
"array-callback-return": "error",
|
||||
"arrow-body-style": "error",
|
||||
"block-scoped-var": "error",
|
||||
"complexity": [
|
||||
"error",
|
||||
{
|
||||
"max": 25
|
||||
}
|
||||
],
|
||||
"curly": "error",
|
||||
"default-case": "error",
|
||||
"default-case-last": "error",
|
||||
"eqeqeq": "error",
|
||||
"func-name-matching": "error",
|
||||
"func-style": [
|
||||
"error",
|
||||
"declaration",
|
||||
{
|
||||
"allowArrowFunctions": true
|
||||
}
|
||||
],
|
||||
"grouped-accessor-pairs": "error",
|
||||
"guard-for-in": "error",
|
||||
"id-match": "error",
|
||||
"max-depth": "error",
|
||||
"max-nested-callbacks": "error",
|
||||
"new-cap": [
|
||||
"error",
|
||||
{
|
||||
"capIsNewExceptions": [
|
||||
"StringEnum",
|
||||
"StringKey"
|
||||
],
|
||||
"capIsNewExceptionPattern": "^Type\\.",
|
||||
"newIsCapExceptions": [
|
||||
"constructor"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-alert": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-caller": "error",
|
||||
"no-console": "error",
|
||||
"no-constant-condition": [
|
||||
"error",
|
||||
{
|
||||
"checkLoops": false
|
||||
}
|
||||
],
|
||||
"no-constructor-return": "error",
|
||||
"no-div-regex": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-else-return": "error",
|
||||
"no-eq-null": "error",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "error",
|
||||
"no-extra-bind": "error",
|
||||
"no-extra-label": "error",
|
||||
"no-implicit-coercion": "error",
|
||||
"no-implicit-globals": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-iterator": "error",
|
||||
"no-label-var": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "error",
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-str": "error",
|
||||
"no-nested-ternary": "error",
|
||||
"no-new": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-plusplus": [
|
||||
"error",
|
||||
{
|
||||
"allowForLoopAfterthoughts": true
|
||||
}
|
||||
],
|
||||
"no-proto": "error",
|
||||
"no-restricted-globals": "error",
|
||||
"no-restricted-imports": "error",
|
||||
"no-restricted-properties": "error",
|
||||
"no-return-assign": "error",
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-shadow": [
|
||||
"error",
|
||||
{
|
||||
"allow": [
|
||||
"Event",
|
||||
"Request",
|
||||
"yargs"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-throw-literal": "error",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-call": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "error",
|
||||
"no-void": [
|
||||
"error",
|
||||
{
|
||||
"allowAsStatement": true
|
||||
}
|
||||
],
|
||||
"prefer-arrow-callback": "error",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-numeric-literals": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"prefer-regex-literals": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"radix": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"symbol-description": "error",
|
||||
"unicode-bom": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"vars-on-top": "error",
|
||||
"yoda": [
|
||||
"error",
|
||||
"never",
|
||||
{
|
||||
"exceptRange": true
|
||||
}
|
||||
],
|
||||
"node/no-sync": [
|
||||
"error",
|
||||
{
|
||||
"ignores": [
|
||||
"TechnologySync",
|
||||
"techsToSync"
|
||||
]
|
||||
}
|
||||
],
|
||||
"@stylistic/array-bracket-newline": "off",
|
||||
"@stylistic/array-bracket-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@stylistic/array-element-newline": "off",
|
||||
"@stylistic/arrow-parens": "off",
|
||||
"@stylistic/arrow-spacing": [
|
||||
"error",
|
||||
{
|
||||
"after": true,
|
||||
"before": true
|
||||
}
|
||||
],
|
||||
"@stylistic/block-spacing": "error",
|
||||
"@stylistic/brace-style": [
|
||||
"error",
|
||||
"1tbs",
|
||||
{
|
||||
"allowSingleLine": true
|
||||
}
|
||||
],
|
||||
"@stylistic/comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
"generics": "only-multiline",
|
||||
"enums": "always-multiline",
|
||||
"tuples": "always-multiline"
|
||||
}
|
||||
],
|
||||
"@stylistic/comma-spacing": "error",
|
||||
"@stylistic/comma-style": [
|
||||
"error",
|
||||
"last"
|
||||
],
|
||||
"@stylistic/computed-property-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@stylistic/dot-location": [
|
||||
"error",
|
||||
"property"
|
||||
],
|
||||
"@stylistic/eol-last": "error",
|
||||
"@stylistic/function-call-argument-newline": "off",
|
||||
"@stylistic/function-paren-newline": "off",
|
||||
"@stylistic/generator-star-spacing": [
|
||||
"error",
|
||||
{
|
||||
"before": false,
|
||||
"after": true
|
||||
}
|
||||
],
|
||||
"@stylistic/implicit-arrow-linebreak": "error",
|
||||
"@stylistic/jsx-quotes": "error",
|
||||
"@stylistic/key-spacing": "error",
|
||||
"@stylistic/keyword-spacing": "error",
|
||||
"@stylistic/line-comment-position": "off",
|
||||
"@stylistic/linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"@stylistic/lines-around-comment": "off",
|
||||
"@stylistic/lines-between-class-members": [
|
||||
"error",
|
||||
"always",
|
||||
{
|
||||
"exceptAfterSingleLine": true
|
||||
}
|
||||
],
|
||||
"@stylistic/max-len": [
|
||||
"error",
|
||||
{
|
||||
"code": 120
|
||||
}
|
||||
],
|
||||
"@stylistic/max-statements-per-line": "off",
|
||||
"@stylistic/multiline-comment-style": [
|
||||
"error",
|
||||
"separate-lines"
|
||||
],
|
||||
"@stylistic/multiline-ternary": "off",
|
||||
"@stylistic/new-parens": "error",
|
||||
"@stylistic/newline-per-chained-call": "off",
|
||||
"@stylistic/no-confusing-arrow": "error",
|
||||
"@stylistic/no-extra-parens": "off",
|
||||
"@stylistic/no-floating-decimal": "error",
|
||||
"@stylistic/no-mixed-operators": [
|
||||
"error",
|
||||
{
|
||||
"groups": [
|
||||
[
|
||||
"&",
|
||||
"|",
|
||||
"^",
|
||||
"~",
|
||||
"<<",
|
||||
">>",
|
||||
">>>"
|
||||
],
|
||||
[
|
||||
"==",
|
||||
"!=",
|
||||
"===",
|
||||
"!==",
|
||||
">",
|
||||
">=",
|
||||
"<",
|
||||
"<="
|
||||
],
|
||||
[
|
||||
"in",
|
||||
"instanceof"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"@stylistic/no-multi-spaces": "error",
|
||||
"@stylistic/no-multiple-empty-lines": "error",
|
||||
"@stylistic/no-tabs": "off",
|
||||
"@stylistic/no-trailing-spaces": "error",
|
||||
"@stylistic/no-whitespace-before-property": "error",
|
||||
"@stylistic/nonblock-statement-body-position": "error",
|
||||
"@stylistic/object-curly-newline": "error",
|
||||
"@stylistic/object-curly-spacing": "off",
|
||||
"@stylistic/one-var-declaration-per-line": "error",
|
||||
"@stylistic/operator-linebreak": [
|
||||
"error",
|
||||
"after",
|
||||
{
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
":": "before",
|
||||
"||": "before",
|
||||
"&&": "before",
|
||||
"??": "before",
|
||||
"|": "before",
|
||||
"&": "before"
|
||||
}
|
||||
}
|
||||
],
|
||||
"@stylistic/padded-blocks": "off",
|
||||
"@stylistic/padding-line-between-statements": "error",
|
||||
"@stylistic/quote-props": "off",
|
||||
"@stylistic/quotes": [
|
||||
"error",
|
||||
"double",
|
||||
{
|
||||
"avoidEscape": true
|
||||
}
|
||||
],
|
||||
"@stylistic/rest-spread-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@stylistic/semi": "error",
|
||||
"@stylistic/semi-spacing": "error",
|
||||
"@stylistic/semi-style": "off",
|
||||
"@stylistic/space-before-blocks": "error",
|
||||
"@stylistic/space-before-function-paren": "off",
|
||||
"@stylistic/space-in-parens": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@stylistic/space-infix-ops": "off",
|
||||
"@stylistic/space-unary-ops": "error",
|
||||
"@stylistic/spaced-comment": [
|
||||
"error",
|
||||
"always",
|
||||
{
|
||||
"line": {
|
||||
"markers": [
|
||||
"/"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"@stylistic/switch-colon-spacing": "error",
|
||||
"@stylistic/template-curly-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@stylistic/template-tag-spacing": "error",
|
||||
"@stylistic/wrap-iife": "error",
|
||||
"@stylistic/wrap-regex": "off",
|
||||
"@stylistic/yield-star-spacing": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"{packages,plugins}/**/*.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/no-duplicate-enum-values": "error",
|
||||
"typescript/no-empty-object-type": "error",
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/no-extra-non-null-assertion": "error",
|
||||
"typescript/no-misused-new": "error",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||
"typescript/no-require-imports": "off",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-constraint": "error",
|
||||
"typescript/no-unsafe-declaration-merging": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/triple-slash-reference": "off",
|
||||
"no-shadow": [
|
||||
"error",
|
||||
{
|
||||
"allow": [
|
||||
"Event",
|
||||
"Request",
|
||||
"yargs"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-useless-constructor": "error"
|
||||
},
|
||||
"plugins": [
|
||||
"typescript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"test/**/*.js",
|
||||
"plugins/*/test/**/*.js"
|
||||
],
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off"
|
||||
},
|
||||
"env": {
|
||||
"mocha": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/web_ui/src/**/*.{ts,tsx}",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{ts,js,tsx,jsx}",
|
||||
"{packages,plugins}/*/web/**/*.{ts,tsx}"
|
||||
],
|
||||
"globals": {
|
||||
"SpeechRecognitionAlternative": "readonly",
|
||||
"SpeechRecognitionResult": "readonly",
|
||||
"SpeechRecognitionResultList": "readonly"
|
||||
},
|
||||
"env": {
|
||||
"browser": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/create/templates/**/*.{js,cjs,ts,cts,jsx,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"@stylistic/indent": "off",
|
||||
"@stylistic/spaced-comment": "off",
|
||||
"@stylistic/comma-spacing": "off",
|
||||
"@stylistic/max-len": "off"
|
||||
},
|
||||
"jsPlugins": [
|
||||
"@stylistic/eslint-plugin"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,7 +18,7 @@ Use the detailed guides for the affected area:
|
||||
- Read the relevant source and tests before changing behavior.
|
||||
If documentation and implementation disagree, report the discrepancy and distinguish observed behavior from intended policy.
|
||||
Update affected documentation in the same change.
|
||||
- Follow nearby code and `eslint.config.mjs`: tabs, double quotes, semicolons, and `.js` extensions in relative TypeScript imports.
|
||||
- Follow nearby code and `.oxlintrc.json`: tabs, double quotes, semicolons, and `.js` extensions in relative TypeScript imports.
|
||||
Import shared public APIs through `@clusterio/lib` and check Node/browser exports when changing them.
|
||||
- Run commands from the root using the pinned pnpm version and the [documented Node requirements](docs/contributing.md#supported-nodejs-and-factorio-versions).
|
||||
Install with `pnpm install --no-frozen-lockfile`; the lockfile is intentionally ignored.
|
||||
|
||||
@@ -31,6 +31,27 @@ Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added a player proxy on the controller that accepts Factorio clients on a single UDP port, sends each player to an instance picked by plugins or the instance they were last on, and starts stopped instances on demand. It listens on port 34197 by default. Instances can also get a dedicated proxy port with `instance.player_proxy_port`. See [Player proxy](/docs/player-proxy.md).
|
||||
- Added the Lobby plugin, which sends players connecting through the proxy to a lobby running their Factorio version where they pick the instance to join, creating the lobby on a host with that version if there is none, and can list the cluster and individual instances, including stopped ones, in the Factorio server browser.
|
||||
|
||||
### Features
|
||||
|
||||
- Added the `chooseSave` instance hook for plugins to pick or create the save an instance starts with, and `setSaveScenario` in `@clusterio/host` to make the save patcher stop loading the freeplay scenario of a save.
|
||||
- Added the `playerProxyRoute` controller hook for plugins to decide which instance a player connecting through the player proxy goes to, given the name, Factorio version and mods of the player.
|
||||
- Added `HostFactorioVersionsRequest` for the controller to ask a host which Factorio versions it has installed and whether it can download others.
|
||||
|
||||
### Changes
|
||||
|
||||
- Changed the default `host.factorio_port_range` of new hosts to 34200-34299, so it doesn't include the player proxy port 34197.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed map positions after the first one in exchange strings being decoded relative to the wrong previous position.
|
||||
|
||||
## Version 2.0.3
|
||||
|
||||
### Features
|
||||
|
||||
@@ -73,6 +73,7 @@ Plugins add functionality to Factorio servers, Clusterio itself or both.
|
||||
These are the plugins supported and maintained by the Clusterio developers:
|
||||
|
||||
- [Global Chat](/plugins/global_chat/README.md): share the in-game chat between servers.
|
||||
- [Lobby](/plugins/lobby/README.md): lobby servers where players pick the server to join, and listing of the cluster in the Factorio server browser.
|
||||
- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked and the research queue between servers.
|
||||
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the controller.
|
||||
- [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests.
|
||||
|
||||
@@ -49,7 +49,7 @@ async function removeBuildArtifacts() {
|
||||
tryRemove(path.join(basePath, packageName, "dist"))
|
||||
);
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
|
||||
tryRemove(path.join(basePath, packageName, "node_modules", ".vite"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ Plugin classes also supply a `plugin` name.
|
||||
|
||||
`Link.register` registers request and event classes and compiles their payload validators.
|
||||
Core classes are registered by the link module; `registerPluginMessages` registers each plugin's `messages` array.
|
||||
It also registers the plugin's `errors` array with `Link.registerError`, so an error thrown by a request handler is rejected on the requester as the same class.
|
||||
Incoming messages invoke handlers attached with `link.handle(MessageClass, handler)`.
|
||||
Plugins attach these handlers from their entrypoint functions rather than relying on method-name conventions.
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ E.g.:
|
||||
let value = configInstance.fields["group.field"]
|
||||
|
||||
The set method also takes an extra argument, which is the value to set the field to.
|
||||
If the value is not valid this will throw an InvalidValue exception, if the field doesn't exist it will throw an InvalidField exception.
|
||||
If the value is not valid this will throw an InvalidValueError exception, if the field doesn't exist it will throw an InvalidFieldError exception.
|
||||
|
||||
|
||||
## Config Lifecycle
|
||||
|
||||
+35
-3
@@ -103,7 +103,7 @@ Defaults to null meaning assume localhost.
|
||||
|
||||
Used to override where static assets such as the web bundle is loaded from.
|
||||
This can be used if you want to put the static assets on a separate cdn url like `https://cdn.example.com/static/` while keeping the API hosted directly by clusterio.
|
||||
Due to a limitation on how the webpack assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
|
||||
Due to a limitation on how the web assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
|
||||
|
||||
See also the `clusteriocontroller copy-static` command for how to obtain the static asset files that need to be hosted.
|
||||
|
||||
@@ -214,6 +214,25 @@ Must be greater than 0.
|
||||
Defaults to 15.
|
||||
|
||||
|
||||
### controller.player_proxy_port
|
||||
|
||||
UDP port the controller accepts connections from Factorio clients on.
|
||||
Players connecting to it are forwarded to an instance, which is started if it's stopped, see [Player proxy](player-proxy.md).
|
||||
Disabled when null.
|
||||
Must be greater than 0.
|
||||
Requires a restart of the controller to take effect.
|
||||
|
||||
Defaults to 34197, the default port of Factorio servers.
|
||||
|
||||
|
||||
### controller.player_proxy_default_instance_id
|
||||
|
||||
ID of the instance players connecting through the player proxy are sent to when no plugin picks an instance for them and they have not been on any instance before.
|
||||
If null, a running instance is picked.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.factorio_username
|
||||
|
||||
Username of the Factorio.com account to authenticate with the Factorio API with.
|
||||
@@ -382,11 +401,13 @@ Defaults to "localhost".
|
||||
### host.factorio_port_range
|
||||
|
||||
UDP ports the host hands out to instances that don't have factorio.game_port set.
|
||||
Written as a comma separated list of ports and/or ranges of ports separated with a dash, for example "34100-34199" or "34100, 34200-34210".
|
||||
Written as a comma separated list of ports and/or ranges of ports separated with a dash, for example "34200-34299" or "34100, 34200-34210".
|
||||
A port is picked when the instance is started and remembered in factorio.host_assigned_game_port, so an instance keeps the same port as long as it is still free.
|
||||
Starting an instance fails if all ports in the range are taken.
|
||||
|
||||
Defaults to "34100-34199".
|
||||
Defaults to "34200-34299", which leaves out 34197 used by controller.player_proxy_port.
|
||||
Host configs created by older versions of Clusterio have "34100-34199" saved, which includes 34197.
|
||||
Ports in use by another process are skipped when assigning, so this only conflicts if the host is started before a controller on the same machine.
|
||||
|
||||
|
||||
### host.factorio_username
|
||||
@@ -490,6 +511,15 @@ Does not affect start or stop of the instance while the host is running.
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### instance.player_proxy_port
|
||||
|
||||
Extra UDP port the player proxy on the controller listens on that sends players straight to this instance, starting it if it's stopped.
|
||||
Only used when controller.player_proxy_port is set, see [Player proxy](player-proxy.md).
|
||||
Must be greater than 0, and should not be in the host.factorio_port_range of a host on the same machine as the controller.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### instance.exclude_from_start_all
|
||||
|
||||
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
|
||||
@@ -644,6 +674,8 @@ The settings in `data/server-settings.example.json` of the Factorio installation
|
||||
|
||||
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
|
||||
|
||||
With `auto_pause` on, the plugins included with Clusterio stop their periodic commands while no players are online, so the game stays paused.
|
||||
|
||||
Defaults to a set of common settings filled in when the instance is created, such as `"tags": ["clusterio"]`, `"auto_pause": false` and a `name` and `description` based on the cluster and instance names.
|
||||
|
||||
|
||||
|
||||
+37
-7
@@ -18,6 +18,7 @@ Read our [past decisions](/docs/decisions.md) for compatibility policies and des
|
||||
- [Checking your code for errors](#checking-your-code-for-errors)
|
||||
- [Cleaning generated and runtime files](#cleaning-generated-and-runtime-files)
|
||||
- [Submitting a Pull Request](#submitting-a-pull-request)
|
||||
- [Breaking Compatibility with the Last Release](#breaking-compatibility-with-the-last-release)
|
||||
- [Editing your Feature Branch](#editing-your-feature-branch)
|
||||
- [Resolving Merge Conflicts](#resolving-merge-conflicts)
|
||||
- [Updating Your GitHub Fork](#updating-your-github-fork)
|
||||
@@ -42,7 +43,7 @@ Clusterio manages distributed Factorio servers through a controller, hosts, and
|
||||
| `packages/host/` | Instance lifecycle, Factorio processes, save patching, and exports. Start with `src/Host.ts`, `src/Instance.ts`, `src/server.ts`, and `src/patch.ts`. |
|
||||
| `packages/ctl/` | Command-line administration and plugin commands. |
|
||||
| `packages/lib/` | Shared messages, routing, config, permissions, plugin loading, logging, and utilities for Node and browser consumers. |
|
||||
| `packages/web_ui/` | React/Ant Design interface and browser control connection; bundled through the controller's webpack build. |
|
||||
| `packages/web_ui/` | React/Ant Design interface and browser control connection; bundled through the controller's Vite build. |
|
||||
| `packages/create/` | Installer and JavaScript/TypeScript plugin templates. |
|
||||
| `plugins/` | Bundled plugins, including their Node, web, Lua, and test code. |
|
||||
| `external_plugins/` | Plugins developed in independent repositories and included in the local workspace. |
|
||||
@@ -119,13 +120,12 @@ To have TypeScript watch for changes and rebuild immediadly you may run `pnpm wa
|
||||
|
||||
For web development on the controller there are also the following flags:
|
||||
|
||||
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
|
||||
`--dev` - Serve the `web_ui` package from source through Vite's development server with hot module replacement.
|
||||
|
||||
`--dev-plugin [name]` - Recompile the web module for the given plugin on the fly proving live reloading of it.
|
||||
`--dev-plugin [name]` - Serve the web module for the given plugin from source the same way.
|
||||
|
||||
These options can be combined and multiple plugins can be actived for dev mode.
|
||||
Keep in mind if you're working on external plugins the `--dev-plugin` option is likely to only work if the plugin is placed inside the `external_plugins` directory and its dependencies are installed with pnpm as part of the clusterio repository workspace.
|
||||
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `pnpm -r run prepare` before you start the controller without these flags again.
|
||||
|
||||
|
||||
### Build outputs
|
||||
@@ -136,9 +136,8 @@ Note that webpack is configured to delete the web interface build when using the
|
||||
| `pnpm watch` | TypeScript compilation as files change; restart affected Node processes afterward. |
|
||||
| `pnpm -r run prepare` | Compiled packages, generated validators, and production web bundles. |
|
||||
| `pnpm build-mod` | Factorio mod archives in root `dist/`. |
|
||||
| `pnpm docs` | Generated API documentation. |
|
||||
|
||||
`pnpm build` alone does not regenerate the standalone validator or webpack bundles.
|
||||
`pnpm build` alone does not regenerate the standalone validator or web bundles.
|
||||
After changing `packages/lib/src/data/messages_core.ts`, run `pnpm -r run prepare` to compile the schema and regenerate its validator.
|
||||
|
||||
|
||||
@@ -228,7 +227,7 @@ Inspect `node clean.js --help` and preview the selected scope with `--dry-run` b
|
||||
|
||||
| Command | Removal scope |
|
||||
| --- | --- |
|
||||
| `node clean.js --dry-run` | Preview workspace package/plugin build output and webpack caches; omit `--dry-run` to clean, or use `pnpm clean`. |
|
||||
| `node clean.js --dry-run` | Preview workspace package/plugin build output and Vite dependency caches; omit `--dry-run` to clean, or use `pnpm clean`. |
|
||||
| `node clean.js tests --dry-run` | Also preview root `.nyc_output/`, `coverage/`, the whole `temp/` directory, and `static/`. |
|
||||
| `node clean.js all --dry-run` | Also preview runtime databases, instances, logs, mods, local configs, `FactorioAdminToken.txt`, and `plugin-list.json`. |
|
||||
|
||||
@@ -251,6 +250,37 @@ If there are more changes you want to have merged or mistakes you need to fix yo
|
||||
Pull request descriptions should contain a `### Changelog` section with additions for the changelog contained inside a code block, see the [CHANGELOG.md](/CHANGELOG.md) file for the formatting of changelog entries and possible sections to use.
|
||||
If there are no changelog entries to add, remove the code block and start the changelog section with the text `None.`, followed by an optional reason for why it is not is needed.
|
||||
|
||||
### Breaking Compatibility with the Last Release
|
||||
|
||||
The version-compat job in CI runs the local controller with the latest published host, and the published controller with the local host.
|
||||
It fails if a link message changed its src, dst, data or response schema since that release.
|
||||
If the break is intended, add an entry to [scripts/version-compat-breaks.json](../scripts/version-compat-breaks.json) in the same pull request.
|
||||
Breaking changes are only accepted if the next release is intended to include them, see our decision on [breaking changes](/docs/decisions.md#breaking-changes).
|
||||
The entry is keyed by the published version directly before the breaking change was introduced, which is the latest release at the time.
|
||||
Every entry needs the pull request number, a reason saying what breaks, and an impact saying why end users are not or only minimally affected.
|
||||
|
||||
```json
|
||||
{
|
||||
"2.0.3": {
|
||||
"messages": {
|
||||
"request FactorioVersionsRequest": {
|
||||
"pr": 1129,
|
||||
"reason": "What breaks between the two versions.",
|
||||
"impact": "Why end users are not or only minimally affected."
|
||||
}
|
||||
},
|
||||
"connect": { "pr": 1234, "reason": "Why a host cannot connect at all.", "impact": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Listed messages are reported as warnings with the reason and impact instead of failing the job.
|
||||
Only add `connect` when the published and local versions cannot connect at all, it skips starting the cluster.
|
||||
Listing a message that did not change fails the job.
|
||||
Entries only apply while their version is the latest release, after the next release they are ignored and can be removed.
|
||||
Describe the break in the Breaking Changes section of the changelog.
|
||||
|
||||
|
||||
### Editing your Feature Branch
|
||||
|
||||
Until your feature branch is merged you can edit it as you see fit by using the rebase tool.
|
||||
|
||||
+20
-13
@@ -18,7 +18,7 @@ If there is a particular issue or concern you would like the team to comment on
|
||||
- [Naming Times and Durations](#naming-times-and-durations)
|
||||
- [Strings](#strings)
|
||||
3. Code Style (TS / JS / Web)
|
||||
- [ESLint](#eslint)
|
||||
- [Oxlint](#oxlint)
|
||||
- [Imports Within `lib` Package](#imports-within-lib-package)
|
||||
- [Name Style](#name-style-ts)
|
||||
4. Code Style (Lua)
|
||||
@@ -38,7 +38,7 @@ If there is a particular issue or concern you would like the team to comment on
|
||||
- [React](#react)
|
||||
- [Separation of Host and Controller](#separation-of-host-and-controller)
|
||||
- [Typescript](#typescript)
|
||||
- [Webpack](#webpack)
|
||||
- [Vite](#vite)
|
||||
8. Supportability
|
||||
- [Breaking Changes](#breaking-changes)
|
||||
- [Distribution](#distribution)
|
||||
@@ -91,6 +91,11 @@ Therefore, we decided to use TSDoc comments for this purpose as it is well inter
|
||||
Our specific implementation is TypeDoc because it is lightweight it and can output TSDoc comments as html.
|
||||
API Extractor was also considered but not selected.
|
||||
|
||||
🧾 29th October 2026 | [Github PR #1147](https://github.com/clusterio/clusterio/pull/1147)
|
||||
|
||||
We dropped TypeDoc as the generated docs were never published and it does not support TypeScript 7.
|
||||
TSDoc comments are still used for editor hover text.
|
||||
|
||||
|
||||
## Code Style (General)
|
||||
|
||||
@@ -157,14 +162,15 @@ Therefore, we selected double quotes as our standard.
|
||||
|
||||
## Code Style (TS / JS / Web)
|
||||
|
||||
### ESLint
|
||||
### Oxlint
|
||||
|
||||
🧾 Time Immemorial
|
||||
🧾 20th October 2026
|
||||
|
||||
> Use ESLint for style checking.
|
||||
> Use Oxlint for style checking, with formatting rules from `@stylistic/eslint-plugin`.
|
||||
|
||||
We needed to maintain a consistent style across all our files.
|
||||
Therefore, we utilise the industry-standard ESLint, as it is familiar and highly configurable.
|
||||
We needed to maintain a consistent style across all our files and used ESLint for it from the start.
|
||||
Oxlint runs the same rules about three times faster, reads ESLint disable comments, and does not depend on the TypeScript compiler API.
|
||||
The formatting rules ESLint deprecated run through `@stylistic/eslint-plugin` loaded as an Oxlint JS plugin, so the code style did not change.
|
||||
|
||||
|
||||
### Imports Within `lib` Package
|
||||
@@ -347,14 +353,15 @@ Transitioning to any other langauge would be too significant an undertaking.
|
||||
Initially, we supported multiple languages for plugins, including Rust and Go; however, this approach was quickly abandoned due to its complexity.
|
||||
|
||||
|
||||
### Webpack
|
||||
### Vite
|
||||
|
||||
🧾 Time Immemorial
|
||||
🧾 20th October 2026
|
||||
|
||||
> Our web stack uses Webpack and associated plugins.
|
||||
> Our web stack uses Vite. Plugin web bundles are ES modules that import shared dependencies through an import map.
|
||||
|
||||
We required a bundler for our web build, and at the time, Webpack was the standard.
|
||||
While we have considered alternatives since, transitioning to another solution would be a significant undertaking.
|
||||
We used Webpack with Module Federation for loading plugins from the start, as it was the standard at the time.
|
||||
Vite builds the web interface several times faster, its development server updates modules without reloading the page, and plugin bundles no longer depend on one bundler's runtime.
|
||||
See [#1127](https://github.com/clusterio/clusterio/issues/1127) for the discussion.
|
||||
|
||||
|
||||
## Supportability
|
||||
@@ -370,7 +377,7 @@ We aim to be highly transparent with breaking changes and maintain compatibility
|
||||
During the alpha stage every alpha was allowed to contain breaking changes, as long as they were documented and provided migration actions.
|
||||
From 2.0 onwards changes which require action from people running a cluster, such as changes to configs, stored data or the command line, are held back for the next major version.
|
||||
Changes to the plugin API may still be made in minor versions.
|
||||
Plugin authors should check the changelog when a new minor version is released, non-trivial changes come with a migration guide such as [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
|
||||
Plugin authors should check the changelog when a new minor version is released, non-trivial changes come with a migration guide such as [Migrating Plugins to 2.1](/docs/devs/plugin-migration-to-2.1.md).
|
||||
|
||||
|
||||
### Distribution
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Migrating Plugins to 2.1
|
||||
|
||||
This guide covers the changes needed to bring a plugin written for 2.0 up to 2.1.
|
||||
Plugins still on an alpha should start with the [2.0 guide](plugin-migration-to-2.0.md).
|
||||
|
||||
|
||||
## Request errors
|
||||
|
||||
`RequestError` is gone.
|
||||
Errors thrown by request handlers are now received by the requester as the same class, see [Request errors](/docs/writing-plugins.md#request-errors).
|
||||
Replace `RequestError` with the class that fits:
|
||||
|
||||
- Invalid input: `ValidationError`.
|
||||
- Operation disabled by config: `ForbiddenError`.
|
||||
- Missing instance, user, save etc.: `NotFoundError`.
|
||||
- Instance running, name taken etc.: `ConflictError`.
|
||||
- Host not connected, instance not running: `UnavailableError`.
|
||||
- Timeouts: `TimeoutError`.
|
||||
|
||||
`catch` blocks that check `err instanceof RequestError` should check `ClientError` instead, or the specific class.
|
||||
Checks of `err.code` for remote errors can use `instanceof`, e.g. `err instanceof lib.SessionLost`.
|
||||
|
||||
The config errors `lib.InvalidValue`, `lib.InvalidField` and `lib.InvalidAccess` were renamed to `lib.InvalidValueError`, `lib.InvalidFieldError` and `lib.InvalidAccessError`.
|
||||
|
||||
Errors of classes that are not registered, like a `TypeError` from a bug, are received as `InternalServerError`.
|
||||
Plugins that define their own error classes need to list them in `errors` of the plugin declaration.
|
||||
|
||||
|
||||
## Build the web module with Vite
|
||||
|
||||
The web interface is now built with Vite instead of webpack, and plugin web modules are loaded as ES modules instead of through Module Federation.
|
||||
A plugin built with webpack fails to load with the error "Incompatible dist/web/manifest.json, the plugin must be rebuilt with Vite".
|
||||
|
||||
Delete `webpack.config.cjs` and add a `vite.config.js` next to `package.json`:
|
||||
|
||||
```js
|
||||
import { defineConfig } from "vite";
|
||||
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [clusterioPlugin()],
|
||||
});
|
||||
```
|
||||
|
||||
In `package.json` replace `webpack-cli --mode production` in the `prepare` script with `vite build`, and replace the `webpack`, `webpack-cli` and `webpack-merge` dev dependencies with `vite`.
|
||||
Add `vite.config.js` to `.npmignore` if you had `webpack.config.js` there.
|
||||
|
||||
`clusterioPlugin` exposes `index.ts` (or `index.js`) as `"./"`, `package.json` as `"./package.json"` and `web/index.tsx` (or `.ts`, `.jsx`, `.js`) as `"./web"`.
|
||||
These are the same keys the plugin template used with webpack, so `webEntrypoint: "./web"` stays as it is.
|
||||
If your webpack config exposed other paths, move the code so it is reachable from `web/index.tsx`.
|
||||
|
||||
The web interface provides `@clusterio/lib`, `@clusterio/web_ui`, `@sinclair/typebox`, `antd`, `react`, `react-dom`, `react-router` and `react-router-dom` to plugins.
|
||||
These are left as imports in the plugin bundle and resolved by the browser, there is no need to list them as shared.
|
||||
Everything else is bundled into the plugin, including `@ant-design/icons`.
|
||||
Import `react-dom/client` and `react/jsx-runtime` by those names, other subpaths of the shared packages are bundled as a separate copy.
|
||||
|
||||
Imports of `assert`, `buffer`, `events`, `os`, `path`, `process`, `stream`, `util` and `zlib` resolve to the same browser packages as before, provided by `@clusterio/web_ui`.
|
||||
|
||||
The `container` property of `WebPluginContext` is now the plugin's ES module namespace, `await container.get("./web")` returns the module instead of a factory function.
|
||||
|
||||
`lib.PluginWebpackEnvInfo` is renamed to `lib.PluginWebEnvInfo`.
|
||||
@@ -223,7 +223,8 @@ Plugins may define their own requests as well.
|
||||
- `src: Address` - Responding endpoint.
|
||||
- `dst: Address` - Requester's address, including the original request ID.
|
||||
- `data.message: string` - Human-readable error description.
|
||||
- `data.code: string` (optional) - Error code.
|
||||
- `data.code: string` (optional) - Registered name of the error class, `<plugin>:<class>` for plugin errors.
|
||||
Missing or unknown names are received as `InternalServerError`.
|
||||
- `data.stack: string` (optional) - Error stack.
|
||||
|
||||
|
||||
|
||||
@@ -283,10 +283,15 @@ This command lists all plugins currently known to the remote. Newly installed pl
|
||||
|
||||
### Update
|
||||
|
||||
ctl> controller plugin update <name> [--restart]
|
||||
ctl> host plugin update <host> <name> [--restart]
|
||||
ctl> controller plugin update <name>[@<version>] [--restart]
|
||||
ctl> host plugin update <host> <name>[@<version>] [--restart]
|
||||
|
||||
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`, which stays within the version range saved in the remote's `package.json`.
|
||||
To update to a specific version, including one outside that range, pass it as `<name>@<version>`, for example `@clusterio/plugin-example@2.1.0`.
|
||||
The version must be published on npm, and the remote then executes `npm install --save <name>@<version>`.
|
||||
The plugin must already be installed on the remote, and only remote plugin updates need to be enabled.
|
||||
Changes will not be applied until the remote is restarted.
|
||||
If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
|
||||
### Update All
|
||||
|
||||
@@ -297,10 +302,30 @@ Updates the Clusterio package and all plugins installed from npm in a single `np
|
||||
|
||||
### Install
|
||||
|
||||
ctl> controller plugin install <name> [--restart]
|
||||
ctl> host plugin install <host> <name> [--restart]
|
||||
ctl> controller plugin install <name>[@<version>] [--restart]
|
||||
ctl> host plugin install <host> <name>[@<version>] [--restart]
|
||||
|
||||
If remote plugin installs are enabled on the remote, this will execute `npm install --save <name>`.
|
||||
Pass `<name>@<version>` to install a specific version, for example to match the version used by the rest of the cluster.
|
||||
The package, and the version if given, must be published on npm.
|
||||
Changes will not be applied until the remote is restarted.
|
||||
If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
|
||||
### Search
|
||||
|
||||
ctl> controller plugin search [query] [--page <n>] [--page-size <n>]
|
||||
|
||||
Searches the npm registry for packages tagged with the clusterio-plugin keyword.
|
||||
The search is done by the controller and does not require remote installs to be enabled.
|
||||
The web UI has a plugin browser on the plugins page that uses the same search.
|
||||
|
||||
### Concurrent Operations
|
||||
|
||||
Only one npm operation runs at a time on a machine.
|
||||
A standalone controller and host share their working directory, so the operation holds the `npm-operation.lock` file there while npm runs.
|
||||
Other installs and updates on the same machine or installation are rejected with "Another package operation is already in progress on this machine" until it finishes.
|
||||
A lock file left behind by a process that is no longer running is removed automatically.
|
||||
|
||||
If remote plugin installs are enabled on the remote, this will execute `npm install --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
|
||||
### Enable/Disable Updates
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Player Proxy
|
||||
|
||||
The controller can accept connections from Factorio clients on a single UDP port and forward each player to an instance in the cluster.
|
||||
Players then only need to know one address, and instances that are stopped are started when someone connects to them.
|
||||
|
||||
|
||||
## Contents
|
||||
|
||||
- [Enabling the proxy](#enabling-the-proxy)
|
||||
- [How players are routed](#how-players-are-routed)
|
||||
- [Dedicated instance ports](#dedicated-instance-ports)
|
||||
- [Lobbies and the server browser](#lobbies-and-the-server-browser)
|
||||
- [Limitations](#limitations)
|
||||
|
||||
|
||||
## Enabling the proxy
|
||||
|
||||
The proxy listens on UDP port 34197, the default port of Factorio servers, unless [controller.player_proxy_port](configuration.md#controllerplayer_proxy_port) is changed.
|
||||
Set it to null to turn the proxy off:
|
||||
|
||||
npx clusteriocontroller config set controller.player_proxy_port null
|
||||
|
||||
Make sure the port is reachable from the Internet.
|
||||
The controller connects to the game port of the instances from the address the host connected to the controller from, so the game ports of the instances must be reachable from the controller.
|
||||
Instances behind the proxy should have `visibility.public` turned off in their `factorio.settings`, otherwise they are listed in the server browser with their own address in addition to the proxy.
|
||||
If the port is taken, for example by an instance of a host on the same machine, the controller logs an error and runs without the proxy.
|
||||
|
||||
|
||||
## How players are routed
|
||||
|
||||
A Factorio client starts a connection by sending its version, and only sends the name of the player after the server has answered.
|
||||
The proxy answers the first message itself with the version of the client, waits for the name and list of enabled mods, and then picks an instance in this order:
|
||||
|
||||
1. The first instance returned by a plugin from the `playerProxyRoute` controller hook, see [Writing plugins](writing-plugins.md#defining-the-plugin-entrypoint).
|
||||
2. The instance the port the player connected to is dedicated to, see below.
|
||||
3. The instance the player was last on.
|
||||
4. [controller.player_proxy_default_instance_id](configuration.md#controllerplayer_proxy_default_instance_id).
|
||||
5. Any running instance.
|
||||
|
||||
If the chosen instance is stopped it is started, and the player is connected once it's running.
|
||||
Factorio clients give up after roughly 10 seconds, so the first attempt to join an instance that takes longer to start fails, but trying again once it's up works.
|
||||
The lobby plugin avoids this by letting players wait in a lobby instead.
|
||||
|
||||
The version of the client is not checked against the instance.
|
||||
Factorio clients only check the version in the answer to their first message, which the proxy sends, so a player sent to an instance running a different version does not get Factorio's version mismatch message and fails later in the handshake instead.
|
||||
The proxy logs a warning when this happens.
|
||||
|
||||
|
||||
## Dedicated instance ports
|
||||
|
||||
[instance.player_proxy_port](configuration.md#instanceplayer_proxy_port) makes the proxy listen on another port that always sends players to that instance.
|
||||
This lets each instance have its own address while still being started on demand.
|
||||
|
||||
|
||||
## Lobbies and the server browser
|
||||
|
||||
The [lobby plugin](../plugins/lobby/README.md) builds on the proxy:
|
||||
|
||||
- Players connecting to the proxy go to a lobby instance running their Factorio version, where an in-game list lets them pick the instance to join.
|
||||
Lobbies are created on demand on a host with that Factorio version, so different versions can be run behind the same port without configuring anything.
|
||||
Picking a stopped instance starts it, and the player is moved there once it's running.
|
||||
- The cluster is listed as one game in the Factorio server browser, with the players online in the whole cluster.
|
||||
- Instances can be listed as their own games in the server browser on a dedicated port, also while they are stopped.
|
||||
Players joining a stopped instance from the server browser wait in a lobby while it starts.
|
||||
|
||||
|
||||
## Limitations
|
||||
|
||||
- Every player reaches the instances from the address of the controller, so bans by IP address and limits on connection attempts per address in Factorio apply to all players behind the proxy together.
|
||||
- Factorio's network protocol is not stable between versions, the proxy is written for Factorio 2.0 and 2.1.
|
||||
- All game traffic passes through the controller, which adds latency if the controller and the instances are far apart.
|
||||
@@ -19,6 +19,20 @@ Use [contributing](contributing.md) for setup and validation commands.
|
||||
Check supported configurations and compatibility policy; a merged revision or earlier clean review is not proof of correctness.
|
||||
|
||||
|
||||
## Surface overlapping responsibilities
|
||||
|
||||
- For substantial new or replaced functionality, check whether existing code or test helpers already serve the same responsibility, including outside the changed files.
|
||||
Search by behavior, then verify the implementations and their callers; nearby code is not necessarily the preferred shared implementation.
|
||||
- Compare contracts, callers, and constraints, not just names or similar code.
|
||||
Distinguish shared behavior and general correctness requirements from differences that may justify separate implementations.
|
||||
- Surface significant overlap to the developer with source locations for both implementations, the duplicated responsibility, its consequences, and any legitimate differences.
|
||||
Explain maintenance or consistency risks without presenting them as reproduced runtime defects.
|
||||
- Recommend an approach based on the evidence, including alternatives, tradeoffs, and affected callers or tests.
|
||||
Leave the architectural decision to the developer unless already authorized; do not silently consolidate the implementations or dismiss the overlap as intentional.
|
||||
- Recheck after substantial fixes, especially when shared functionality is replaced, bypassed, or overridden.
|
||||
Keep unresolved design questions in the candidate ledger and final report, and record the developer's decision when provided.
|
||||
|
||||
|
||||
## Exercise the boundaries
|
||||
|
||||
- Write a small matrix of relevant states and transitions before probing.
|
||||
|
||||
+76
-5
@@ -138,8 +138,8 @@ The following properties are recognized:
|
||||
Object mapping ctl configuration fields to `FieldDefinition` objects.
|
||||
|
||||
**webEntrypoint**:
|
||||
Optional Module Federation exposure key for the web entrypoint, such as `"./web"`.
|
||||
It must match the key in the plugin's webpack `exposes` configuration; it is not a Node.js file path.
|
||||
Optional key of the web entrypoint in the plugin's web bundle, this should be `"./web"`.
|
||||
The `clusterioPlugin` Vite plugin exposes `web/index.tsx` (or `.ts`, `.jsx`, `.js`) under this key; it is not a Node.js file path.
|
||||
|
||||
**migrationEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which exports the migration entrypoint function for this plugin.
|
||||
@@ -154,6 +154,10 @@ The following properties are recognized:
|
||||
Array of request and event classes for this plugin.
|
||||
See guide for [defining link messages](#defining-link-messages) below.
|
||||
|
||||
**errors**:
|
||||
Array of error classes for this plugin.
|
||||
See [Request errors](#request-errors).
|
||||
|
||||
**permissions**:
|
||||
Array of permission definitions for this plugin.
|
||||
See [Plugin Permissions](#plugin-permissions)
|
||||
@@ -501,6 +505,25 @@ Data out from Factorio does not have the same limits as data into Factorio, RCON
|
||||
For `send_json` it's possible that payloads greater than 4kB are received after payloads that were sent at a later point in time.
|
||||
For RCON, commands longer than 50 characters may end up being executed after shorter commands sent after it.
|
||||
|
||||
### Auto pause
|
||||
|
||||
Each RCON command makes a paused game run one tick, so commands sent on a timer keep a server with `auto_pause` on running while nobody is online.
|
||||
`instance.autoPaused` is true while the instance was started with `auto_pause` on and no players are online, and the `autoPauseChanged` hook is invoked with the new value when it changes.
|
||||
Skip periodic commands while it is true:
|
||||
|
||||
```js
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
timer = setInterval(() => {
|
||||
if (!instance.autoPaused) {
|
||||
instance.sendRcon("/sc my_plugin.update()").catch(handleError);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
```
|
||||
|
||||
Commands sent in response to events from elsewhere in the cluster are fine, they cost one tick each.
|
||||
A game paused with `game.tick_paused` does not run ticks for commands.
|
||||
|
||||
### Communicating over UDP
|
||||
|
||||
Instances can optionally exchange UDP packets with the Factorio server for low latency fire-and-forget communication, using the `--enable-lua-udp` option of Factorio.
|
||||
@@ -641,16 +664,16 @@ Registration rejects control-origin messages with no permission declaration.
|
||||
|
||||
A callback receives `(user, message)`, where `message` is the raw wire envelope.
|
||||
It runs on the controller before destination payload validation and deserialization, so `message.data` is untrusted and may be missing or malformed.
|
||||
Check any required payload shape before inspecting fields, and use `user.checkPermission(...)` or throw `PermissionError` for a normal denial.
|
||||
Check any required payload shape before inspecting fields, and use `user.checkPermission(...)` or throw a `ClientError` such as `PermissionError` for a normal denial.
|
||||
For example, this checks the payload shape needed to choose a permission; the destination schema still validates the full payload:
|
||||
|
||||
```js
|
||||
import { PermissionError } from "@clusterio/lib";
|
||||
import { ValidationError } from "@clusterio/lib";
|
||||
|
||||
function checkFrobnicatePermission(user, message) {
|
||||
const data = message.data;
|
||||
if (!data || typeof data !== "object" || typeof data.special !== "boolean") {
|
||||
throw new PermissionError("Expected a boolean special field");
|
||||
throw new ValidationError("Expected a boolean special field");
|
||||
}
|
||||
user.checkPermission(data.special ? "foo_frobber.special" : "foo_frobber.frobnicate");
|
||||
}
|
||||
@@ -740,6 +763,54 @@ connection.sendTo({ instanceId: 123 }, message);
|
||||
connection.sendTo({ controlId: 123 }, message);
|
||||
```
|
||||
|
||||
### Request errors
|
||||
|
||||
An error thrown by a request handler is sent back to the requester, and the promise returned by `.sendTo()` rejects with an instance of the same class.
|
||||
The error classes follow the HTTP status codes:
|
||||
|
||||
```
|
||||
Error
|
||||
├── ClientError (4xx)
|
||||
│ ├── ValidationError (400)
|
||||
│ │ ├── InvalidValueError
|
||||
│ │ └── InvalidFieldError
|
||||
│ ├── ForbiddenError (403)
|
||||
│ │ ├── PermissionError
|
||||
│ │ └── InvalidAccessError
|
||||
│ ├── NotFoundError (404)
|
||||
│ ├── TimeoutError (408)
|
||||
│ └── ConflictError (409)
|
||||
└── ServerError (5xx)
|
||||
├── InternalServerError (500)
|
||||
└── UnavailableError (503)
|
||||
```
|
||||
|
||||
Throw a `ClientError` subclass when the request can't be done as asked, and `UnavailableError` when the host or instance that should handle it is not connected or not running, for example:
|
||||
|
||||
```js
|
||||
controller.handle(messages.Frobnicate, async request => {
|
||||
if (!frobnicators.has(request.id)) {
|
||||
throw new lib.NotFoundError(`Frobnicator ${request.id} does not exist`);
|
||||
}
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
Errors of a class that is not registered are logged as unexpected by the side that threw them and are received as `InternalServerError`.
|
||||
Registered errors are not logged.
|
||||
The message and stack trace of the original error are kept in both cases.
|
||||
|
||||
Plugins can define their own error classes by listing them in `errors` of the plugin declaration.
|
||||
They need a static `plugin` property set to the plugin name and must take the message as the first constructor argument:
|
||||
|
||||
```js
|
||||
export class FrobnicatorJammed extends lib.ConflictError {
|
||||
static plugin = "foo_frobber";
|
||||
}
|
||||
```
|
||||
|
||||
A subclass that is not registered is received as its closest registered parent class.
|
||||
|
||||
### Handling connection events
|
||||
|
||||
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
import {
|
||||
defineConfig,
|
||||
globalIgnores,
|
||||
} from "eslint/config";
|
||||
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import node from "eslint-plugin-node";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import js from "@eslint/js";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
|
||||
import {
|
||||
FlatCompat,
|
||||
} from "@eslint/eslintrc";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default defineConfig([{
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
"@stylistic": stylistic,
|
||||
node,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.commonjs,
|
||||
},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"@stylistic/indent": ["error", "tab", {
|
||||
"ignoredNodes": [
|
||||
"JSXFragment", // TODO: Linter crashes if this is removed
|
||||
],
|
||||
"ignoreComments": true,
|
||||
"SwitchCase": 1, // Indent case statements one level from switch
|
||||
}],
|
||||
"accessor-pairs": "error",
|
||||
"array-bracket-newline": "off",
|
||||
"array-bracket-spacing": ["error", "never"],
|
||||
"array-callback-return": "error",
|
||||
"array-element-newline": "off",
|
||||
"arrow-body-style": "error",
|
||||
"arrow-parens": "off",
|
||||
|
||||
"arrow-spacing": ["error", {
|
||||
"after": true,
|
||||
"before": true,
|
||||
}],
|
||||
|
||||
"block-scoped-var": "error",
|
||||
"block-spacing": "error",
|
||||
|
||||
"brace-style": ["error", "1tbs", {
|
||||
"allowSingleLine": true,
|
||||
}],
|
||||
|
||||
"camelcase": "off",
|
||||
"capitalized-comments": "off",
|
||||
"class-methods-use-this": "off",
|
||||
|
||||
"comma-dangle": ["error", {
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
}],
|
||||
|
||||
"comma-spacing": "error",
|
||||
"comma-style": ["error", "last"],
|
||||
"complexity": ["error", { "max": 25 }],
|
||||
"computed-property-spacing": ["error", "never"],
|
||||
"consistent-return": "error",
|
||||
"consistent-this": "error",
|
||||
"curly": "error",
|
||||
"default-case": "error",
|
||||
"default-case-last": "error",
|
||||
"default-param-last": "off",
|
||||
"dot-location": ["error", "property"],
|
||||
|
||||
"dot-notation": ["error", {
|
||||
"allowPattern": "^[a-z]+(_[a-z]+)*$",
|
||||
}],
|
||||
|
||||
"eol-last": "error",
|
||||
"eqeqeq": "error",
|
||||
"func-call-spacing": "error",
|
||||
"func-name-matching": "error",
|
||||
"func-names": "off",
|
||||
|
||||
"func-style": ["error", "declaration", {
|
||||
"allowArrowFunctions": true,
|
||||
}],
|
||||
|
||||
"function-call-argument-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
|
||||
"generator-star-spacing": ["error", {
|
||||
"before": false,
|
||||
"after": true,
|
||||
}],
|
||||
|
||||
"grouped-accessor-pairs": "error",
|
||||
"guard-for-in": "error",
|
||||
"id-blacklist": "error",
|
||||
"id-length": "off",
|
||||
"id-match": "error",
|
||||
"implicit-arrow-linebreak": "error",
|
||||
|
||||
"indent-legacy": "off",
|
||||
"init-declarations": "off",
|
||||
"jsx-quotes": "error",
|
||||
"key-spacing": "error",
|
||||
"keyword-spacing": "error",
|
||||
"line-comment-position": "off",
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"lines-around-comment": "off",
|
||||
"lines-around-directive": "off",
|
||||
|
||||
"lines-between-class-members": ["error", "always", {
|
||||
"exceptAfterSingleLine": true,
|
||||
}],
|
||||
|
||||
"max-classes-per-file": "off",
|
||||
"max-depth": "error",
|
||||
|
||||
"max-len": ["error", {
|
||||
"code": 120,
|
||||
}],
|
||||
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": "off",
|
||||
"max-nested-callbacks": "error",
|
||||
"max-params": "off",
|
||||
"max-statements": "off",
|
||||
"max-statements-per-line": "off",
|
||||
"multiline-comment-style": ["error", "separate-lines"],
|
||||
"multiline-ternary": "off",
|
||||
|
||||
"new-cap": ["error", {
|
||||
"capIsNewExceptions": ["StringEnum", "StringKey"],
|
||||
"capIsNewExceptionPattern": "^Type\\.",
|
||||
"newIsCapExceptions": ["constructor"],
|
||||
}],
|
||||
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
"newline-per-chained-call": "off",
|
||||
"no-alert": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-await-in-loop": "off",
|
||||
"no-bitwise": "off",
|
||||
"no-buffer-constructor": "error",
|
||||
"no-caller": "error",
|
||||
"no-catch-shadow": "off",
|
||||
"no-confusing-arrow": "error",
|
||||
"no-console": "error",
|
||||
|
||||
"no-constant-condition": ["error", {
|
||||
"checkLoops": false,
|
||||
}],
|
||||
|
||||
"no-constructor-return": "error",
|
||||
"no-continue": "off",
|
||||
"no-div-regex": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-else-return": "error",
|
||||
"no-empty-function": "off",
|
||||
"no-eq-null": "error",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "error",
|
||||
"no-extra-bind": "error",
|
||||
"no-extra-label": "error",
|
||||
"no-extra-parens": "off",
|
||||
"no-floating-decimal": "error",
|
||||
"no-implicit-coercion": "error",
|
||||
"no-implicit-globals": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-inline-comments": "off",
|
||||
"no-invalid-this": "error",
|
||||
"no-iterator": "error",
|
||||
"no-label-var": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "error",
|
||||
"no-magic-numbers": "off",
|
||||
|
||||
"no-mixed-operators": ["error", {
|
||||
"groups": [
|
||||
["&", "|", "^", "~", "<<", ">>", ">>>"],
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
}],
|
||||
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-spaces": "error",
|
||||
"no-multi-str": "error",
|
||||
"no-multiple-empty-lines": "error",
|
||||
"no-native-reassign": "error",
|
||||
"no-negated-condition": "off",
|
||||
"no-negated-in-lhs": "error",
|
||||
"no-nested-ternary": "error",
|
||||
"no-new": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-object": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "off",
|
||||
|
||||
"no-plusplus": ["error", {
|
||||
"allowForLoopAfterthoughts": true,
|
||||
}],
|
||||
|
||||
"no-proto": "error",
|
||||
"no-restricted-exports": "error",
|
||||
"no-restricted-globals": "error",
|
||||
"no-restricted-imports": "error",
|
||||
"no-restricted-properties": "error",
|
||||
"no-restricted-syntax": "error",
|
||||
"no-return-assign": "error",
|
||||
"no-return-await": "off",
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
|
||||
"no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
"no-spaced-func": "error",
|
||||
|
||||
"no-tabs": "off",
|
||||
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-ternary": "off",
|
||||
"no-throw-literal": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-undefined": "off",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-use-before-define": "off",
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-call": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-constructor": "off",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "error",
|
||||
|
||||
"no-void": ["error", {
|
||||
"allowAsStatement": true,
|
||||
}],
|
||||
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"nonblock-statement-body-position": "error",
|
||||
"object-curly-newline": "error",
|
||||
"object-curly-spacing": "off",
|
||||
"object-shorthand": "off",
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
":": "before",
|
||||
"||": "before",
|
||||
"&&": "before",
|
||||
"??": "before",
|
||||
},
|
||||
}],
|
||||
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "error",
|
||||
"prefer-const": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-named-capture-group": "off",
|
||||
"prefer-numeric-literals": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"prefer-reflect": "off",
|
||||
"prefer-regex-literals": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"quote-props": "off",
|
||||
|
||||
"quotes": ["error", "double", {
|
||||
"avoidEscape": true,
|
||||
}],
|
||||
|
||||
"radix": ["error", "always"],
|
||||
"require-atomic-updates": "off",
|
||||
"require-await": "off",
|
||||
"require-jsdoc": "off",
|
||||
"require-unicode-regexp": "off",
|
||||
"rest-spread-spacing": ["error", "never"],
|
||||
"semi": "error",
|
||||
"semi-spacing": "error",
|
||||
"semi-style": "off",
|
||||
"sort-imports": "off",
|
||||
"sort-keys": "off",
|
||||
"sort-vars": "off",
|
||||
"space-before-blocks": "error",
|
||||
"space-before-function-paren": "off",
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-infix-ops": "off",
|
||||
"space-unary-ops": "error",
|
||||
|
||||
"spaced-comment": ["error", "always", {
|
||||
"line": {
|
||||
"markers": ["/"],
|
||||
},
|
||||
}],
|
||||
|
||||
"switch-colon-spacing": "error",
|
||||
"symbol-description": "error",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"template-tag-spacing": "error",
|
||||
"unicode-bom": ["error", "never"],
|
||||
|
||||
"vars-on-top": "error",
|
||||
"wrap-iife": "error",
|
||||
"wrap-regex": "off",
|
||||
"yield-star-spacing": "error",
|
||||
|
||||
"yoda": ["error", "never", {
|
||||
"exceptRange": true,
|
||||
}],
|
||||
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
|
||||
"node/no-missing-import": "off",
|
||||
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
|
||||
"node/no-unpublished-require": "off",
|
||||
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
|
||||
"node/shebang": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["{packages,plugins}/**/*.ts?(x)"],
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
extends: compat.extends("plugin:@typescript-eslint/recommended"),
|
||||
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
|
||||
"@typescript-eslint/no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
// Allow require imports where necessary (dynamic loading, webpack configs, etc.)
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
|
||||
"prefer-const": "off",
|
||||
"no-shadow": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.mocha,
|
||||
},
|
||||
|
||||
sourceType: "module",
|
||||
parserOptions: {},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
}, {
|
||||
files: [
|
||||
"packages/web_ui/src/**/*.ts?(x)",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
|
||||
"{packages,plugins}/*/web/**/*.ts?(x)",
|
||||
],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
|
||||
"sourceType": "module",
|
||||
|
||||
parserOptions: {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
"node/shebang": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["packages/create/templates/**/*.?(c){t,j}s?(x)"],
|
||||
|
||||
"rules": {
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"@stylistic/indent": "off",
|
||||
"spaced-comment": "off",
|
||||
"comma-spacing": "off",
|
||||
"max-len": "off",
|
||||
"indent": "off",
|
||||
},
|
||||
}, globalIgnores(["**/dist/", "dist/"])]);
|
||||
+3
-12
@@ -15,10 +15,9 @@
|
||||
"cover": "c8 pnpm test",
|
||||
"fast-cover": "FAST_TEST=y c8 pnpm test",
|
||||
"ci-cover": "c8 --reporter=lcovonly pnpm test",
|
||||
"lint": "eslint packages plugins test",
|
||||
"lint-fix": "eslint --fix packages plugins test",
|
||||
"lint": "oxlint packages plugins test",
|
||||
"lint-fix": "oxlint --fix packages plugins test",
|
||||
"clean": "node ./clean.js",
|
||||
"docs": "typedoc",
|
||||
"bundle-dependencies": "bundle-dependencies"
|
||||
},
|
||||
"c8": {
|
||||
@@ -43,22 +42,14 @@
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@eslint/eslintrc": "^3.3.7",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"c8": "^12.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.71.2",
|
||||
"@typescript-eslint/parser": "^8.71.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"express": "catalog:",
|
||||
"form-data": "^4.0.6",
|
||||
"globals": "^17.13.0",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"mocha": "^11.8.0",
|
||||
"typedoc": "^0.28.20",
|
||||
"oxlint": "^1.88.0",
|
||||
"typescript": "catalog:",
|
||||
"yargs": "catalog:",
|
||||
"yauzl": "catalog:",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
webpack.config.js
|
||||
vite.config.js
|
||||
dist/browser
|
||||
@@ -147,15 +147,15 @@ async function handleBootstrapCommand(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
);
|
||||
|
||||
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const roles = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
lib.Role.fromJSON.bind(lib.Role),
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const users = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
UserRecord.fromJSON.bind(UserRecord),
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const userManager = new UserManager(users, roles, controllerConfig);
|
||||
|
||||
@@ -400,7 +400,8 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
}
|
||||
|
||||
logger.info(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = await lib.loadPluginList(args.pluginList);
|
||||
const skippedNpmPlugins = new Map<string, string>();
|
||||
let pluginList = await lib.loadPluginList(args.pluginList, true, skippedNpmPlugins);
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (command === "plugin") {
|
||||
@@ -415,7 +416,7 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
}
|
||||
|
||||
logger.info("Loading Plugin info");
|
||||
const pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
const pluginInfos = await lib.loadPluginInfos(pluginList, skippedNpmPlugins);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.registerPluginPermissions(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
@@ -474,13 +475,12 @@ async function startup() {
|
||||
return;
|
||||
}
|
||||
|
||||
controller = new Controller(
|
||||
clusterLogger,
|
||||
pluginInfos,
|
||||
controller = await Controller.fromConfig(
|
||||
controllerConfig,
|
||||
pluginInfos,
|
||||
clusterLogger,
|
||||
Boolean(args.canRestart),
|
||||
Boolean(args.recovery),
|
||||
...await Controller.bootstrap(controllerConfig)
|
||||
);
|
||||
|
||||
// Refuse to start if there are no users loaded
|
||||
|
||||
@@ -11,6 +11,10 @@ export { default as UserManager } from "./src/UserManager.js";
|
||||
export { default as UserRecord } from "./src/UserRecord.js";
|
||||
export { default as User } from "./src/User.js";
|
||||
export { default as WsServer } from "./src/WsServer.js";
|
||||
export {
|
||||
default as PlayerProxyServer, type PlayerProxyRouteRequest, type PlayerProxyRoute,
|
||||
} from "./src/PlayerProxyServer.js";
|
||||
export * as proxyProtocol from "./src/proxy_protocol.js";
|
||||
|
||||
if (import.meta.main) {
|
||||
bootstrap();
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"type": "module",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"prepare": "tsc --build && vite build",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
@@ -51,7 +51,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/busboy": "^1.5.5",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "catalog:",
|
||||
@@ -61,13 +60,12 @@
|
||||
"@types/semver": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/yargs": "catalog:",
|
||||
"antd": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"webpack": "catalog:",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-dev-middleware": "^8.3.0",
|
||||
"webpack-merge": "catalog:"
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default class BaseConnection extends lib.Link {
|
||||
let { id } = request;
|
||||
let modPack = this._controller.modPacks.get(id);
|
||||
if (!modPack) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
|
||||
throw new lib.NotFoundError(`Mod pack with ID ${id} does not exist`);
|
||||
}
|
||||
return modPack;
|
||||
}
|
||||
@@ -71,11 +71,11 @@ export default class BaseConnection extends lib.Link {
|
||||
async handleModPackGetDefaultRequest(): Promise<lib.ModPack> {
|
||||
let id = this._controller.config.get("controller.default_mod_pack_id");
|
||||
if (id === null) {
|
||||
throw new lib.RequestError("Default mod pack not set on controller");
|
||||
throw new lib.ConflictError("Default mod pack not set on controller");
|
||||
}
|
||||
let modPack = this._controller.modPacks.get(id);
|
||||
if (!modPack) {
|
||||
throw new lib.RequestError(`Default mod pack configured (${id}) does not exist`);
|
||||
throw new lib.NotFoundError(`Default mod pack configured (${id}) does not exist`);
|
||||
}
|
||||
return modPack;
|
||||
}
|
||||
@@ -84,10 +84,10 @@ export default class BaseConnection extends lib.Link {
|
||||
let filename = lib.ModInfo.filename(mod.name, mod.version);
|
||||
let modInfo = this._controller.modStore.files.get(filename);
|
||||
if (!modInfo) {
|
||||
throw new lib.RequestError(`Mod ${filename} does not exist on controller`);
|
||||
throw new lib.NotFoundError(`Mod ${filename} does not exist on controller`);
|
||||
}
|
||||
if (mod.sha1 && mod.sha1 !== modInfo.sha1) {
|
||||
throw new lib.RequestError(`Mod ${filename} checksum does not match controller's checksum`);
|
||||
throw new lib.ConflictError(`Mod ${filename} checksum does not match controller's checksum`);
|
||||
}
|
||||
return modInfo;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type Controller from "./Controller.js";
|
||||
import type InstanceRecord from "./InstanceRecord.js";
|
||||
import type ControlConnection from "./ControlConnection.js";
|
||||
import type HostConnection from "./HostConnection.js";
|
||||
import type { PlayerProxyRouteRequest } from "./PlayerProxyServer.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export type ControllerPluginContext = lib.PluginLoadContext<{
|
||||
@@ -28,6 +29,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
this.modsUpdated = this.newHook();
|
||||
this.rolesUpdated = this.newHook();
|
||||
this.playerEvent = this.newHook();
|
||||
this.playerProxyRoute = this.newHook();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,7 +109,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* @param curr - The current value of the field.
|
||||
* @param prev - The previous value of the field.
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @stylistic/max-len
|
||||
readonly instanceConfigFieldChanged: lib.AsyncHook<[instance: InstanceRecord, field: string, curr: unknown, prev: unknown]>;
|
||||
|
||||
/**
|
||||
@@ -165,7 +167,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* The connection the event occured on.
|
||||
* @param event - one of connect, drop, resume and close
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @stylistic/max-len
|
||||
readonly hostConnectionEvent: lib.AsyncHook<[connection: HostConnection, event: "connect" | "drop" | "resume" | "close"]>;
|
||||
|
||||
/**
|
||||
@@ -202,7 +204,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* The connection the event occured on.
|
||||
* @param event - one of connect, drop, resume, and close.
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @stylistic/max-len
|
||||
readonly controlConnectionEvent: lib.AsyncHook<[connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"]>;
|
||||
|
||||
/**
|
||||
@@ -266,6 +268,24 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* @param event - Information about the event.
|
||||
*/
|
||||
readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>;
|
||||
|
||||
/**
|
||||
* Called to pick the instance a player connecting through the player proxy goes to
|
||||
*
|
||||
* Invoked when a Factorio client has sent its name to the player proxy
|
||||
* enabled with controller.player_proxy_port. Handlers return the ID of
|
||||
* the instance to send the player to, or undefined to leave the choice
|
||||
* to other plugins and the controller. If the instance is stopped the
|
||||
* proxy starts it, which can take longer than the client is willing to
|
||||
* wait.
|
||||
*
|
||||
* Factorio clients connect twice when joining a server they don't have a
|
||||
* key for, so a handler must give the same answer for both attempts.
|
||||
*
|
||||
* @param request - Name, version and connection details of the player.
|
||||
* @returns ID of instance to route the player to.
|
||||
*/
|
||||
readonly playerProxyRoute: lib.AsyncHook<[request: PlayerProxyRouteRequest], number | undefined>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
|
||||
@@ -135,6 +135,8 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
|
||||
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
|
||||
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
|
||||
this.handle(lib.PluginSearchRequest, this.handlePluginSearchRequest.bind(this));
|
||||
this.handle(lib.PluginLatestVersionsRequest, this.handlePluginLatestVersionsRequest.bind(this));
|
||||
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
|
||||
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
|
||||
}
|
||||
@@ -153,7 +155,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
try {
|
||||
this.checkPermission(message, entry);
|
||||
} catch (err: any) {
|
||||
this.connector.sendResponseError(new lib.ResponseError(err.message, err.code), message.src);
|
||||
this.connector.sendResponseError(lib.Link.errorToResponse(err, false), message.src);
|
||||
logger.audit(`Permission denied for ${message.name} by ${this.user.id} from ${this.connector.dst}`);
|
||||
throw err;
|
||||
}
|
||||
@@ -195,12 +197,15 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
async handleControllerRestartRequest() {
|
||||
if (!this._controller.canRestart) {
|
||||
throw new lib.RequestError("Cannot restart, controller does not have a process monitor to restart it.");
|
||||
throw new lib.ConflictError("Cannot restart, controller does not have a process monitor to restart it.");
|
||||
}
|
||||
if (await lib.packageOperationRunning()) {
|
||||
throw new lib.ConflictError("Cannot restart while a plugin install or update is running on this machine.");
|
||||
}
|
||||
const downgrade = await this._controller.checkRestartDowngrade();
|
||||
if (downgrade) {
|
||||
const { installedVersion, runningVersion } = downgrade;
|
||||
throw new lib.RequestError(
|
||||
throw new lib.ConflictError(
|
||||
`Cannot restart controller with installed Clusterio version ${installedVersion} because it ` +
|
||||
`is older than running version ${runningVersion}. ` +
|
||||
"Stop the controller before starting the older version manually."
|
||||
@@ -298,13 +303,13 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
|
||||
if ((request.config["instance.assigned_host"] ?? null) !== null) {
|
||||
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
|
||||
throw new lib.ValidationError("instance.assigned_host must be set through the assign-host interface");
|
||||
}
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
if (request.cloneFromId) {
|
||||
const baseInstance = this._controller.instances.get(request.cloneFromId);
|
||||
if (!baseInstance) {
|
||||
throw new lib.RequestError(`Instance with ID ${request.cloneFromId} does not exist`);
|
||||
throw new lib.NotFoundError(`Instance with ID ${request.cloneFromId} does not exist`);
|
||||
}
|
||||
instanceConfig.update(baseInstance.config.toJSON(), false);
|
||||
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
|
||||
@@ -325,12 +330,12 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
private static validateInstanceConfigSetField(fieldName: string) {
|
||||
if (fieldName === "instance.assigned_host") {
|
||||
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
|
||||
throw new lib.ValidationError("instance.assigned_host must be set through the assign-host interface");
|
||||
}
|
||||
|
||||
if (fieldName === "instance.id") {
|
||||
// XXX is this worth implementing? It's race condition galore.
|
||||
throw new lib.RequestError("Setting instance.id is not supported");
|
||||
throw new lib.ValidationError("Setting instance.id is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,7 +393,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
let ready = new Promise<void>((resolve, reject) => {
|
||||
stream.events.on("source", resolve);
|
||||
stream.events.on("timeout", () => reject(
|
||||
new lib.RequestError("Timed out establishing stream from host")
|
||||
new lib.TimeoutError("Timed out establishing stream from host")
|
||||
));
|
||||
});
|
||||
ready.catch(() => {});
|
||||
@@ -405,17 +410,17 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
|
||||
if (request.sourceInstanceId === request.targetInstanceId) {
|
||||
throw new lib.RequestError("Source and target instance may not be the same");
|
||||
throw new lib.ValidationError("Source and target instance may not be the same");
|
||||
}
|
||||
let sourceInstance = this._controller.instances.getForRequest(request.sourceInstanceId);
|
||||
let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId);
|
||||
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
|
||||
let targetHostId = targetInstance.config.get("instance.assigned_host");
|
||||
if (sourceHostId === null) {
|
||||
throw new lib.RequestError("Source instance is not assigned a host");
|
||||
throw new lib.ConflictError("Source instance is not assigned a host");
|
||||
}
|
||||
if (targetHostId === null) {
|
||||
throw new lib.RequestError("Target instance is not assigned a host");
|
||||
throw new lib.ConflictError("Target instance is not assigned a host");
|
||||
}
|
||||
|
||||
// Let host handle request if source and target is on the same host.
|
||||
@@ -426,12 +431,12 @@ export default class ControlConnection extends BaseConnection {
|
||||
// Check connectivity
|
||||
let sourceHostConnection = this._controller.wsServer.hostConnections.get(sourceHostId);
|
||||
if (!sourceHostConnection || sourceHostConnection.connector.closing) {
|
||||
throw new lib.RequestError("Source host is not connected to the controller");
|
||||
throw new lib.UnavailableError("Source host is not connected to the controller");
|
||||
}
|
||||
|
||||
let targetHostConnection = this._controller.wsServer.hostConnections.get(targetHostId);
|
||||
if (!targetHostConnection || targetHostConnection.connector.closing) {
|
||||
throw new lib.RequestError("Target host is not connected to the controller");
|
||||
throw new lib.UnavailableError("Target host is not connected to the controller");
|
||||
}
|
||||
|
||||
// Create stream to proxy from target to source
|
||||
@@ -440,7 +445,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (stream.source) {
|
||||
stream.source.destroy();
|
||||
}
|
||||
stream.events.emit("error", new lib.RequestError("Timed out establishing transfer stream"));
|
||||
stream.events.emit("error", new lib.TimeoutError("Timed out establishing transfer stream"));
|
||||
});
|
||||
|
||||
// Ignore errors if not listening for them to avoid crash.
|
||||
@@ -480,10 +485,10 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleModPackCreateRequest(request: lib.ModPackCreateRequest) {
|
||||
let modPack = request.modPack;
|
||||
if (modPack.id === undefined) {
|
||||
throw new lib.RequestError("Mod pack need an ID to be created");
|
||||
throw new lib.ValidationError("Mod pack need an ID to be created");
|
||||
}
|
||||
if (this._controller.modPacks.has(modPack.id)) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${modPack.id} already exist`);
|
||||
throw new lib.ConflictError(`Mod pack with ID ${modPack.id} already exist`);
|
||||
}
|
||||
this._controller.modPacks.set(modPack);
|
||||
}
|
||||
@@ -491,7 +496,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleModPackUpdateRequest(request: lib.ModPackUpdateRequest) {
|
||||
let modPack = request.modPack;
|
||||
if (modPack.id === undefined || !this._controller.modPacks.has(modPack.id)) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${modPack.id} does not exist`);
|
||||
throw new lib.NotFoundError(`Mod pack with ID ${modPack.id} does not exist`);
|
||||
}
|
||||
this._controller.modPacks.set(modPack);
|
||||
}
|
||||
@@ -500,7 +505,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
let { id } = request;
|
||||
let modPack = this._controller.modPacks.getMutable(id);
|
||||
if (!modPack) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
|
||||
throw new lib.NotFoundError(`Mod pack with ID ${id} does not exist`);
|
||||
}
|
||||
this._controller.modPacks.delete(modPack);
|
||||
this._controller.deleteNote("mod_pack", id);
|
||||
@@ -582,7 +587,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
author: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].author, b.versions[0].author),
|
||||
};
|
||||
if (!Object.prototype.hasOwnProperty.call(sorters, sort)) {
|
||||
throw new lib.RequestError(`Invalid value for sort: ${sort}`);
|
||||
throw new lib.ValidationError(`Invalid value for sort: ${sort}`);
|
||||
}
|
||||
resultList.sort(sorters[sort as keyof typeof sorters]);
|
||||
let order = request.sortOrder;
|
||||
@@ -629,7 +634,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
} catch (error: any) {
|
||||
logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error}`);
|
||||
// Propagate a user-friendly error back to the client
|
||||
throw new lib.RequestError(`Portal mod fetch failed: ${error}`);
|
||||
throw new lib.InternalServerError(`Portal mod fetch failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,7 +652,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
const token = this._controller.config.get("controller.factorio_token");
|
||||
|
||||
if (!username || username === "" || !token || token === "") {
|
||||
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
|
||||
throw new lib.ConflictError("Factorio credentials (username, token) not configured on the controller.");
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -656,18 +661,16 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error(`Error downloading mods from portal: ${error.message}`);
|
||||
// Improve error message clarity
|
||||
let errorMessage = "Mod portal download failed";
|
||||
if (error instanceof lib.RequestError) {
|
||||
errorMessage += `: ${error.message}`;
|
||||
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
|
||||
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
|
||||
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
|
||||
errorMessage += ": Mod version not found on the portal.";
|
||||
} else {
|
||||
errorMessage += `: ${error.message}`;
|
||||
const errorMessage = "Mod portal download failed";
|
||||
if (error.message.includes("401") || error.message.includes("Unauthorized")) {
|
||||
throw new lib.ConflictError(
|
||||
`${errorMessage}: Authentication failed. Check Factorio username/token in controller config.`
|
||||
);
|
||||
}
|
||||
throw new lib.RequestError(errorMessage);
|
||||
if (error.message.includes("404") || error.message.includes("Not Found")) {
|
||||
throw new lib.NotFoundError(`${errorMessage}: Mod version not found on the portal.`);
|
||||
}
|
||||
throw new lib.InternalServerError(`${errorMessage}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,7 +680,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
for (const mod of request.mods) {
|
||||
const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version));
|
||||
if (!modInfo) {
|
||||
throw new lib.RequestError(`Failed to find mod ${mod.name} in store after download attempt.`);
|
||||
throw new lib.InternalServerError(`Failed to find mod ${mod.name} in store after download attempt.`);
|
||||
}
|
||||
modInfos.push(modInfo);
|
||||
}
|
||||
@@ -711,7 +714,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
} catch (error: any) {
|
||||
if (!error.message.includes("404") && !error.message.includes("Not Found")) {
|
||||
logger.error(`Error fetching mod from portal: ${error.message}`);
|
||||
throw new lib.RequestError(`Dependency resolution failed: ${error.message}`);
|
||||
throw new lib.InternalServerError(`Dependency resolution failed: ${error.message}`);
|
||||
} else {
|
||||
releases = null;
|
||||
}
|
||||
@@ -951,7 +954,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
let { id, name, description, permissions } = request;
|
||||
let role = this._controller.roles.getMutable(id);
|
||||
if (!role) {
|
||||
throw new lib.RequestError(`Role with ID ${id} does not exist`);
|
||||
throw new lib.NotFoundError(`Role with ID ${id} does not exist`);
|
||||
}
|
||||
|
||||
role.name = name;
|
||||
@@ -963,7 +966,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleRoleGrantDefaultPermissionsRequest(request: lib.RoleGrantDefaultPermissionsRequest) {
|
||||
let role = this._controller.roles.get(request.id);
|
||||
if (!role) {
|
||||
throw new lib.RequestError(`Role with ID ${request.id} does not exist`);
|
||||
throw new lib.NotFoundError(`Role with ID ${request.id} does not exist`);
|
||||
}
|
||||
|
||||
role.grantDefaultPermissions();
|
||||
@@ -975,7 +978,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
const role = this._controller.roles.get(id);
|
||||
if (!role) {
|
||||
throw new lib.RequestError(`Role with ID ${id} does not exist`);
|
||||
throw new lib.NotFoundError(`Role with ID ${id} does not exist`);
|
||||
}
|
||||
this._controller.roles.delete(role);
|
||||
this._controller.deleteNote("role", id);
|
||||
@@ -994,7 +997,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleNoteSetRequest(request: lib.NoteSetRequest) {
|
||||
const { resourceType, resourceId, content } = request;
|
||||
if (!resourceType || !resourceId) {
|
||||
throw new lib.RequestError("resourceType and resourceId must not be empty");
|
||||
throw new lib.ValidationError("resourceType and resourceId must not be empty");
|
||||
}
|
||||
this._controller.setNote(resourceType, resourceId, content, this.user.name);
|
||||
}
|
||||
@@ -1003,7 +1006,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
let name = request.name;
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User ${name} does not exist`);
|
||||
throw new lib.NotFoundError(`User ${name} does not exist`);
|
||||
}
|
||||
|
||||
return user.toUserDetails();
|
||||
@@ -1020,7 +1023,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
|
||||
let user = this._controller.users.getByName(request.name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${request.name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${request.name}' does not exist`);
|
||||
}
|
||||
if (user.id !== this.user.id) {
|
||||
this.user.checkPermission("core.user.revoke_other_token");
|
||||
@@ -1037,12 +1040,12 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
|
||||
let user = this._controller.users.getByName(request.name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${request.name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${request.name}' does not exist`);
|
||||
}
|
||||
|
||||
for (let roleId of request.roles) {
|
||||
if (!this._controller.roles.has(roleId)) {
|
||||
throw new lib.RequestError(`Role with ID ${roleId} does not exist`);
|
||||
throw new lib.NotFoundError(`Role with ID ${roleId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1058,7 +1061,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1074,7 +1077,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,7 +1095,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1104,7 +1107,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
let name = request.name;
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${name}' does not exist`);
|
||||
}
|
||||
|
||||
this._controller.users.deleteUser(user);
|
||||
@@ -1335,47 +1338,54 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
async handleControllerUpdateRequest(request: lib.ControllerUpdateRequest) {
|
||||
if (!this._controller.config.get("controller.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Remote updates are disabled on this machine");
|
||||
}
|
||||
return await lib.updatePackage("@clusterio/controller");
|
||||
}
|
||||
|
||||
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
|
||||
if (!this._controller.config.get("controller.allow_plugin_updates")) {
|
||||
throw new lib.RequestError("Plugin updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin updates are disabled on this machine");
|
||||
}
|
||||
return await lib.handlePluginUpdate(request.pluginPackage, this._controller.pluginInfos);
|
||||
}
|
||||
|
||||
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
|
||||
if (!this._controller.config.get("controller.allow_plugin_install")) {
|
||||
throw new lib.RequestError("Plugin installs are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin installs are disabled on this machine");
|
||||
}
|
||||
await lib.handlePluginInstall(request.pluginPackage);
|
||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
|
||||
if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
|
||||
const npmPackage = await lib.handlePluginInstall(request.pluginPackage);
|
||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it. Version
|
||||
// changes to loaded plugins are noticed there.
|
||||
if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === npmPackage)) {
|
||||
this._controller.config.restartRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
|
||||
if (!this._controller.config.get("controller.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Remote updates are disabled on this machine");
|
||||
}
|
||||
if (!this._controller.config.get("controller.allow_plugin_updates")) {
|
||||
throw new lib.RequestError("Plugin updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin updates are disabled on this machine");
|
||||
}
|
||||
return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos);
|
||||
}
|
||||
|
||||
async handlePluginListRequest(request: lib.PluginListRequest) {
|
||||
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo,
|
||||
this._controller.loadedPlugins.has(pluginInfo),
|
||||
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
async handlePluginSearchRequest(request: lib.PluginSearchRequest) {
|
||||
return await lib.searchPlugins(request.query, request.page, request.pageSize);
|
||||
}
|
||||
|
||||
async handlePluginLatestVersionsRequest(request: lib.PluginLatestVersionsRequest) {
|
||||
return new lib.PluginLatestVersionsRequest.Response(await lib.fetchLatestVersions(
|
||||
request.packages, this._controller.pluginLatestVersionCache,
|
||||
));
|
||||
}
|
||||
|
||||
async handlePluginListRequest(request: lib.PluginListRequest) {
|
||||
return await this._controller.listPlugins();
|
||||
}
|
||||
|
||||
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
|
||||
if (this.ws_dumper) {
|
||||
return; // Already dumping
|
||||
|
||||
@@ -32,6 +32,7 @@ import HostRecord from "./HostRecord.js";
|
||||
import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js";
|
||||
import ControllerRouter from "./ControllerRouter.js";
|
||||
import InstanceManager from "./InstanceManager.js";
|
||||
import PlayerProxyServer, { type PlayerProxyRouteRequest } from "./PlayerProxyServer.js";
|
||||
|
||||
const endpointDurationSummary = new Summary(
|
||||
"clusterio_controller_http_endpoint_duration_seconds",
|
||||
@@ -52,7 +53,7 @@ type ControllerDebugEvents = {
|
||||
|
||||
type ControllerEvents = {
|
||||
"stop": [],
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages all controller related operations
|
||||
@@ -75,6 +76,8 @@ export default class Controller {
|
||||
hooks = new ControllerHooks(logger);
|
||||
/** Plugins which are currently loaded */
|
||||
loadedPlugins: Set<lib.PluginNodeEnvInfo> = new Set();
|
||||
/** Latest versions of plugins on npm, as shown on the plugins page */
|
||||
pluginLatestVersionCache: lib.LatestVersionCache = new Map();
|
||||
|
||||
/** WebSocket server */
|
||||
wsServer: WsServer;
|
||||
@@ -82,6 +85,7 @@ export default class Controller {
|
||||
trustedProxies: BlockList;
|
||||
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
||||
private _events = new events.EventEmitter<ControllerEvents>();
|
||||
playerProxyServer: PlayerProxyServer | null = null;
|
||||
|
||||
/** Event subscription controller */
|
||||
subscriptions: lib.SubscriptionController;
|
||||
@@ -104,7 +108,7 @@ export default class Controller {
|
||||
_registeredEvents = new Map<lib.EventClass<unknown>, lib.EventHandler<unknown>>();
|
||||
_snoopedEvents = new Map();
|
||||
|
||||
devMiddleware: any | null = null;
|
||||
devServer: import("vite").ViteDevServer | null = null;
|
||||
|
||||
startedAtMs = Date.now();
|
||||
autosaveInterval?: ReturnType<typeof setInterval>;
|
||||
@@ -148,7 +152,14 @@ export default class Controller {
|
||||
}
|
||||
});
|
||||
|
||||
static async bootstrap(config: lib.ControllerConfig) {
|
||||
/** Create a controller with its datastores and mod store loaded from disk */
|
||||
static async fromConfig(
|
||||
config: lib.ControllerConfig,
|
||||
pluginInfos: lib.PluginNodeEnvInfo[],
|
||||
clusterLogger: winston.Logger,
|
||||
canRestart = false,
|
||||
recoveryMode = false,
|
||||
) {
|
||||
let databaseDirectory = config.get("controller.database_directory");
|
||||
await fs.mkdir(databaseDirectory, { recursive: true });
|
||||
|
||||
@@ -158,23 +169,23 @@ export default class Controller {
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
);
|
||||
|
||||
const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const systems = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "systems.json"),
|
||||
lib.SystemInfo.fromJSON.bind(lib.SystemInfo),
|
||||
this.migrateSystems,
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const hosts = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "hosts.json"),
|
||||
HostRecord.fromJSON.bind(HostRecord),
|
||||
this.migrateHosts, this.finaliseHosts,
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const instances = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const instances = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "instances.json"),
|
||||
InstanceRecord.fromJSON.bind(InstanceRecord),
|
||||
this.migrateInstances, this.finaliseInstances,
|
||||
).bootstrap());
|
||||
));
|
||||
// Save plugin versions written while loading
|
||||
for (const instance of instances.values()) {
|
||||
if (instance.config.dirty) {
|
||||
@@ -182,33 +193,33 @@ export default class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
const saves = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const saves = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "saves.json"),
|
||||
lib.SaveDetails.fromJSON.bind(lib.SaveDetails)
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const modPacks = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "mod-packs.json"),
|
||||
lib.ModPack.fromJSON.bind(lib.ModPack),
|
||||
this.migrateModPacks,
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const roles = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
lib.Role.fromJSON.bind(lib.Role),
|
||||
this.migrateRoles,
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const users = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
UserRecord.fromJSON.bind(UserRecord),
|
||||
undefined, this.finaliseUsers,
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const notes = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const notes = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "notes.json"),
|
||||
lib.Note.fromJSON.bind(lib.Note),
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
let modsDirectory = config.get("controller.mods_directory");
|
||||
await fs.mkdir(modsDirectory, { recursive: true });
|
||||
@@ -223,7 +234,12 @@ export default class Controller {
|
||||
lib.Role.ensureDefaultAdminRole(roles);
|
||||
lib.Role.ensureDefaultPlayerRole(roles);
|
||||
|
||||
return [
|
||||
return new this(
|
||||
clusterLogger,
|
||||
pluginInfos,
|
||||
config,
|
||||
canRestart,
|
||||
recoveryMode,
|
||||
systems,
|
||||
hosts,
|
||||
instances,
|
||||
@@ -233,7 +249,7 @@ export default class Controller {
|
||||
roles,
|
||||
users,
|
||||
notes,
|
||||
] as const;
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -272,7 +288,6 @@ export default class Controller {
|
||||
this.clusterLogger = clusterLogger;
|
||||
this.pluginInfos = pluginInfos;
|
||||
this.config = config;
|
||||
|
||||
this.app = express();
|
||||
this.app.locals.controller = this;
|
||||
this.app.locals.streams = new Map();
|
||||
@@ -364,7 +379,7 @@ export default class Controller {
|
||||
}, 600e3);
|
||||
logSizeGauge.callback = async () => { logSizeGauge.set(await this.logSize()); };
|
||||
|
||||
// Start webpack development server if enabled
|
||||
// Start Vite development server if enabled
|
||||
if (args.dev || args.devPlugin) {
|
||||
await this._startDevServer(args);
|
||||
}
|
||||
@@ -402,20 +417,37 @@ export default class Controller {
|
||||
}
|
||||
|
||||
Controller.addAppRoutes(this.app, this.pluginInfos);
|
||||
if (this.devServer) {
|
||||
this.app.use(this.devServer.middlewares);
|
||||
}
|
||||
|
||||
if (!args.dev) {
|
||||
let manifestPath = path.join(import.meta.dirname, "..", "..", "web", "manifest.json");
|
||||
|
||||
let manifest = await Controller.loadJsonObject(manifestPath);
|
||||
if (!manifest["main.js"]) {
|
||||
logger.error("Missing main.js entry in dist/web/manifest.json");
|
||||
if (!manifest.main) {
|
||||
logger.error("Missing main entry in dist/web/manifest.json");
|
||||
}
|
||||
this.app.locals.mainBundle = manifest["main.js"] || "no_web_build";
|
||||
this.app.locals.webBundle = {
|
||||
main: manifest.main || "no_web_build",
|
||||
imports: manifest.imports || {},
|
||||
};
|
||||
}
|
||||
this.app.locals.staticRoot = this.config.get("controller.static_url");
|
||||
|
||||
// Load plugins
|
||||
// Created before plugins load so they can use it, but only started after they have attached their hooks
|
||||
const playerProxyPort = this.config.get("controller.player_proxy_port");
|
||||
if (playerProxyPort !== null) {
|
||||
this.playerProxyServer = new PlayerProxyServer(
|
||||
this,
|
||||
logger.child({ component: "player_proxy" }),
|
||||
playerProxyPort,
|
||||
);
|
||||
}
|
||||
|
||||
await this.loadPlugins();
|
||||
await this.playerProxyServer?.start();
|
||||
|
||||
// Log all express errors from middleware or routes (app.use is missing the type overload)
|
||||
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
||||
@@ -478,37 +510,44 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async _startDevServer(args: ControllerArgs) {
|
||||
logger.warn("Webpack development mode enabled");
|
||||
logger.warn("Vite development mode enabled");
|
||||
|
||||
const webpack = (await import("webpack")).default;
|
||||
const webpackDevMiddleware = (await import("webpack-dev-middleware")).default;
|
||||
const webpackConfigs = [];
|
||||
const vite = await import("vite");
|
||||
const webUiVite = await import("@clusterio/web_ui/vite.mjs");
|
||||
|
||||
if (args.dev) {
|
||||
// @ts-expect-error this file is run from a different folder
|
||||
webpackConfigs.push((await import("../../../webpack.config.cjs")).default({})); // Path outside of build
|
||||
}
|
||||
if (args.devPlugin) {
|
||||
let devPlugins = new Map();
|
||||
for (let name of args.devPlugin) {
|
||||
let info = this.pluginInfos.find(i => i.name === name);
|
||||
if (!info) {
|
||||
throw new lib.StartupError(`No plugin named ${name}`);
|
||||
}
|
||||
const webpackConfig = path.posix.join(info.requirePath, "webpack.config.cjs");
|
||||
let config = (await import(
|
||||
path.isAbsolute(webpackConfig) ? pathToFileURL(webpackConfig).href : webpackConfig
|
||||
)).default({});
|
||||
devPlugins.set(name, webpackConfigs.length);
|
||||
webpackConfigs.push(config);
|
||||
const devPlugins = new Map<string, string>();
|
||||
const pluginDirs = new Map<string, string>();
|
||||
for (let name of (args.devPlugin ?? []).map(String)) {
|
||||
let info = this.pluginInfos.find(i => i.name === name);
|
||||
if (!info) {
|
||||
throw new lib.StartupError(`No plugin named ${name}`);
|
||||
}
|
||||
this.app.locals.devPlugins = devPlugins;
|
||||
pluginDirs.set(name, path.dirname(info.packagePath));
|
||||
devPlugins.set(name, webUiVite.devPluginUrl(name));
|
||||
}
|
||||
this.app.locals.devPlugins = devPlugins;
|
||||
|
||||
|
||||
const compiler = webpack(webpackConfigs);
|
||||
this.devMiddleware = webpackDevMiddleware(compiler, { serverSideRender: true });
|
||||
this.app.use(this.devMiddleware);
|
||||
const controllerDir = path.join(import.meta.dirname, "..", "..", "..");
|
||||
this.devServer = await vite.createServer({
|
||||
configFile: path.join(controllerDir, "vite.config.js"),
|
||||
root: controllerDir,
|
||||
base: "/static/",
|
||||
appType: "custom",
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
preTransformRequests: false,
|
||||
},
|
||||
plugins: [webUiVite.clusterioDevPlugins(pluginDirs)],
|
||||
});
|
||||
if (args.dev) {
|
||||
this.app.locals.webBundle = {
|
||||
main: this.devServer.config.plugins.find(p => p.name === "clusterio:app")!.api.entry,
|
||||
imports: Object.fromEntries(webUiVite.sharedModules.map(
|
||||
name => [name, webUiVite.devSharedUrl(name)]
|
||||
)),
|
||||
};
|
||||
}
|
||||
this.app.locals.devServer = this.devServer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -559,12 +598,17 @@ export default class Controller {
|
||||
await this.clusterLogIndex.save();
|
||||
}
|
||||
|
||||
if (this.devMiddleware) {
|
||||
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
|
||||
if (this.devServer) {
|
||||
await this.devServer.close();
|
||||
}
|
||||
|
||||
await this.hooks.shutdown.invoke();
|
||||
|
||||
if (this.playerProxyServer) {
|
||||
await this.playerProxyServer.stop();
|
||||
this.playerProxyServer = null;
|
||||
}
|
||||
|
||||
await this.wsServer.stop();
|
||||
|
||||
let stopTasks = [];
|
||||
@@ -634,7 +678,9 @@ export default class Controller {
|
||||
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
|
||||
}
|
||||
const restartRequired = await this.checkRestartRequired();
|
||||
requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired, this.startedAtMs));
|
||||
requests.push(lib.gatherSystemInfo(
|
||||
"controller", this.canRestart, restartRequired, this.startedAtMs, await this.listPlugins(),
|
||||
));
|
||||
const newMetrics = await Promise.all(requests);
|
||||
for (const metric of newMetrics) {
|
||||
this.systems.set(metric);
|
||||
@@ -644,6 +690,15 @@ export default class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/** Plugins on the controller, including those installed or updated since it started */
|
||||
async listPlugins() {
|
||||
return await lib.listPluginDetails(
|
||||
this.pluginInfos,
|
||||
pluginInfo => this.loadedPlugins.has(pluginInfo),
|
||||
pluginInfo => this.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
);
|
||||
}
|
||||
|
||||
async checkRestartRequired() {
|
||||
if (this.config.restartRequired) {
|
||||
return true;
|
||||
@@ -989,6 +1044,94 @@ export default class Controller {
|
||||
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the instance a player connecting through the player proxy goes to
|
||||
*
|
||||
* Plugins get the first say through the playerProxyRoute hook, then the
|
||||
* instance the proxy port is dedicated to, the instance the player was
|
||||
* last on, controller.player_proxy_default_instance_id, and finally any
|
||||
* running instance.
|
||||
*
|
||||
* @param request - Details about the connecting player.
|
||||
* @returns ID of the instance to route to, or undefined if there is none.
|
||||
*/
|
||||
async resolvePlayerRoute(request: PlayerProxyRouteRequest): Promise<number | undefined> {
|
||||
const routable = (id: number | undefined) => {
|
||||
const instance = id === undefined ? undefined : this.instances.get(id);
|
||||
return Boolean(instance && !instance.isDeleted && instance.config.get("instance.assigned_host") !== null);
|
||||
};
|
||||
|
||||
for (const result of await this.hooks.playerProxyRoute.collect(request)) {
|
||||
if (routable(result)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (routable(request.portInstanceId)) {
|
||||
return request.portInstanceId;
|
||||
}
|
||||
|
||||
const lastInstanceId = this._lastInstanceOfPlayer(request.username);
|
||||
if (routable(lastInstanceId)) {
|
||||
return lastInstanceId;
|
||||
}
|
||||
|
||||
const defaultId = this.config.get("controller.player_proxy_default_instance_id") ?? undefined;
|
||||
if (routable(defaultId)) {
|
||||
return defaultId;
|
||||
}
|
||||
|
||||
for (const instance of this.instances.values()) {
|
||||
if (instance.status === "running") {
|
||||
return instance.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address players reach the player proxy on
|
||||
*
|
||||
* @param port -
|
||||
* Proxy port to give the address for, defaults to the main port.
|
||||
* @returns controller.player_proxy_public_address, or the host name of
|
||||
* controller.public_url with the port if not set. Undefined if the
|
||||
* proxy is disabled or neither is set.
|
||||
*/
|
||||
getPlayerProxyAddress(port?: number) {
|
||||
if (!this.playerProxyServer) {
|
||||
return undefined;
|
||||
}
|
||||
const configured = this.config.get("controller.player_proxy_public_address");
|
||||
if (configured) {
|
||||
if (port === undefined) {
|
||||
return configured;
|
||||
}
|
||||
// Swap the port, host may be a name, IPv4 address or IPv6 address in brackets
|
||||
const host = /^(\[[^\]]+\]|[^:]+)(?::\d+)?$/.exec(configured)?.[1] ?? `[${configured}]`;
|
||||
return `${host}:${port}`;
|
||||
}
|
||||
const publicUrl = this.config.get("controller.public_url");
|
||||
if (!publicUrl) {
|
||||
return undefined;
|
||||
}
|
||||
return `${new URL(publicUrl).hostname}:${port ?? this.playerProxyServer.port}`;
|
||||
}
|
||||
|
||||
private _lastInstanceOfPlayer(name: string) {
|
||||
const user = this.users.getByName(name);
|
||||
let lastId: number | undefined;
|
||||
let lastJoinMs = -Infinity;
|
||||
for (const [instanceId, stats] of user?.instanceStats ?? []) {
|
||||
const joinMs = stats.lastJoinAt?.getTime() ?? -Infinity;
|
||||
if (joinMs > lastJoinMs) {
|
||||
lastJoinMs = joinMs;
|
||||
lastId = instanceId;
|
||||
}
|
||||
}
|
||||
return lastId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all saves currently stored for the given instance, if any
|
||||
* @param instanceId - Id of Instance to clear saves for.
|
||||
@@ -1332,26 +1475,29 @@ export default class Controller {
|
||||
let depth = routeDepth + Number(req.path.slice(-1) === "/");
|
||||
let webRoot = "../".repeat(depth) || "./";
|
||||
let staticRoot = res.app.locals.staticRoot ?? `${webRoot}static/`;
|
||||
let mainBundle: string = "";
|
||||
if (res.app.locals.mainBundle) {
|
||||
mainBundle = res.app.locals.mainBundle;
|
||||
} else {
|
||||
let stats = res.locals.webpack.devMiddleware.stats.stats[0];
|
||||
mainBundle = stats.toJson().assetsByChunkName["main"][0];
|
||||
}
|
||||
mainBundle = routes.stripStaticPrefix(mainBundle);
|
||||
const { main, imports } = res.app.locals.webBundle;
|
||||
const importMap = JSON.stringify({
|
||||
imports: Object.fromEntries(Object.entries(imports).map(
|
||||
([name, file]) => [name, `${staticRoot}${routes.stripStaticPrefix(file)}`]
|
||||
)),
|
||||
}).replace(/</g, "\\u003c");
|
||||
|
||||
fs.readFile(
|
||||
path.join(import.meta.dirname, "..", "..", "..", "web", "index.html"),
|
||||
"utf8",
|
||||
).then((content) => {
|
||||
res.type("text/html");
|
||||
res.send(content
|
||||
).then(async (content) => {
|
||||
content = content
|
||||
.replace(/__CLUSTER_NAME__/g, res.app.locals.controller.config.get("controller.name"))
|
||||
.replace(/__WEB_ROOT__/g, webRoot)
|
||||
.replace(/__STATIC_ROOT__/g, staticRoot)
|
||||
.replace(/__MAIN_BUNDLE__/g, mainBundle)
|
||||
);
|
||||
.replace(/__IMPORT_MAP__/g, () => importMap)
|
||||
.replace(/__MAIN_BUNDLE__/g, () => routes.stripStaticPrefix(main))
|
||||
;
|
||||
if (res.app.locals.devServer) {
|
||||
content = await res.app.locals.devServer.transformIndexHtml(req.originalUrl, content);
|
||||
}
|
||||
res.type("text/html");
|
||||
res.send(content);
|
||||
}).catch(err => {
|
||||
next(err);
|
||||
});
|
||||
@@ -1440,24 +1586,24 @@ export default class Controller {
|
||||
} else if (dst.type === lib.Address.control) {
|
||||
connection = this.wsServer.controlConnections.get(dst.id);
|
||||
if (!connection) {
|
||||
throw new lib.RequestError("Target control connection does not exist.");
|
||||
throw new lib.NotFoundError("Target control connection does not exist.");
|
||||
}
|
||||
|
||||
} else if (dst.type === lib.Address.instance) {
|
||||
const instance = this.instances.getForRequest(dst.id);
|
||||
let hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId === null) {
|
||||
throw new lib.RequestError("Instance is not assigned to a host");
|
||||
throw new lib.ConflictError("Instance is not assigned to a host");
|
||||
}
|
||||
connection = this.wsServer.hostConnections.get(hostId);
|
||||
if (!connection) {
|
||||
throw new lib.RequestError("Host containing instance is not connected");
|
||||
throw new lib.UnavailableError("Host containing instance is not connected");
|
||||
}
|
||||
|
||||
} else if (dst.type === lib.Address.host) {
|
||||
connection = this.wsServer.hostConnections.get(dst.id);
|
||||
if (!connection) {
|
||||
throw new lib.RequestError("Host is not connected");
|
||||
throw new lib.UnavailableError("Host is not connected");
|
||||
}
|
||||
|
||||
} else {
|
||||
@@ -1541,11 +1687,11 @@ export default class Controller {
|
||||
let instance = this.instances.getForRequest(request.instanceId);
|
||||
let hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId === null) {
|
||||
throw new lib.RequestError("Instance is not assigned to a host");
|
||||
throw new lib.ConflictError("Instance is not assigned to a host");
|
||||
}
|
||||
let connection = this.wsServer.hostConnections.get(hostId);
|
||||
if (!connection) {
|
||||
throw new lib.RequestError("Host containing instance is not connected");
|
||||
throw new lib.UnavailableError("Host containing instance is not connected");
|
||||
}
|
||||
return await connection.send(request);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export default class ControllerRouter {
|
||||
return false;
|
||||
}
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError(msg || "Unroutable destination"), message.src
|
||||
new lib.ResponseError(msg || "Unroutable destination", "UnavailableError"), message.src
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -65,12 +65,12 @@ export default class InstanceManager {
|
||||
*
|
||||
* @param instanceId - ID of instance to get.
|
||||
* @returns The instance record.
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
* @throws {module:lib.NotFoundError} if the instance does not exist.
|
||||
*/
|
||||
getForRequest(instanceId: number): InstanceRecord {
|
||||
const instance = this.records.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
throw new lib.NotFoundError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export default class InstanceManager {
|
||||
* @param instanceConfig - Config to base the newly created instance on.
|
||||
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
|
||||
* @returns The created instance record.
|
||||
* @throws {module:lib.RequestError} if an instance with the same ID already exists.
|
||||
* @throws {module:lib.ConflictError} if an instance with the same ID already exists.
|
||||
*/
|
||||
async createInstance(
|
||||
instanceConfig: lib.InstanceConfig,
|
||||
@@ -133,7 +133,7 @@ export default class InstanceManager {
|
||||
): Promise<InstanceRecord> {
|
||||
const instanceId = instanceConfig.get("instance.id");
|
||||
if (this.records.has(instanceId)) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
|
||||
throw new lib.ConflictError(`Instance with ID ${instanceId} already exists`);
|
||||
}
|
||||
|
||||
const controllerName = this._controller.config.get("controller.name");
|
||||
@@ -172,8 +172,8 @@ export default class InstanceManager {
|
||||
* @param hostId - ID of host to assign the instance to. If undefined,
|
||||
* the instance will be unassigned.
|
||||
*
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
* @throws {module:lib.RequestError} if the target host is not connected.
|
||||
* @throws {module:lib.NotFoundError} if the instance does not exist.
|
||||
* @throws {module:lib.UnavailableError} if the target host is not connected.
|
||||
*/
|
||||
async assignInstance(
|
||||
instanceId: number,
|
||||
@@ -191,7 +191,7 @@ export default class InstanceManager {
|
||||
if (hostId !== null) {
|
||||
newHostConnection = hostConnections.get(hostId);
|
||||
if (!newHostConnection) {
|
||||
throw new lib.RequestError("Target host is not connected to the controller");
|
||||
throw new lib.UnavailableError("Target host is not connected to the controller");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ export default class InstanceManager {
|
||||
*
|
||||
* @param instanceId - ID of instance to delete.
|
||||
*
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
* @throws {module:lib.NotFoundError} if the instance does not exist.
|
||||
*/
|
||||
async deleteInstance(instanceId: number): Promise<void> {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
import dgram from "node:dgram";
|
||||
import { isIPv6 } from "node:net";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import type Controller from "./Controller.js";
|
||||
import type InstanceRecord from "./InstanceRecord.js";
|
||||
import * as protocol from "./proxy_protocol.js";
|
||||
import { MessageType } from "./proxy_protocol.js";
|
||||
|
||||
const SESSION_IDLE_TIMEOUT_MS = 30_000;
|
||||
const INSTANCE_START_TIMEOUT_MS = 120_000;
|
||||
const UPSTREAM_RETRY_INTERVAL_MS = 500;
|
||||
const UPSTREAM_RETRY_COUNT = 20;
|
||||
const PROBE_TIMEOUT_MS = 2_000;
|
||||
const MAX_PENDING_PACKETS = 256;
|
||||
|
||||
/** Details about a player connecting through the proxy that is used to decide where to send them */
|
||||
export interface PlayerProxyRouteRequest {
|
||||
/** Name the player connects with */
|
||||
username: string;
|
||||
/** Factorio version and build of the connecting client */
|
||||
version: protocol.ApplicationVersion;
|
||||
/** Address and port the client connects from */
|
||||
clientAddress: string;
|
||||
clientPort: number;
|
||||
/** Proxy port the client connected to */
|
||||
listenPort: number;
|
||||
/** Instance the proxy port is dedicated to, if any */
|
||||
portInstanceId?: number;
|
||||
/** Mods enabled in the client, including built in mods like base, or undefined if they couldn't be read */
|
||||
mods?: protocol.ModId[];
|
||||
}
|
||||
|
||||
export interface PlayerProxyRoute {
|
||||
instanceId: number;
|
||||
address: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
type Listener = {
|
||||
port: number;
|
||||
socket: dgram.Socket;
|
||||
instanceId?: number;
|
||||
};
|
||||
|
||||
type ClientSession = {
|
||||
key: string;
|
||||
listener: Listener;
|
||||
clientAddress: string;
|
||||
clientPort: number;
|
||||
lastSeenMs: number;
|
||||
/** Raw ConnectionRequest received from the client */
|
||||
request?: protocol.ConnectionRequest & { buffer: Buffer };
|
||||
/** Request id the proxy answered the client with */
|
||||
proxyServerRequestId: number;
|
||||
username?: string;
|
||||
/** Fragments of the client's ConnectionRequestReplyConfirm by fragment number */
|
||||
confirmFragments: Map<number, Buffer>;
|
||||
confirmMessageId?: number;
|
||||
confirmLastFragment?: number;
|
||||
confirmServerRequestIdOffset?: number;
|
||||
state: "handshake" | "routing" | "connecting" | "connected" | "failed";
|
||||
route?: PlayerProxyRoute;
|
||||
upstream?: dgram.Socket;
|
||||
upstreamRetry?: ReturnType<typeof setInterval>;
|
||||
serverRequestId?: number;
|
||||
/** Packets received from the client before the upstream connection was ready */
|
||||
pending: Buffer[];
|
||||
};
|
||||
|
||||
function randomUInt32() {
|
||||
return Math.floor(Math.random() * 2 ** 32) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the IPv4-mapped prefix from addresses so they can be used with udp4 sockets
|
||||
* @param address - Address to normalise.
|
||||
* @returns The address with any ::ffff: prefix removed.
|
||||
*/
|
||||
function normaliseAddress(address: string) {
|
||||
return address.startsWith("::ffff:") ? address.slice(7) : address;
|
||||
}
|
||||
|
||||
/**
|
||||
* UDP proxy for Factorio players
|
||||
*
|
||||
* Accepts connections from Factorio clients, completes the first step of the
|
||||
* connection handshake itself to learn the name and version of the player,
|
||||
* and then forwards the connection to the instance picked by the controller.
|
||||
* Stopped instances are started on demand.
|
||||
*/
|
||||
export default class PlayerProxyServer {
|
||||
private _listeners = new Map<number, Listener>();
|
||||
private _sessions = new Map<string, ClientSession>();
|
||||
private _cleanupTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private _closed = false;
|
||||
private _onInstancesUpdated = () => {
|
||||
this.syncListeners().catch(err => {
|
||||
this._logger.error(`Unexpected error syncing proxy ports:\n${err.stack}`);
|
||||
});
|
||||
};
|
||||
|
||||
/** Last version and build seen from each instance's Factorio server */
|
||||
readonly serverVersions = new Map<number, protocol.ApplicationVersion>();
|
||||
|
||||
constructor(
|
||||
private _controller: Controller,
|
||||
private _logger: lib.Logger,
|
||||
private _port: number,
|
||||
) {}
|
||||
|
||||
get port() {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
/** Ports currently listened on, mapped to the instance they are dedicated to */
|
||||
get listeners(): ReadonlyMap<number, number | undefined> {
|
||||
return new Map([...this._listeners.values()].map(listener => [listener.port, listener.instanceId]));
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this._closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this._listen(this._port);
|
||||
} catch (err: any) {
|
||||
// Not fatal so the rest of the controller still works, for example if a host on the
|
||||
// same machine runs an instance on the port.
|
||||
this._logger.error(
|
||||
`Failed to listen on player proxy port ${this._port}, players can't connect through the ` +
|
||||
`proxy: ${err.message}`
|
||||
);
|
||||
}
|
||||
this._controller.instances.records.on("update", this._onInstancesUpdated);
|
||||
await this.syncListeners();
|
||||
|
||||
this._cleanupTimer = setInterval(() => this._cleanupSessions(), SESSION_IDLE_TIMEOUT_MS);
|
||||
this._cleanupTimer.unref();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this._closed) {
|
||||
return;
|
||||
}
|
||||
this._closed = true;
|
||||
this._controller.instances.records.off("update", this._onInstancesUpdated);
|
||||
|
||||
clearInterval(this._cleanupTimer);
|
||||
for (const session of this._sessions.values()) {
|
||||
this._closeSession(session);
|
||||
}
|
||||
this._sessions.clear();
|
||||
|
||||
await Promise.all([...this._listeners.values()].map(listener => this._closeListener(listener)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open and close dedicated instance ports to match instance.player_proxy_port
|
||||
*/
|
||||
async syncListeners() {
|
||||
await this._syncListenersSerial.invoke();
|
||||
}
|
||||
|
||||
private _syncListenersSerial = new lib.AsyncSerialMergingCallback(async () => {
|
||||
if (this._closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wanted = new Map<number, number>();
|
||||
for (const instance of this._controller.instances.values()) {
|
||||
const port = instance.config.get("instance.player_proxy_port");
|
||||
if (port === null || instance.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
if (port === this._port) {
|
||||
this._logger.warn(`Instance ${instance.id} uses the main player proxy port ${port}, ignoring it`);
|
||||
continue;
|
||||
}
|
||||
if (wanted.has(port)) {
|
||||
this._logger.warn(
|
||||
`Instances ${wanted.get(port)} and ${instance.id} both use player proxy port ${port}, ` +
|
||||
`only routing it to ${wanted.get(port)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
wanted.set(port, instance.id);
|
||||
}
|
||||
|
||||
for (const listener of [...this._listeners.values()]) {
|
||||
if (listener.port === this._port) {
|
||||
continue;
|
||||
}
|
||||
const instanceId = wanted.get(listener.port);
|
||||
if (instanceId === undefined) {
|
||||
await this._closeListener(listener);
|
||||
} else {
|
||||
listener.instanceId = instanceId;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [port, instanceId] of wanted) {
|
||||
if (!this._listeners.has(port)) {
|
||||
try {
|
||||
await this._listen(port, instanceId);
|
||||
} catch (err: any) {
|
||||
this._logger.error(`Failed to listen on player proxy port ${port}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
private async _listen(port: number, instanceId?: number) {
|
||||
const socket = dgram.createSocket("udp4");
|
||||
const listener: Listener = { port, socket, instanceId };
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("error", reject);
|
||||
socket.bind(port, () => {
|
||||
socket.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
socket.on("error", err => {
|
||||
this._logger.error(`Player proxy socket on port ${port} errored: ${err.message}`);
|
||||
});
|
||||
socket.on("message", (message, rinfo) => {
|
||||
try {
|
||||
this._handleClientMessage(listener, message, rinfo);
|
||||
} catch (err: any) {
|
||||
this._logger.error(`Unexpected error handling player proxy packet:\n${err.stack}`);
|
||||
}
|
||||
});
|
||||
this._listeners.set(port, listener);
|
||||
this._logger.info(
|
||||
instanceId === undefined
|
||||
? `Player proxy listening on UDP ${port}`
|
||||
: `Player proxy listening on UDP ${port} for instance ${instanceId}`
|
||||
);
|
||||
}
|
||||
|
||||
private async _closeListener(listener: Listener) {
|
||||
this._listeners.delete(listener.port);
|
||||
for (const session of [...this._sessions.values()]) {
|
||||
if (session.listener === listener) {
|
||||
this._closeSession(session);
|
||||
}
|
||||
}
|
||||
await new Promise<void>(resolve => listener.socket.close(() => resolve()));
|
||||
}
|
||||
|
||||
private _handleClientMessage(listener: Listener, message: Buffer, rinfo: dgram.RemoteInfo) {
|
||||
if (this._closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = protocol.messageType(message);
|
||||
const key = `${listener.port}|${rinfo.address}|${rinfo.port}`;
|
||||
let session = this._sessions.get(key);
|
||||
|
||||
if (type === MessageType.ConnectionRequest) {
|
||||
let request: protocol.ConnectionRequest;
|
||||
try {
|
||||
request = protocol.parseConnectionRequest(message);
|
||||
} catch (err: any) {
|
||||
this._logger.verbose(`Ignoring malformed connection request: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// A different request id means the client started a new connection from the same socket,
|
||||
// which is what Factorio does after LuaPlayer::connect_to_server.
|
||||
if (session && session.request && session.request.clientRequestId !== request.clientRequestId) {
|
||||
this._closeSession(session);
|
||||
session = undefined;
|
||||
}
|
||||
if (!session) {
|
||||
session = this._createSession(key, listener, rinfo);
|
||||
}
|
||||
session.lastSeenMs = Date.now();
|
||||
this._handleConnectionRequest(session, request, message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
if (type === MessageType.Ping) {
|
||||
// Pings from the server browser are answered for the proxy itself
|
||||
this._replyToPing(listener, message, rinfo);
|
||||
}
|
||||
return;
|
||||
}
|
||||
session.lastSeenMs = Date.now();
|
||||
|
||||
if (type === MessageType.ConnectionRequestReplyConfirm) {
|
||||
this._handleConfirm(session, message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.state === "connected") {
|
||||
this._sendUpstream(session, message);
|
||||
} else if (type === MessageType.Ping) {
|
||||
this._replyToPing(listener, message, rinfo);
|
||||
} else {
|
||||
this._queuePending(session, message);
|
||||
}
|
||||
}
|
||||
|
||||
private _replyToPing(listener: Listener, message: Buffer, rinfo: dgram.RemoteInfo) {
|
||||
const reply = protocol.pingReplyFor(message);
|
||||
if (reply) {
|
||||
listener.socket.send(reply, rinfo.port, rinfo.address);
|
||||
}
|
||||
}
|
||||
|
||||
private _createSession(key: string, listener: Listener, rinfo: dgram.RemoteInfo): ClientSession {
|
||||
const session: ClientSession = {
|
||||
key,
|
||||
listener,
|
||||
clientAddress: rinfo.address,
|
||||
clientPort: rinfo.port,
|
||||
lastSeenMs: Date.now(),
|
||||
proxyServerRequestId: randomUInt32(),
|
||||
confirmFragments: new Map(),
|
||||
state: "handshake",
|
||||
pending: [],
|
||||
};
|
||||
this._sessions.set(key, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
private _handleConnectionRequest(session: ClientSession, request: protocol.ConnectionRequest, buffer: Buffer) {
|
||||
session.request = { ...request, buffer };
|
||||
if (session.state !== "handshake" && session.state !== "routing") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reply with the client's own version so it proceeds to send its username. If the instance
|
||||
// it ends up on runs a different version the connection will fail at that point instead.
|
||||
this._sendToClient(session, protocol.encodeConnectionRequestReply({
|
||||
version: request.version,
|
||||
clientRequestId: request.clientRequestId,
|
||||
serverRequestId: session.proxyServerRequestId,
|
||||
maxPacketSize: protocol.DEFAULT_MAX_PACKET_SIZE,
|
||||
}));
|
||||
}
|
||||
|
||||
private _handleConfirm(session: ClientSession, message: Buffer) {
|
||||
let header: protocol.MessageHeader;
|
||||
try {
|
||||
header = protocol.parseHeader(message);
|
||||
} catch (err: any) {
|
||||
this._logger.verbose(`Ignoring malformed connection confirm: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fragmentNumber = header.fragmented ? header.fragmentNumber! : 0;
|
||||
if (fragmentNumber === 0) {
|
||||
let confirm: protocol.ConnectionRequestReplyConfirm;
|
||||
try {
|
||||
confirm = protocol.parseConnectionRequestReplyConfirm(message);
|
||||
} catch (err: any) {
|
||||
this._logger.verbose(`Ignoring malformed connection confirm: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
confirm.serverRequestId !== session.proxyServerRequestId
|
||||
&& confirm.serverRequestId !== session.serverRequestId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (session.confirmMessageId !== undefined && session.confirmMessageId !== header.messageId) {
|
||||
// The client is sending a different confirm message, start over with it
|
||||
session.confirmFragments.clear();
|
||||
session.confirmLastFragment = undefined;
|
||||
}
|
||||
session.confirmMessageId = header.messageId;
|
||||
session.confirmServerRequestIdOffset = confirm.serverRequestIdOffset;
|
||||
session.username = confirm.username;
|
||||
} else if (session.confirmMessageId !== undefined && header.messageId !== session.confirmMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.serverRequestId !== undefined) {
|
||||
// Resends of the confirm are passed on directly once the instance has answered
|
||||
this._sendUpstream(session, this._patchConfirm(session, message, fragmentNumber));
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.confirmFragments.size < MAX_PENDING_PACKETS) {
|
||||
session.confirmFragments.set(fragmentNumber, message);
|
||||
}
|
||||
if (!header.fragmented || header.lastFragment) {
|
||||
session.confirmLastFragment = fragmentNumber;
|
||||
}
|
||||
|
||||
// Routing waits for the whole message as plugins may use the mods of the client listed in it
|
||||
if (
|
||||
session.state === "handshake"
|
||||
&& session.username !== undefined
|
||||
&& session.request
|
||||
&& this._confirmComplete(session)
|
||||
) {
|
||||
session.state = "routing";
|
||||
this._routeSession(session).catch(err => {
|
||||
this._logger.error(`Unexpected error routing player ${session.username}:\n${err.stack}`);
|
||||
session.state = "failed";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _confirmComplete(session: ClientSession) {
|
||||
if (session.confirmLastFragment === undefined) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i <= session.confirmLastFragment; i++) {
|
||||
if (!session.confirmFragments.has(i)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _confirmMods(session: ClientSession) {
|
||||
const fragments = [...session.confirmFragments].sort(([a], [b]) => a - b).map(([, fragment]) => fragment);
|
||||
try {
|
||||
return protocol.parseConfirmMods(protocol.joinPayload(fragments));
|
||||
} catch (err: any) {
|
||||
this._logger.verbose(`Unable to read mods of ${session.username}: ${err.message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _patchConfirm(session: ClientSession, message: Buffer, fragmentNumber: number) {
|
||||
if (fragmentNumber !== 0 || session.confirmServerRequestIdOffset === undefined) {
|
||||
return message;
|
||||
}
|
||||
const patched = Buffer.from(message);
|
||||
patched.writeUInt32LE(session.serverRequestId!, session.confirmServerRequestIdOffset);
|
||||
return patched;
|
||||
}
|
||||
|
||||
private async _routeSession(session: ClientSession) {
|
||||
const request: PlayerProxyRouteRequest = {
|
||||
username: session.username!,
|
||||
version: session.request!.version,
|
||||
clientAddress: session.clientAddress,
|
||||
clientPort: session.clientPort,
|
||||
listenPort: session.listener.port,
|
||||
portInstanceId: session.listener.instanceId,
|
||||
mods: this._confirmMods(session),
|
||||
};
|
||||
|
||||
const instanceId = await this._controller.resolvePlayerRoute(request);
|
||||
if (this._sessions.get(session.key) !== session) {
|
||||
return; // Session was replaced while routing
|
||||
}
|
||||
if (instanceId === undefined) {
|
||||
this._logger.warn(`No instance to route player ${request.username} to`);
|
||||
session.state = "failed";
|
||||
return;
|
||||
}
|
||||
|
||||
const route = await this._resolveInstanceAddress(instanceId);
|
||||
if (this._sessions.get(session.key) !== session) {
|
||||
return;
|
||||
}
|
||||
if (!route) {
|
||||
this._logger.warn(`Unable to route player ${request.username} to instance ${instanceId}`);
|
||||
session.state = "failed";
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.verbose(
|
||||
`Routing player ${request.username} (Factorio ${protocol.formatVersion(request.version)}) ` +
|
||||
`to instance ${instanceId} at ${route.address}:${route.port}`
|
||||
);
|
||||
session.route = route;
|
||||
session.state = "connecting";
|
||||
const upstream = dgram.createSocket(isIPv6(route.address) ? "udp6" : "udp4");
|
||||
session.upstream = upstream;
|
||||
upstream.on("message", data => {
|
||||
try {
|
||||
this._handleUpstreamMessage(session, data);
|
||||
} catch (err: any) {
|
||||
this._logger.error(`Unexpected error handling upstream packet:\n${err.stack}`);
|
||||
}
|
||||
});
|
||||
upstream.on("error", err => {
|
||||
this._logger.warn(`Upstream socket for ${request.username} errored: ${err.message}`);
|
||||
this._closeSession(session);
|
||||
});
|
||||
|
||||
let attempts = 0;
|
||||
const sendRequest = () => {
|
||||
attempts += 1;
|
||||
if (attempts > UPSTREAM_RETRY_COUNT) {
|
||||
this._logger.warn(`Instance ${instanceId} did not answer connection of ${request.username}`);
|
||||
this._closeSession(session);
|
||||
return;
|
||||
}
|
||||
this._sendUpstream(session, session.request!.buffer);
|
||||
};
|
||||
sendRequest();
|
||||
session.upstreamRetry = setInterval(sendRequest, UPSTREAM_RETRY_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the address of an instance, starting it if it is stopped.
|
||||
*
|
||||
* @param instanceId - ID of the instance to look up.
|
||||
* @returns address and port of the instance, or undefined if it's not reachable.
|
||||
*/
|
||||
private async _resolveInstanceAddress(instanceId: number): Promise<PlayerProxyRoute | undefined> {
|
||||
let instance = await this._ensureInstanceRunning(instanceId);
|
||||
if (!instance) {
|
||||
return undefined;
|
||||
}
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
const host = hostId !== null ? this._controller.hosts.get(hostId) : undefined;
|
||||
if (!host || !host.connected || !instance.gamePort) {
|
||||
return undefined;
|
||||
}
|
||||
// The address the host connected to the controller from is preferred as it is how the controller
|
||||
// reaches the host, the public address may not be reachable from inside the network.
|
||||
const address = normaliseAddress(host.remoteAddress || host.publicAddress);
|
||||
if (!address) {
|
||||
return undefined;
|
||||
}
|
||||
return { instanceId, address, port: instance.gamePort };
|
||||
}
|
||||
|
||||
private async _ensureInstanceRunning(instanceId: number): Promise<Readonly<InstanceRecord> | undefined> {
|
||||
const instance = this._controller.instances.get(instanceId);
|
||||
if (!instance || instance.isDeleted || instance.config.get("instance.assigned_host") === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (instance.status === "running") {
|
||||
return instance;
|
||||
}
|
||||
|
||||
if (instance.status === "stopped") {
|
||||
this._logger.info(`Starting instance ${instanceId} for a player connecting through the proxy`);
|
||||
try {
|
||||
await this._controller.sendTo({ instanceId }, new lib.InstanceStartRequest());
|
||||
} catch (err: any) {
|
||||
this._logger.warn(`Failed to start instance ${instanceId}: ${err.message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + INSTANCE_START_TIMEOUT_MS;
|
||||
while (Date.now() < deadline && !this._closed) {
|
||||
const current = this._controller.instances.get(instanceId);
|
||||
if (!current || current.isDeleted) {
|
||||
return undefined;
|
||||
}
|
||||
if (current.status === "running") {
|
||||
return current;
|
||||
}
|
||||
if (current.status === "stopped") {
|
||||
return undefined;
|
||||
}
|
||||
await lib.wait(500);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _handleUpstreamMessage(session: ClientSession, data: Buffer) {
|
||||
if (this._sessions.get(session.key) !== session) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (protocol.messageType(data) === MessageType.ConnectionRequestReply) {
|
||||
if (session.serverRequestId !== undefined) {
|
||||
return;
|
||||
}
|
||||
let reply: protocol.ConnectionRequestReply;
|
||||
try {
|
||||
reply = protocol.parseConnectionRequestReply(data);
|
||||
} catch (err: any) {
|
||||
this._logger.warn(
|
||||
`Ignoring malformed reply from instance ${session.route!.instanceId}: ${err.message}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (reply.clientRequestId !== session.request!.clientRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(session.upstreamRetry);
|
||||
session.upstreamRetry = undefined;
|
||||
session.serverRequestId = reply.serverRequestId;
|
||||
this.serverVersions.set(session.route!.instanceId, reply.version);
|
||||
if (!protocol.versionsEqual(reply.version, session.request!.version)) {
|
||||
this._logger.warn(
|
||||
`Player ${session.username} runs Factorio ${protocol.formatVersion(session.request!.version)} ` +
|
||||
`(build ${session.request!.version.build}) but instance ${session.route!.instanceId} runs ` +
|
||||
`${protocol.formatVersion(reply.version)} (build ${reply.version.build})`
|
||||
);
|
||||
}
|
||||
|
||||
for (const [fragmentNumber, fragment] of [...session.confirmFragments].sort(([a], [b]) => a - b)) {
|
||||
this._sendUpstream(session, this._patchConfirm(session, fragment, fragmentNumber));
|
||||
}
|
||||
session.confirmFragments.clear();
|
||||
session.state = "connected";
|
||||
for (const pending of session.pending) {
|
||||
this._sendUpstream(session, pending);
|
||||
}
|
||||
session.pending = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this._sendToClient(session, data);
|
||||
}
|
||||
|
||||
private _sendToClient(session: ClientSession, data: Buffer) {
|
||||
session.listener.socket.send(data, session.clientPort, session.clientAddress);
|
||||
}
|
||||
|
||||
private _sendUpstream(session: ClientSession, data: Buffer) {
|
||||
if (session.upstream && session.route) {
|
||||
session.upstream.send(data, session.route.port, session.route.address);
|
||||
}
|
||||
}
|
||||
|
||||
private _queuePending(session: ClientSession, message: Buffer) {
|
||||
if (session.pending.length >= MAX_PENDING_PACKETS) {
|
||||
session.pending.shift();
|
||||
}
|
||||
session.pending.push(message);
|
||||
}
|
||||
|
||||
private _closeSession(session: ClientSession) {
|
||||
clearInterval(session.upstreamRetry);
|
||||
session.upstreamRetry = undefined;
|
||||
if (session.upstream) {
|
||||
session.upstream.close();
|
||||
session.upstream = undefined;
|
||||
}
|
||||
if (this._sessions.get(session.key) === session) {
|
||||
this._sessions.delete(session.key);
|
||||
}
|
||||
}
|
||||
|
||||
private _cleanupSessions() {
|
||||
const now = Date.now();
|
||||
for (const session of [...this._sessions.values()]) {
|
||||
if (now - session.lastSeenMs > SESSION_IDLE_TIMEOUT_MS) {
|
||||
this._closeSession(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a Factorio server which version and build it runs
|
||||
*
|
||||
* Sends a connection request and reads the version from the reply, the
|
||||
* server forgets the request after its connection timeout.
|
||||
*
|
||||
* @param address - Address of the Factorio server.
|
||||
* @param port - Game port of the Factorio server.
|
||||
* @returns Version of the server, or undefined if it did not answer.
|
||||
*/
|
||||
static async probeVersion(address: string, port: number): Promise<protocol.ApplicationVersion | undefined> {
|
||||
const socket = dgram.createSocket(isIPv6(address) ? "udp6" : "udp4");
|
||||
const clientRequestId = randomUInt32();
|
||||
try {
|
||||
return await new Promise<protocol.ApplicationVersion | undefined>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => resolve(undefined), PROBE_TIMEOUT_MS);
|
||||
socket.on("error", err => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
socket.on("message", data => {
|
||||
try {
|
||||
const reply = protocol.parseConnectionRequestReply(data);
|
||||
if (reply.clientRequestId === clientRequestId) {
|
||||
clearTimeout(timeout);
|
||||
resolve(reply.version);
|
||||
}
|
||||
} catch { /* not the reply we're waiting for */ }
|
||||
});
|
||||
// The server replies with its own version regardless of what the client sends.
|
||||
const version = { major: 0, minor: 0, sub: 0, build: 0 };
|
||||
socket.send(protocol.encodeConnectionRequest(version, clientRequestId), port, address);
|
||||
});
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the version and build of an instance
|
||||
*
|
||||
* @param instanceId - ID of the instance.
|
||||
* @returns version of the running instance, or the last version seen if it is stopped.
|
||||
*/
|
||||
async instanceVersion(instanceId: number) {
|
||||
const instance = this._controller.instances.get(instanceId);
|
||||
if (instance?.status === "running") {
|
||||
const route = await this._resolveInstanceAddress(instanceId);
|
||||
if (route) {
|
||||
const version = await PlayerProxyServer.probeVersion(route.address, route.port).catch(() => undefined);
|
||||
if (version) {
|
||||
this.serverVersions.set(instanceId, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.serverVersions.get(instanceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Encoding and decoding of the parts of the Factorio network protocol the
|
||||
* player proxy needs to look at: the message header and the connection
|
||||
* handshake messages.
|
||||
*
|
||||
* Based on the Factorio 2.x wire format. Every multi-byte value is little
|
||||
* endian, "space optimized" values are a single byte unless that byte is 0xff,
|
||||
* in which case the full width value follows it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
export const MessageType = {
|
||||
Ping: 0,
|
||||
PingReply: 1,
|
||||
ConnectionRequest: 2,
|
||||
ConnectionRequestReply: 3,
|
||||
ConnectionRequestReplyConfirm: 4,
|
||||
ConnectionAcceptOrDeny: 5,
|
||||
} as const;
|
||||
|
||||
const TYPE_MASK = 0x1f;
|
||||
const FRAGMENTED_FLAG = 0x40;
|
||||
const LAST_FRAGMENT_FLAG = 0x80;
|
||||
const CONFIRM_FLAG = 0x8000;
|
||||
const MESSAGE_ID_MASK = 0x7fff;
|
||||
|
||||
/** Message types that carry a message id even when not fragmented */
|
||||
const RELIABLE_TYPES: ReadonlySet<number> = new Set([
|
||||
MessageType.ConnectionRequest,
|
||||
MessageType.ConnectionRequestReplyConfirm,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Max packet size Factorio servers tell clients to use, this is the largest
|
||||
* datagram every host on the Internet is required to be able to receive.
|
||||
*/
|
||||
export const DEFAULT_MAX_PACKET_SIZE = 508;
|
||||
|
||||
export interface ApplicationVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
sub: number;
|
||||
build: number;
|
||||
}
|
||||
|
||||
export function formatVersion(version: ApplicationVersion) {
|
||||
return `${version.major}.${version.minor}.${version.sub}`;
|
||||
}
|
||||
|
||||
export function versionsEqual(a: ApplicationVersion, b: ApplicationVersion) {
|
||||
return a.major === b.major && a.minor === b.minor && a.sub === b.sub && a.build === b.build;
|
||||
}
|
||||
|
||||
export interface MessageHeader {
|
||||
type: number;
|
||||
fragmented: boolean;
|
||||
lastFragment: boolean;
|
||||
messageId?: number;
|
||||
fragmentNumber?: number;
|
||||
confirms: number[];
|
||||
/** Offset of the payload of the message */
|
||||
payloadOffset: number;
|
||||
}
|
||||
|
||||
export interface ConnectionRequest {
|
||||
header: MessageHeader;
|
||||
version: ApplicationVersion;
|
||||
clientRequestId: number;
|
||||
}
|
||||
|
||||
export interface ConnectionRequestReply {
|
||||
version: ApplicationVersion;
|
||||
clientRequestId: number;
|
||||
serverRequestId: number;
|
||||
maxPacketSize: number;
|
||||
}
|
||||
|
||||
export interface ConnectionRequestReplyConfirm {
|
||||
clientRequestId: number;
|
||||
serverRequestId: number;
|
||||
/** Offset of serverRequestId in the packet */
|
||||
serverRequestIdOffset: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export class BufferReader {
|
||||
constructor(
|
||||
private _buffer: Buffer,
|
||||
public offset = 0,
|
||||
) { }
|
||||
|
||||
private _take(length: number) {
|
||||
const start = this.offset;
|
||||
if (start + length > this._buffer.length) {
|
||||
throw new Error("Unexpected end of message");
|
||||
}
|
||||
this.offset += length;
|
||||
return start;
|
||||
}
|
||||
|
||||
readUInt8() {
|
||||
return this._buffer.readUInt8(this._take(1));
|
||||
}
|
||||
|
||||
readUInt16LE() {
|
||||
return this._buffer.readUInt16LE(this._take(2));
|
||||
}
|
||||
|
||||
readUInt32LE() {
|
||||
return this._buffer.readUInt32LE(this._take(4));
|
||||
}
|
||||
|
||||
/** Read a value stored as one byte or 0xff followed by a uint16 */
|
||||
readSpaceOptimizedUInt16() {
|
||||
const value = this.readUInt8();
|
||||
return value === 0xff ? this.readUInt16LE() : value;
|
||||
}
|
||||
|
||||
/** Read a value stored as one byte or 0xff followed by a uint32 */
|
||||
readSpaceOptimizedUInt32() {
|
||||
const value = this.readUInt8();
|
||||
return value === 0xff ? this.readUInt32LE() : value;
|
||||
}
|
||||
|
||||
readString() {
|
||||
const length = this.readSpaceOptimizedUInt32();
|
||||
const start = this._take(length);
|
||||
return this._buffer.toString("utf8", start, start + length);
|
||||
}
|
||||
|
||||
readVersion(): ApplicationVersion {
|
||||
return {
|
||||
major: this.readSpaceOptimizedUInt16(),
|
||||
minor: this.readSpaceOptimizedUInt16(),
|
||||
sub: this.readSpaceOptimizedUInt16(),
|
||||
build: this.readUInt32LE(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BufferWriter {
|
||||
private _parts: number[] = [];
|
||||
|
||||
writeUInt8(value: number) {
|
||||
this._parts.push(value & 0xff);
|
||||
}
|
||||
|
||||
writeUInt16LE(value: number) {
|
||||
this.writeUInt8(value);
|
||||
this.writeUInt8(value >>> 8);
|
||||
}
|
||||
|
||||
writeUInt32LE(value: number) {
|
||||
this.writeUInt16LE(value);
|
||||
this.writeUInt16LE(value >>> 16);
|
||||
}
|
||||
|
||||
writeSpaceOptimizedUInt16(value: number) {
|
||||
if (value >= 0xff) {
|
||||
this.writeUInt8(0xff);
|
||||
this.writeUInt16LE(value);
|
||||
} else {
|
||||
this.writeUInt8(value);
|
||||
}
|
||||
}
|
||||
|
||||
writeVersion(version: ApplicationVersion) {
|
||||
this.writeSpaceOptimizedUInt16(version.major);
|
||||
this.writeSpaceOptimizedUInt16(version.minor);
|
||||
this.writeSpaceOptimizedUInt16(version.sub);
|
||||
this.writeUInt32LE(version.build);
|
||||
}
|
||||
|
||||
toBuffer() {
|
||||
return Buffer.from(this._parts);
|
||||
}
|
||||
}
|
||||
|
||||
export function messageType(buffer: Buffer) {
|
||||
if (!buffer.length) {
|
||||
return undefined;
|
||||
}
|
||||
return buffer[0] & TYPE_MASK;
|
||||
}
|
||||
|
||||
export function parseHeader(buffer: Buffer): MessageHeader {
|
||||
const reader = new BufferReader(buffer);
|
||||
const typeAndFlags = reader.readUInt8();
|
||||
const type = typeAndFlags & TYPE_MASK;
|
||||
const fragmented = (typeAndFlags & FRAGMENTED_FLAG) !== 0;
|
||||
const lastFragment = (typeAndFlags & LAST_FRAGMENT_FLAG) !== 0;
|
||||
|
||||
let messageId: number | undefined;
|
||||
let hasConfirm = false;
|
||||
if (RELIABLE_TYPES.has(type) || fragmented) {
|
||||
const messageIdAndFlags = reader.readUInt16LE();
|
||||
messageId = messageIdAndFlags & MESSAGE_ID_MASK;
|
||||
hasConfirm = (messageIdAndFlags & CONFIRM_FLAG) !== 0;
|
||||
}
|
||||
|
||||
let fragmentNumber: number | undefined;
|
||||
if (fragmented) {
|
||||
fragmentNumber = reader.readSpaceOptimizedUInt16();
|
||||
}
|
||||
|
||||
const confirms: number[] = [];
|
||||
if (hasConfirm) {
|
||||
const count = reader.readUInt8();
|
||||
for (let i = 0; i < count; i++) {
|
||||
confirms.push(reader.readUInt32LE());
|
||||
}
|
||||
}
|
||||
|
||||
return { type, fragmented, lastFragment, messageId, fragmentNumber, confirms, payloadOffset: reader.offset };
|
||||
}
|
||||
|
||||
export function parseConnectionRequest(buffer: Buffer): ConnectionRequest {
|
||||
const header = parseHeader(buffer);
|
||||
if (header.type !== MessageType.ConnectionRequest) {
|
||||
throw new Error(`Expected ConnectionRequest, got message type ${header.type}`);
|
||||
}
|
||||
const reader = new BufferReader(buffer, header.payloadOffset);
|
||||
const version = reader.readVersion();
|
||||
const clientRequestId = reader.readUInt32LE();
|
||||
return { header, version, clientRequestId };
|
||||
}
|
||||
|
||||
export function parseConnectionRequestReply(buffer: Buffer): ConnectionRequestReply {
|
||||
const header = parseHeader(buffer);
|
||||
if (header.type !== MessageType.ConnectionRequestReply) {
|
||||
throw new Error(`Expected ConnectionRequestReply, got message type ${header.type}`);
|
||||
}
|
||||
const reader = new BufferReader(buffer, header.payloadOffset);
|
||||
return {
|
||||
version: reader.readVersion(),
|
||||
clientRequestId: reader.readUInt32LE(),
|
||||
serverRequestId: reader.readUInt32LE(),
|
||||
maxPacketSize: reader.readUInt16LE(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the start of a ConnectionRequestReplyConfirm
|
||||
*
|
||||
* The message contains the mod list and startup settings of the client and
|
||||
* is usually fragmented, but the fields read here are always in the first
|
||||
* fragment.
|
||||
*
|
||||
* @param buffer - Unfragmented message or first fragment of it.
|
||||
* @returns the parsed fields.
|
||||
*/
|
||||
export function parseConnectionRequestReplyConfirm(buffer: Buffer): ConnectionRequestReplyConfirm {
|
||||
const header = parseHeader(buffer);
|
||||
if (header.type !== MessageType.ConnectionRequestReplyConfirm) {
|
||||
throw new Error(`Expected ConnectionRequestReplyConfirm, got message type ${header.type}`);
|
||||
}
|
||||
if (header.fragmented && header.fragmentNumber !== 0) {
|
||||
throw new Error(`Expected first fragment, got fragment ${header.fragmentNumber}`);
|
||||
}
|
||||
const reader = new BufferReader(buffer, header.payloadOffset);
|
||||
const clientRequestId = reader.readUInt32LE();
|
||||
const serverRequestIdOffset = reader.offset;
|
||||
const serverRequestId = reader.readUInt32LE();
|
||||
reader.readUInt32LE(); // instanceID
|
||||
const username = reader.readString();
|
||||
return { clientRequestId, serverRequestId, serverRequestIdOffset, username };
|
||||
}
|
||||
|
||||
/** Mod enabled in a Factorio client */
|
||||
export interface ModId {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the payloads of the fragments of a message
|
||||
*
|
||||
* @param fragments -
|
||||
* Fragments of the message in order of fragment number, or the message
|
||||
* if it's not fragmented.
|
||||
* @returns the payload of the message.
|
||||
*/
|
||||
export function joinPayload(fragments: Buffer[]) {
|
||||
return Buffer.concat(fragments.map(fragment => fragment.subarray(parseHeader(fragment).payloadOffset)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the mods enabled in the client from a ConnectionRequestReplyConfirm
|
||||
*
|
||||
* @param payload - Complete payload of the message, see {@link joinPayload}.
|
||||
* @returns name and version of each mod.
|
||||
*/
|
||||
export function parseConfirmMods(payload: Buffer): ModId[] {
|
||||
const reader = new BufferReader(payload);
|
||||
reader.readUInt32LE(); // connectionRequestIDGeneratedOnClient
|
||||
reader.readUInt32LE(); // connectionRequestIDGeneratedOnServer
|
||||
reader.readUInt32LE(); // instanceID
|
||||
reader.readString(); // username
|
||||
reader.readString(); // password
|
||||
reader.readString(); // serverKey
|
||||
reader.readString(); // serverKeyTimestamp
|
||||
reader.readUInt32LE(); // coreChecksum
|
||||
reader.readUInt32LE(); // prototypeListChecksum
|
||||
const count = reader.readSpaceOptimizedUInt32();
|
||||
const mods: ModId[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = reader.readString();
|
||||
const version = [
|
||||
reader.readSpaceOptimizedUInt16(), reader.readSpaceOptimizedUInt16(), reader.readSpaceOptimizedUInt16(),
|
||||
].join(".");
|
||||
reader.readUInt32LE(); // crc
|
||||
mods.push({ name, version });
|
||||
}
|
||||
return mods;
|
||||
}
|
||||
|
||||
export function encodeConnectionRequest(version: ApplicationVersion, clientRequestId: number, messageId = 0) {
|
||||
const writer = new BufferWriter();
|
||||
writer.writeUInt8(MessageType.ConnectionRequest);
|
||||
writer.writeUInt16LE(messageId & MESSAGE_ID_MASK);
|
||||
writer.writeVersion(version);
|
||||
writer.writeUInt32LE(clientRequestId);
|
||||
return writer.toBuffer();
|
||||
}
|
||||
|
||||
export function encodeConnectionRequestReply(reply: ConnectionRequestReply) {
|
||||
const writer = new BufferWriter();
|
||||
writer.writeUInt8(MessageType.ConnectionRequestReply);
|
||||
writer.writeVersion(reply.version);
|
||||
writer.writeUInt32LE(reply.clientRequestId);
|
||||
writer.writeUInt32LE(reply.serverRequestId);
|
||||
writer.writeUInt16LE(reply.maxPacketSize);
|
||||
return writer.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a Ping into the PingReply a server would send back
|
||||
*
|
||||
* @param ping - Ping message received.
|
||||
* @returns PingReply message, or undefined if ping was malformed.
|
||||
*/
|
||||
export function pingReplyFor(ping: Buffer) {
|
||||
if (ping.length < 3) {
|
||||
return undefined;
|
||||
}
|
||||
const reply = Buffer.from(ping.subarray(0, 3));
|
||||
reply[0] = MessageType.PingReply;
|
||||
return reply;
|
||||
}
|
||||
@@ -20,9 +20,10 @@ declare global {
|
||||
namespace Express {
|
||||
export interface Locals {
|
||||
controller: Controller,
|
||||
mainBundle: string,
|
||||
webBundle: { main: string, imports: Record<string, string> },
|
||||
devServer?: import("vite").ViteDevServer,
|
||||
staticRoot: string | null,
|
||||
devPlugins: Map<string, number>,
|
||||
devPlugins?: Map<string, string>,
|
||||
streams: Map<string, ProxyStream>,
|
||||
pendingMetrics?: Promise<string>,
|
||||
}
|
||||
@@ -124,7 +125,7 @@ async function getMetrics(req: Request, res: Response) {
|
||||
|
||||
|
||||
/**
|
||||
* Removes the static/ prefix webpack adds to bundle paths so they can be
|
||||
* Removes the static/ prefix from bundle paths in manifest.json so they can be
|
||||
* resolved relative to staticRoot.
|
||||
*/
|
||||
export function stripStaticPrefix(bundlePath: string) {
|
||||
@@ -140,22 +141,18 @@ function getPlugins(req: Request, res: Response) {
|
||||
// Note: Cast through unknown is needed because load_plugin is
|
||||
// defined at runtime and no other fields in the controller config
|
||||
// currently have the boolean type.
|
||||
let web: { main:any, error:string|undefined } = { main: undefined, error: undefined};
|
||||
let web: { main: any, error: string|undefined } = { main: undefined, error: undefined};
|
||||
let devPlugins = req.app.locals.devPlugins;
|
||||
if (devPlugins && devPlugins.has(name)) {
|
||||
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
|
||||
web.main = stats.toJson().assetsByChunkName[name][0];
|
||||
web.main = devPlugins.get(name);
|
||||
} else if (pluginInfo.manifest) {
|
||||
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
|
||||
web.main = pluginInfo.manifest.main;
|
||||
if (!web.main) {
|
||||
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
|
||||
web.error = "Incompatible dist/web/manifest.json, the plugin must be rebuilt with Vite";
|
||||
}
|
||||
} else if (lib.pluginNeedsWebBuild(pluginInfo)) {
|
||||
web.error = "Missing dist/web/manifest.json";
|
||||
}
|
||||
if (web.main === "remoteEntry.js") {
|
||||
web.error = "Incompatible old remoteEntry.js entrypoint.";
|
||||
}
|
||||
if (web.main) {
|
||||
web.main = stripStaticPrefix(web.main);
|
||||
}
|
||||
@@ -308,7 +305,7 @@ type ProxyStreamEvents = {
|
||||
"close": [],
|
||||
"timeout": [],
|
||||
"source": [],
|
||||
}
|
||||
};
|
||||
|
||||
export interface ProxyStream {
|
||||
id: string;
|
||||
@@ -548,7 +545,7 @@ function checkModName(name: string) {
|
||||
try {
|
||||
lib.checkFilename(name);
|
||||
} catch (err: any) {
|
||||
throw new lib.RequestError(`Mod name ${err.message}`);
|
||||
throw new lib.ValidationError(`Mod name ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { clusterioApp } from "@clusterio/web_ui/vite.mjs";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [clusterioApp({ entry: "web/index.tsx" })],
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#2a2a2a" />
|
||||
<title>__CLUSTER_NAME__</title>
|
||||
<script type="importmap">__IMPORT_MAP__</script>
|
||||
<style>
|
||||
html {
|
||||
color: #fff;
|
||||
@@ -26,7 +27,6 @@
|
||||
<div id="root">
|
||||
<div id="bootstrap-msg">Loading web interface...</div>
|
||||
</div>
|
||||
<script id="script-bundle" src="__STATIC_ROOT____MAIN_BUNDLE__" defer></script>
|
||||
<script>
|
||||
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
|
||||
staticRoot = new URL("__STATIC_ROOT__", document.location).href;
|
||||
@@ -41,9 +41,9 @@
|
||||
bootstrapMsg.textContent = `${errorMsg}\nSee web console for details.`;
|
||||
}
|
||||
}
|
||||
const bundleScript = document.getElementById("script-bundle");
|
||||
bundleScript.addEventListener("error", () => scriptError("Main bundle failed to load."));
|
||||
window.addEventListener("error", event => scriptError(event.error));
|
||||
</script>
|
||||
<script id="script-bundle" type="module" src="__STATIC_ROOT____MAIN_BUNDLE__"
|
||||
onerror="scriptError('Main bundle failed to load.')"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line no-implicit-globals
|
||||
declare function scriptError(err: any): void;
|
||||
import("@clusterio/web_ui")
|
||||
.then(webUi => webUi.bootstrap())
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
devServer: {
|
||||
contentBase: "./dist/web",
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "controller",
|
||||
shared: {
|
||||
"@clusterio/lib": { singleton: true },
|
||||
"@clusterio/web_ui": { singleton: true },
|
||||
"antd": { singleton: true },
|
||||
"react": { singleton: true },
|
||||
"react-dom": { singleton: true },
|
||||
"react-router": { singleton: true },
|
||||
"react-router-dom": { singleton: true },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -30,6 +30,7 @@ if (process.platform === "win32") {
|
||||
|
||||
const availablePlugins = [
|
||||
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
||||
{ name: "Lobby", value: "@clusterio/plugin-lobby" },
|
||||
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
|
||||
{ name: "Player Sync", value: "@clusterio/plugin-player_sync" },
|
||||
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function copyPluginTemplates(pluginName, templates) {
|
||||
const config = !templates.includes("no_config") && templates.some(value => (
|
||||
["controller", "host", "instance", "ctl"].includes(value)
|
||||
));
|
||||
const webpack = config || templates.includes("controller") || templates.includes("web");
|
||||
const webBuild = config || templates.includes("controller") || templates.includes("web");
|
||||
|
||||
// A count of the number of isolated contexts the plugin runs under
|
||||
let pluginContexts = 0;
|
||||
@@ -113,15 +113,15 @@ export async function copyPluginTemplates(pluginName, templates) {
|
||||
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
|
||||
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
|
||||
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json"));
|
||||
if (webpack) {
|
||||
if (webBuild) {
|
||||
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
|
||||
}
|
||||
}
|
||||
|
||||
// Files and dependences to support webpack
|
||||
if (webpack) {
|
||||
prepare.push("webpack-cli --mode production");
|
||||
files.set("webpack.config.cjs", path.join(commonPath, "webpack.config.cjs"));
|
||||
// Files and dependences to support the web build
|
||||
if (webBuild) {
|
||||
prepare.push("vite build");
|
||||
files.set("vite.config.js", path.join(commonPath, "vite.config.js"));
|
||||
if (templates.includes("web")) {
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
|
||||
pluginContexts += 1;
|
||||
@@ -185,7 +185,7 @@ export async function copyPluginTemplates(pluginName, templates) {
|
||||
ctl: templates.includes("ctl"),
|
||||
web: templates.includes("web"),
|
||||
// Macro flags for context requirements
|
||||
webpack: webpack,
|
||||
web_build: webBuild,
|
||||
config: config,
|
||||
// String values for package json
|
||||
clusterio_version: _package.version,
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"devDependencies": {
|
||||
//%// Typescript and type declarations
|
||||
//%if typescript
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^7.0.2",
|
||||
"@types/node": "^24.13.4",
|
||||
//%endif
|
||||
//%if typescript & web
|
||||
@@ -67,15 +67,13 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
//%endif
|
||||
//%if webpack
|
||||
"webpack": "^5.110.3",
|
||||
"webpack-cli": "^7.2.3",
|
||||
"webpack-merge": "^5.9.0",
|
||||
//%if web_build
|
||||
"vite": "^8.3.4",
|
||||
//%endif
|
||||
//%if controller
|
||||
"@clusterio/controller": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if webpack
|
||||
//%if web_build
|
||||
"@clusterio/web_ui": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if host
|
||||
@@ -87,7 +85,7 @@
|
||||
"@clusterio/lib": "^__clusterio_version__"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.6"
|
||||
"@sinclair/typebox": "^0.34.52"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
webpack.config.js
|
||||
vite.config.js
|
||||
dist/browser
|
||||
dist/web
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [clusterioPlugin()],
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.__ext__x",
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "__plugin_name__",
|
||||
library: { type: "window", name: "plugin___plugin_name__" },
|
||||
exposes: {
|
||||
"./": "./index.__ext__",
|
||||
"./package.json": "./package.json",
|
||||
"./web": "./web/index.__ext__x",
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
//%if web
|
||||
"antd": { import: false },
|
||||
"react": { import: false },
|
||||
"react-dom": { import: false },
|
||||
//%endif
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2023"],
|
||||
"types": ["node"],
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"target": "es2022",
|
||||
|
||||
+6
-6
@@ -307,12 +307,12 @@ async function startCtl() {
|
||||
logger.error(`Error running command: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else if (err instanceof lib.RequestError) {
|
||||
if (err.stack) {
|
||||
logger.error(`Error sending request:\n${err.stack}`);
|
||||
} else {
|
||||
logger.error(`Error sending request: ${err.message}`);
|
||||
}
|
||||
} else if (err instanceof lib.ClientError || err instanceof lib.UnavailableError) {
|
||||
logger.error(`Error sending request: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else if (err instanceof lib.ServerError) {
|
||||
logger.error(`Error sending request:\n${err.stack ?? err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else {
|
||||
|
||||
@@ -180,7 +180,7 @@ controllerPluginCommands.add(new lib.Command({
|
||||
}));
|
||||
controllerPluginCommands.add(new lib.Command({
|
||||
definition: ["update <plugin>", "Update a plugin on the controller", (yargs) => {
|
||||
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to update, optionally as name@version", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
@@ -192,9 +192,28 @@ controllerPluginCommands.add(new lib.Command({
|
||||
}
|
||||
},
|
||||
}));
|
||||
controllerPluginCommands.add(new lib.Command({
|
||||
definition: ["search [query]", "Search npm for plugins", (yargs) => {
|
||||
yargs.positional("query", { describe: "Text to search for", type: "string", default: "" });
|
||||
yargs.option("page", { type: "number", description: "Page of results to show", default: 1 });
|
||||
yargs.option("page-size", { type: "number", description: "Results per page", default: 20 });
|
||||
}],
|
||||
handler: async function(args: { query: string, page: number, pageSize: number }, ctl: Ctl) {
|
||||
const response = await ctl.sendTo(
|
||||
"controller", new lib.PluginSearchRequest(args.query, args.page, args.pageSize)
|
||||
);
|
||||
print(asTable(response.results.map(plugin => ({
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
publisher: plugin.publisher ?? "",
|
||||
description: plugin.description ?? "",
|
||||
}))));
|
||||
print(`Showing ${response.results.length} of ${response.total} results`);
|
||||
},
|
||||
}));
|
||||
controllerPluginCommands.add(new lib.Command({
|
||||
definition: ["install <plugin>", "Install a plugin on the controller", (yargs) => {
|
||||
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to install, optionally as name@version", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
|
||||
@@ -278,7 +278,7 @@ hostPluginCommands.add(new lib.Command({
|
||||
hostPluginCommands.add(new lib.Command({
|
||||
definition: ["update <host> <plugin>", "Update a plugin on a host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to update on", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to update, optionally as name@version", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
@@ -294,7 +294,7 @@ hostPluginCommands.add(new lib.Command({
|
||||
hostPluginCommands.add(new lib.Command({
|
||||
definition: ["install <host> <plugin>", "Install a plugin on a host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to install on", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to install, optionally as name@version", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as lib from "@clusterio/lib";
|
||||
|
||||
export async function getEditor(argsEditor: string) {
|
||||
// eslint-disable-next-line
|
||||
return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined
|
||||
return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined;
|
||||
// needed for the process.env statements to not be flagged by eslint
|
||||
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
|
||||
}
|
||||
|
||||
@@ -164,7 +164,8 @@ async function startHost() {
|
||||
}
|
||||
|
||||
logger.info(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = await lib.loadPluginList(args.pluginList);
|
||||
const skippedNpmPlugins = new Map<string, string>();
|
||||
let pluginList = await lib.loadPluginList(args.pluginList, true, skippedNpmPlugins);
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (command === "plugin") {
|
||||
@@ -173,7 +174,7 @@ async function startHost() {
|
||||
}
|
||||
|
||||
logger.info("Loading Plugin info");
|
||||
let pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
let pluginInfos = await lib.loadPluginInfos(pluginList, skippedNpmPlugins);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.registerPluginPermissions(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
@@ -233,13 +234,12 @@ async function startHost() {
|
||||
}
|
||||
|
||||
let hostConnector = new HostConnector(hostConfig, pluginInfos);
|
||||
host = new Host(
|
||||
hostConnector,
|
||||
host = await Host.fromConfig(
|
||||
hostConfig,
|
||||
pluginInfos,
|
||||
hostConnector,
|
||||
Boolean(args.canRestart),
|
||||
Boolean(args.recovery),
|
||||
...await Host.bootstrap(hostConfig)
|
||||
);
|
||||
|
||||
// Handle interrupts
|
||||
|
||||
@@ -4,6 +4,7 @@ export { BaseInstancePlugin, InstanceHooks, type InstancePluginContext } from ".
|
||||
export { default as Host, HostRouter } from "./src/Host.js";
|
||||
export { default as Instance } from "./src/Instance.js";
|
||||
export { default as InstanceConnection } from "./src/InstanceConnection.js";
|
||||
export { setSaveScenario } from "./src/patch.js";
|
||||
|
||||
if (import.meta.main) {
|
||||
bootstrap();
|
||||
|
||||
@@ -23,6 +23,9 @@ export class InstanceHooks extends lib.AsyncHookCollection {
|
||||
this.controllerConnectionEvent = this.newHook();
|
||||
this.prepareControllerDisconnect = this.newHook();
|
||||
this.playerEvent = this.newHook();
|
||||
this.autoPauseChanged = this.newHook();
|
||||
// Long timeout as handlers may create a save
|
||||
this.chooseSave = this.newHook(logger, 300_000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +57,19 @@ export class InstanceHooks extends lib.AsyncHookCollection {
|
||||
*/
|
||||
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
|
||||
|
||||
/**
|
||||
* Called to pick the save to start when none was requested
|
||||
*
|
||||
* Invoked after mods have been synced when the instance is started
|
||||
* without a save being specified. Handlers may create a save in the
|
||||
* saves directory of the instance and return its name to have it
|
||||
* started instead of the newest save. Return undefined to leave the
|
||||
* choice to other plugins and the default of the newest save.
|
||||
*
|
||||
* @returns name of save in the instance's saves directory to start.
|
||||
*/
|
||||
readonly chooseSave: lib.AsyncHook<[], string | undefined>;
|
||||
|
||||
/**
|
||||
* Called after the Factorio server is started
|
||||
*/
|
||||
@@ -147,6 +163,19 @@ export class InstanceHooks extends lib.AsyncHookCollection {
|
||||
* @param event - Information about the event.
|
||||
*/
|
||||
readonly playerEvent: lib.AsyncHook<[event: lib.PlayerEvent]>;
|
||||
|
||||
/**
|
||||
* Called when Factorio auto pauses or unpauses the game
|
||||
*
|
||||
* Invoked when the last player leaves or the first player joins an
|
||||
* instance started with the auto_pause server setting on. Every RCON
|
||||
* command makes a paused game run one tick, so periodic commands
|
||||
* should be skipped while paused. See `Instance.autoPaused`. It is
|
||||
* not invoked on start, read `autoPaused` in the start hook instead.
|
||||
*
|
||||
* @param paused - true if the game is now paused.
|
||||
*/
|
||||
readonly autoPauseChanged: lib.AsyncHook<[paused: boolean]>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
@@ -160,6 +189,7 @@ export interface BaseInstancePlugin {
|
||||
onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void;
|
||||
onPrepareControllerDisconnect?(connection: Instance): Promise<void>;
|
||||
onPlayerEvent?(event: lib.PlayerEvent): Promise<void>;
|
||||
onAutoPauseChanged?(paused: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,6 +252,7 @@ export class BaseInstancePlugin {
|
||||
attach(instance.hooks.controllerConnectionEvent, this.onControllerConnectionEvent);
|
||||
attach(instance.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect);
|
||||
attach(instance.hooks.playerEvent, this.onPlayerEvent);
|
||||
attach(instance.hooks.autoPauseChanged, this.onAutoPauseChanged);
|
||||
}
|
||||
|
||||
static fromContext(context: InstancePluginContext) {
|
||||
|
||||
+162
-75
@@ -15,6 +15,7 @@ import { logger } from "@clusterio/lib";
|
||||
import type { HostConnector } from "../host.js";
|
||||
import Instance from "./Instance.js";
|
||||
import InstanceConnection from "./InstanceConnection.js";
|
||||
import { listFactorioVersions } from "./server.js";
|
||||
import { BaseHostPlugin, HostHooks } from "./BaseHostPlugin.js";
|
||||
|
||||
|
||||
@@ -22,7 +23,38 @@ function checkRequestSaveName(name: string) {
|
||||
try {
|
||||
lib.checkFilename(name);
|
||||
} catch (err: any) {
|
||||
throw new lib.RequestError(`Save name ${err.message}`);
|
||||
throw new lib.ValidationError(`Save name ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function copySaveExclusive(sourcePath: string, targetPath: string) {
|
||||
await fs.copyFile(
|
||||
sourcePath,
|
||||
targetPath,
|
||||
fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file without overwriting an existing target, fails with EEXIST
|
||||
* if the target exists.
|
||||
*/
|
||||
async function moveSaveExclusive(sourcePath: string, targetPath: string) {
|
||||
try {
|
||||
await fs.link(sourcePath, targetPath);
|
||||
} catch (err: any) {
|
||||
// Hard links fail across filesystems and on some filesystems with
|
||||
// varying codes, the copy is still exclusive.
|
||||
if (err.code === "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
await copySaveExclusive(sourcePath, targetPath);
|
||||
}
|
||||
try {
|
||||
await fs.unlink(sourcePath);
|
||||
} catch (err: any) {
|
||||
await fs.unlink(targetPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +204,7 @@ export class HostRouter {
|
||||
return false;
|
||||
}
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError(msg || "Instance is not running."), message.src
|
||||
new lib.ResponseError(msg || "Instance is not running.", "UnavailableError"), message.src
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -234,13 +266,13 @@ export class HostRouter {
|
||||
let dst = message.dst;
|
||||
if (nextHop) {
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError("Instance is already running."), message.src
|
||||
new lib.ResponseError("Instance is already running.", "ConflictError"), message.src
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.host._shuttingDown) {
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError("Host is shutting down."), message.src
|
||||
new lib.ResponseError("Host is shutting down.", "UnavailableError"), message.src
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -249,9 +281,7 @@ export class HostRouter {
|
||||
instanceConnection.connector.forward(message);
|
||||
}).catch(err => {
|
||||
logger.error(`Error starting instance:\n${err.stack ?? err.message}`, this.host.instanceLogMeta(dst.id));
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError(err.message, err.code, err.stack), message.src
|
||||
);
|
||||
origin.connector.sendResponseError(lib.Link.errorToResponse(err), message.src);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -264,9 +294,7 @@ export class HostRouter {
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (message.type === "request") {
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError(err.message, err.code, err.stack), message.src
|
||||
);
|
||||
origin.connector.sendResponseError(lib.Link.errorToResponse(err), message.src);
|
||||
}
|
||||
logger.warn(`Failed to deliver ${(message as any).name || "message"} (${message.type}): ${err.message}`);
|
||||
if (err instanceof lib.InvalidMessage && err.errors) {
|
||||
@@ -322,6 +350,8 @@ export default class Host extends lib.Link {
|
||||
_startup = true;
|
||||
_disconnecting = false;
|
||||
_shuttingDown = false;
|
||||
/** Instances waiting on {@link Instance.fromConfig} */
|
||||
_startingInstances = new Set<number>();
|
||||
|
||||
startedAtMs = Date.now();
|
||||
|
||||
@@ -329,12 +359,18 @@ export default class Host extends lib.Link {
|
||||
"_warning": "Changes to this file will be overwritten by the controller's copy.",
|
||||
};
|
||||
|
||||
static async bootstrap(hostConfig: lib.HostConfig) {
|
||||
/** Create a host with its mod store loaded from disk */
|
||||
static async fromConfig(
|
||||
hostConfig: lib.HostConfig,
|
||||
pluginInfos: lib.PluginNodeEnvInfo[],
|
||||
connector: HostConnector,
|
||||
canRestart = false,
|
||||
recoveryMode = false,
|
||||
) {
|
||||
const modsDirectory = hostConfig.get("host.mods_directory");
|
||||
await fs.mkdir(modsDirectory, { recursive: true });
|
||||
return [
|
||||
await lib.ModStore.fromDirectory(modsDirectory),
|
||||
] as const;
|
||||
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
|
||||
return new this(connector, hostConfig, pluginInfos, canRestart, recoveryMode, modStore);
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -427,6 +463,7 @@ export default class Host extends lib.Link {
|
||||
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
|
||||
this.handle(lib.HostFactorioVersionsRequest, this.handleHostFactorioVersionsRequest.bind(this));
|
||||
this.handle(lib.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this));
|
||||
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
|
||||
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
|
||||
@@ -498,8 +535,8 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
|
||||
let instancesDir = this.config.get("host.instances_directory");
|
||||
for (let i = 0; i < 10; i++) { // Limit attempts in case this is somehow an infinite loop
|
||||
let candidateDir = path.join(instancesDir, await lib.findUnusedName(instancesDir, name));
|
||||
for (let i = 1; ; i++) {
|
||||
let candidateDir = path.join(instancesDir, i === 1 ? name : `${name}-${i}`);
|
||||
try {
|
||||
await fs.mkdir(candidateDir);
|
||||
} catch (err: any) {
|
||||
@@ -510,7 +547,6 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
return candidateDir;
|
||||
}
|
||||
throw Error("Unable to create instance dir, retry threshold reached");
|
||||
}
|
||||
|
||||
async broadcastEventToInstance<T>(event: lib.Event<T>) {
|
||||
@@ -528,12 +564,15 @@ export default class Host extends lib.Link {
|
||||
|
||||
async handleHostRestartRequest() {
|
||||
if (!this.canRestart) {
|
||||
throw new lib.RequestError("Cannot restart, host does not have a process monitor to restart it.");
|
||||
throw new lib.ConflictError("Cannot restart, host does not have a process monitor to restart it.");
|
||||
}
|
||||
if (await lib.packageOperationRunning()) {
|
||||
throw new lib.ConflictError("Cannot restart while a plugin install or update is running on this machine.");
|
||||
}
|
||||
const downgrade = await this.checkRestartDowngrade();
|
||||
if (downgrade) {
|
||||
const { installedVersion, runningVersion } = downgrade;
|
||||
throw new lib.RequestError(
|
||||
throw new lib.ConflictError(
|
||||
`Cannot restart host with installed Clusterio version ${installedVersion} because it is older than ` +
|
||||
`running version ${runningVersion}. Stop the host before starting the older version manually.`
|
||||
);
|
||||
@@ -554,7 +593,7 @@ export default class Host extends lib.Link {
|
||||
if (fieldName === "host.id") {
|
||||
// Changing host.id at runtime is not worth the effort to
|
||||
// support and will break a lot of things if it is allowed.
|
||||
throw new lib.RequestError("Setting 'host.id' while host is running is not supported");
|
||||
throw new lib.ConflictError("Setting 'host.id' while host is running is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,7 +797,7 @@ export default class Host extends lib.Link {
|
||||
this.send(
|
||||
new lib.InstanceStatusChangedEvent(
|
||||
instanceId,
|
||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||
instanceConnection ? instanceConnection.instance.status : this._unconnectedStatus(instanceId),
|
||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||
instanceConnection
|
||||
? instanceConnection.instance.server.version
|
||||
@@ -799,7 +838,7 @@ export default class Host extends lib.Link {
|
||||
getRequestInstance(instanceId: number) {
|
||||
let instance = this.assignedInstances.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
throw new lib.NotFoundError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -845,7 +884,7 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
if (!availablePorts.size) {
|
||||
const others = inUse.size ? ` (${inUse.size} in use by other processes)` : "";
|
||||
throw new lib.RequestError(`No available port left to assign from host.factorio_port_range${others}`);
|
||||
throw new lib.ConflictError(`No available port left to assign from host.factorio_port_range${others}`);
|
||||
}
|
||||
const [newPort] = availablePorts;
|
||||
instanceToAssign.config.set("factorio.host_assigned_game_port", newPort);
|
||||
@@ -874,6 +913,11 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
}
|
||||
|
||||
/** Status of an instance without a connection */
|
||||
_unconnectedStatus(instanceId: number) {
|
||||
return this._startingInstances.has(instanceId) ? "starting" as const : "stopped" as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and connect an unloaded instance
|
||||
*
|
||||
@@ -882,29 +926,36 @@ export default class Host extends lib.Link {
|
||||
*/
|
||||
async _connectInstance(instanceId: number) {
|
||||
let unloadedInstance = this.getRequestInstance(instanceId);
|
||||
if (this.instanceConnections.has(instanceId)) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
|
||||
if (this.instanceConnections.has(instanceId) || this._startingInstances.has(instanceId)) {
|
||||
throw new lib.ConflictError(`Instance with ID ${instanceId} is running`);
|
||||
}
|
||||
|
||||
let hostAddress = new lib.Address(lib.Address.host, this.config.get("host.id"));
|
||||
let instanceAddress = new lib.Address(lib.Address.instance, instanceId);
|
||||
let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
|
||||
let instance = new Instance(
|
||||
this, connectionClient,
|
||||
unloadedInstance.path, this.config.get("host.factorio_directory"),
|
||||
unloadedInstance.config
|
||||
);
|
||||
let instance: Instance;
|
||||
this._startingInstances.add(instanceId);
|
||||
try {
|
||||
instance = await Instance.fromConfig(
|
||||
unloadedInstance.config, this, connectionClient,
|
||||
unloadedInstance.path, this.config.get("host.factorio_directory"),
|
||||
);
|
||||
} finally {
|
||||
this._startingInstances.delete(instanceId);
|
||||
}
|
||||
let instanceConnection = new InstanceConnection(connectionServer, this, instance);
|
||||
|
||||
this.instanceConnections.set(instanceId, instanceConnection);
|
||||
await instance.init(this.pluginInfos);
|
||||
await instance.loadPlugins(this.pluginInfos);
|
||||
|
||||
return instanceConnection;
|
||||
}
|
||||
|
||||
async handleSystemInfoRequest() {
|
||||
const restartRequired = await this.checkRestartRequired();
|
||||
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs);
|
||||
return lib.gatherSystemInfo(
|
||||
this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs, await this.listPlugins(),
|
||||
);
|
||||
}
|
||||
|
||||
async checkRestartRequired() {
|
||||
@@ -971,6 +1022,23 @@ export default class Host extends lib.Link {
|
||||
return null;
|
||||
}
|
||||
|
||||
async handleHostFactorioVersionsRequest() {
|
||||
let installed: Awaited<ReturnType<typeof listFactorioVersions>>;
|
||||
try {
|
||||
installed = await listFactorioVersions(this.config.get("host.factorio_directory"));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
installed = { direct: false, versions: new Set() };
|
||||
}
|
||||
return {
|
||||
versions: [...installed.versions],
|
||||
// Matches the conditions for downloads in FactorioServer.checkForUpdates
|
||||
canDownload: !installed.direct && process.platform === "linux",
|
||||
};
|
||||
}
|
||||
|
||||
async handleHostMetricsRequest() {
|
||||
let requests: Promise<InstanceType<typeof lib.InstanceMetricsRequest["Response"]>>[] = [];
|
||||
for (let instanceConnection of this.instanceConnections.values()) {
|
||||
@@ -1025,19 +1093,16 @@ export default class Host extends lib.Link {
|
||||
checkRequestSaveName(newName);
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
try {
|
||||
// Link save in the new location.
|
||||
await fs.link(
|
||||
await moveSaveExclusive(
|
||||
path.join(instance.path, "saves", oldName),
|
||||
path.join(instance.path, "saves", newName),
|
||||
);
|
||||
// Unlink save in old location.
|
||||
await fs.unlink(path.join(instance.path, "saves", oldName));
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
throw new lib.RequestError(`${newName} already exists`);
|
||||
throw new lib.ConflictError(`${newName} already exists`);
|
||||
}
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${oldName} does not exist`);
|
||||
throw new lib.NotFoundError(`${oldName} does not exist`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -1057,13 +1122,32 @@ export default class Host extends lib.Link {
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${source} does not exist`);
|
||||
throw new lib.NotFoundError(`${source} does not exist`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
|
||||
}
|
||||
|
||||
async _moveOrCopySave(sourcePath: string, targetPath: string, copy: boolean) {
|
||||
const { dir, name, ext } = path.parse(targetPath);
|
||||
for (let i = 1; ; i++) {
|
||||
targetPath = path.format({ dir, name: i === 1 ? name : `${name}-${i}`, ext });
|
||||
try {
|
||||
if (copy) {
|
||||
await copySaveExclusive(sourcePath, targetPath);
|
||||
} else {
|
||||
await moveSaveExclusive(sourcePath, targetPath);
|
||||
}
|
||||
return path.basename(targetPath);
|
||||
} catch (err: any) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
|
||||
let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request;
|
||||
checkRequestSaveName(sourceName);
|
||||
@@ -1071,31 +1155,27 @@ export default class Host extends lib.Link {
|
||||
let sourceInstance = this.getRequestInstance(sourceInstanceId);
|
||||
let targetInstance = this.getRequestInstance(targetInstanceId);
|
||||
|
||||
if (!targetName.endsWith(".zip")) {
|
||||
targetName += ".zip";
|
||||
}
|
||||
|
||||
// For consistency with remote transfer initiated through pullSave the
|
||||
// target is renamed if it already exists.
|
||||
targetName = await lib.findUnusedName(
|
||||
path.join(targetInstance.path, "saves"), targetName, ".zip"
|
||||
);
|
||||
|
||||
const sourcePath = path.join(sourceInstance.path, "saves", sourceName);
|
||||
try {
|
||||
if (copy) {
|
||||
await fs.copyFile(
|
||||
path.join(sourceInstance.path, "saves", sourceName),
|
||||
path.join(targetInstance.path, "saves", targetName),
|
||||
fs.constants.COPYFILE_FICLONE
|
||||
);
|
||||
} else {
|
||||
await fs.rename(
|
||||
path.join(sourceInstance.path, "saves", sourceName),
|
||||
path.join(targetInstance.path, "saves", targetName),
|
||||
);
|
||||
}
|
||||
targetName = await this._moveOrCopySave(
|
||||
sourcePath,
|
||||
path.join(targetInstance.path, "saves", targetName),
|
||||
copy,
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${sourceName} does not exist`);
|
||||
// ENOENT may also come from a missing target saves folder.
|
||||
if (err.code === "ENOENT" && !await fs.access(sourcePath).then(() => true, () => false)) {
|
||||
throw new lib.NotFoundError(`${sourceName} does not exist`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstance.path, "saves"));
|
||||
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstance.path, "saves"));
|
||||
|
||||
@@ -1123,7 +1203,7 @@ export default class Host extends lib.Link {
|
||||
await fs.unlink(path.join(instance.path, "saves", name));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${name} does not exist`);
|
||||
throw new lib.NotFoundError(`${name} does not exist`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -1154,7 +1234,7 @@ export default class Host extends lib.Link {
|
||||
content = (await fs.open(path.join(instance.path, "saves", name))).createReadStream();
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${name} does not exist`);
|
||||
throw new lib.NotFoundError(`${name} does not exist`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -1173,12 +1253,12 @@ export default class Host extends lib.Link {
|
||||
async handleInstanceDeleteInternalRequest(request: lib.InstanceDeleteInternalRequest) {
|
||||
let instanceId = request.instanceId;
|
||||
if (this.instanceConnections.has(instanceId)) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
|
||||
throw new lib.ConflictError(`Instance with ID ${instanceId} is running`);
|
||||
}
|
||||
|
||||
let instance = this.discoveredInstances.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
throw new lib.NotFoundError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
|
||||
this.discoveredInstances.delete(instanceId);
|
||||
@@ -1188,45 +1268,52 @@ export default class Host extends lib.Link {
|
||||
|
||||
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
|
||||
if (!this.config.get("host.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Remote updates are disabled on this machine");
|
||||
}
|
||||
return await lib.updatePackage("@clusterio/host");
|
||||
}
|
||||
|
||||
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
|
||||
if (!this.config.get("host.allow_plugin_updates")) {
|
||||
throw new lib.RequestError("Plugin updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin updates are disabled on this machine");
|
||||
}
|
||||
return await lib.handlePluginUpdate(request.pluginPackage, this.pluginInfos);
|
||||
}
|
||||
|
||||
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
|
||||
if (!this.config.get("host.allow_plugin_install")) {
|
||||
throw new lib.RequestError("Plugin installs are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin installs are disabled on this machine");
|
||||
}
|
||||
await lib.handlePluginInstall(request.pluginPackage);
|
||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
|
||||
if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
|
||||
const npmPackage = await lib.handlePluginInstall(request.pluginPackage);
|
||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it. Version
|
||||
// changes to loaded plugins are noticed there.
|
||||
if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === npmPackage)) {
|
||||
this.config.restartRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
|
||||
if (!this.config.get("host.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Remote updates are disabled on this machine");
|
||||
}
|
||||
if (!this.config.get("host.allow_plugin_updates")) {
|
||||
throw new lib.RequestError("Plugin updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin updates are disabled on this machine");
|
||||
}
|
||||
return await lib.handleUpdateAll("@clusterio/host", this.pluginInfos);
|
||||
}
|
||||
|
||||
async handlePluginListRequest(request: lib.PluginListRequest) {
|
||||
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo,
|
||||
this.loadedPlugins.has(pluginInfo),
|
||||
this.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
));
|
||||
return await this.listPlugins();
|
||||
}
|
||||
|
||||
/** Plugins on this host, including those installed or updated since it started */
|
||||
async listPlugins() {
|
||||
return await lib.listPluginDetails(
|
||||
this.pluginInfos,
|
||||
pluginInfo => this.loadedPlugins.has(pluginInfo),
|
||||
pluginInfo => this.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
pluginInfo => Boolean(pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint),
|
||||
);
|
||||
}
|
||||
|
||||
sendHostUpdate() {
|
||||
@@ -1253,7 +1340,7 @@ export default class Host extends lib.Link {
|
||||
let instanceConnection = this.instanceConnections.get(instanceId);
|
||||
list.push(new lib.HostInstanceUpdate(
|
||||
instance.config.toRemote("controller"),
|
||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||
instanceConnection ? instanceConnection.instance.status : this._unconnectedStatus(instanceId),
|
||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
||||
));
|
||||
|
||||
+117
-59
@@ -10,7 +10,7 @@ const execAsync = util.promisify(exec);
|
||||
// internal libraries
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { FactorioServer } from "./server.js";
|
||||
import { FactorioServer, checkForUpdates } from "./server.js";
|
||||
import { loadModules, patch } from "./patch.js";
|
||||
import { exportData } from "./export.js";
|
||||
import type Host from "./Host.js";
|
||||
@@ -21,7 +21,7 @@ function checkRequestSaveName(name: string) {
|
||||
try {
|
||||
lib.checkFilename(name);
|
||||
} catch (err: any) {
|
||||
throw new lib.RequestError(`Save name ${err.message}`);
|
||||
throw new lib.ValidationError(`Save name ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,13 @@ const scriptCommands = [
|
||||
"/silent-command", "/sc",
|
||||
];
|
||||
|
||||
function instanceLogger(config: lib.InstanceConfig) {
|
||||
return lib.logger.child({
|
||||
instance_id: config.get("instance.id"),
|
||||
instance_name: config.get("instance.name"),
|
||||
});
|
||||
}
|
||||
|
||||
// Statuses the instance passes through on its way to running or stopped.
|
||||
const transitoryStatuses: ReadonlySet<Instance["_status"]> = new Set([
|
||||
"starting", "stopping", "creating_save", "exporting_data",
|
||||
@@ -183,6 +190,7 @@ export default class Instance extends lib.Link {
|
||||
_loadedSave: string | null = null;
|
||||
_playerCheckInterval: ReturnType<typeof setInterval> | undefined;
|
||||
_hadPlayersOnline = false;
|
||||
_autoPause = false;
|
||||
_playerAutosaveSlot = 1;
|
||||
_expectedUserUpdates: { action: string, name: string, reason: string }[] = [];
|
||||
_settleWaiters: (() => void)[] = [];
|
||||
@@ -191,8 +199,8 @@ export default class Instance extends lib.Link {
|
||||
host: Host,
|
||||
connector: lib.VirtualConnector,
|
||||
dir: string,
|
||||
factorioDir: string,
|
||||
instanceConfig: lib.InstanceConfig
|
||||
instanceConfig: lib.InstanceConfig,
|
||||
server: FactorioServer,
|
||||
) {
|
||||
super(connector);
|
||||
this._host = host;
|
||||
@@ -202,10 +210,7 @@ export default class Instance extends lib.Link {
|
||||
|
||||
this.id = this.config.get("instance.id");
|
||||
|
||||
this.logger = lib.logger.child({
|
||||
instance_id: this.id,
|
||||
instance_name: this.name,
|
||||
});
|
||||
this.logger = instanceLogger(instanceConfig);
|
||||
|
||||
this.hooks = new InstanceHooks(this.logger);
|
||||
|
||||
@@ -225,28 +230,7 @@ export default class Instance extends lib.Link {
|
||||
};
|
||||
this.config.on("fieldChanged", this._configFieldChanged);
|
||||
|
||||
let serverOptions = {
|
||||
logger: this.logger,
|
||||
// TODO: factorio.version is not validated
|
||||
version: this.config.get("factorio.version") as lib.TargetVersion,
|
||||
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,
|
||||
enableLuaUdp: this.config.get("factorio.enable_lua_udp"),
|
||||
luaUdpPort: this.config.get("factorio.lua_udp_port") ?? undefined,
|
||||
enableWhitelist: this.config.get("factorio.enable_whitelist"),
|
||||
enableAuthserverBans: this.config.get("factorio.enable_authserver_bans"),
|
||||
verboseLogging: this.config.get("factorio.verbose_logging"),
|
||||
consoleLogging: this.config.get("factorio.console_logging"),
|
||||
stripPaths: this.config.get("factorio.strip_paths"),
|
||||
maxConcurrentCommands: this.config.get("factorio.max_concurrent_commands"),
|
||||
shutdownTimeoutMs: this.config.get("factorio.shutdown_timeout") * 1000,
|
||||
};
|
||||
|
||||
this.server = new FactorioServer(
|
||||
factorioDir, this._dir, serverOptions
|
||||
);
|
||||
this.server = server;
|
||||
|
||||
this.server.on("output", (parsed, line) => {
|
||||
this.logger.log("server", { message: line, instance_id: this.id, parsed });
|
||||
@@ -405,10 +389,19 @@ export default class Instance extends lib.Link {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True while Factorio has paused the game because auto_pause is on
|
||||
* and no players are online.
|
||||
*/
|
||||
get autoPaused() {
|
||||
return this._autoPause && this.playersOnline.size === 0;
|
||||
}
|
||||
|
||||
_recordPlayerJoin(name: string) {
|
||||
if (this.playersOnline.has(name)) {
|
||||
return;
|
||||
}
|
||||
const wasPaused = this.autoPaused;
|
||||
this.playersOnline.add(name);
|
||||
|
||||
let stats = this.playerStats.get(name);
|
||||
@@ -428,6 +421,9 @@ export default class Instance extends lib.Link {
|
||||
stats,
|
||||
};
|
||||
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats));
|
||||
if (wasPaused) {
|
||||
this.hooks.autoPauseChanged.invoke(false);
|
||||
}
|
||||
this.hooks.playerEvent.invoke(event);
|
||||
}
|
||||
|
||||
@@ -449,6 +445,9 @@ export default class Instance extends lib.Link {
|
||||
stats,
|
||||
};
|
||||
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("leave", name, stats, reason));
|
||||
if (this.autoPaused) {
|
||||
this.hooks.autoPauseChanged.invoke(true);
|
||||
}
|
||||
this.hooks.playerEvent.invoke(event);
|
||||
}
|
||||
|
||||
@@ -645,6 +644,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
|
||||
notifyExit() {
|
||||
this._loadedSave = null;
|
||||
this._autoPause = false;
|
||||
this.notifyStatus("stopped");
|
||||
this.connector.emit("close");
|
||||
|
||||
@@ -670,20 +670,22 @@ end`.replace(/\r?\n/g, " ");
|
||||
this._saveStats().catch(err => this.logger.error(`Error saving stats:\n${err.stack}`));
|
||||
}
|
||||
|
||||
async _loadStats() {
|
||||
static async _readStats(statsPath: string) {
|
||||
let instanceStats;
|
||||
try {
|
||||
instanceStats = JSON.parse(await fs.readFile(this.path("instance-stats.json"), "utf8"));
|
||||
instanceStats = JSON.parse(await fs.readFile(statsPath, "utf8"));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return;
|
||||
return { playerStats: new Map<string, lib.PlayerStats>(), playerAutosaveSlot: 1 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
this.playerStats = new Map(instanceStats["players"].map(
|
||||
([id, stats]: [number, Static<typeof lib.PlayerStats.jsonSchema>]) => [id, new lib.PlayerStats(stats)])
|
||||
);
|
||||
this._playerAutosaveSlot = instanceStats["player_autosave_slot"] || 1;
|
||||
return {
|
||||
playerStats: new Map<string, lib.PlayerStats>(instanceStats["players"].map(
|
||||
([id, stats]: [string, Static<typeof lib.PlayerStats.jsonSchema>]) => [id, new lib.PlayerStats(stats)]
|
||||
)),
|
||||
playerAutosaveSlot: (instanceStats["player_autosave_slot"] as number) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
async _saveStats() {
|
||||
@@ -694,21 +696,35 @@ end`.replace(/\r?\n/g, " ");
|
||||
await lib.safeOutputFile(this.path("instance-stats.json"), content);
|
||||
}
|
||||
|
||||
async init(pluginInfos: lib.PluginNodeEnvInfo[]) {
|
||||
// After changing the status this function is responsible for calling notifyExit if it throws.
|
||||
this.notifyStatus("starting");
|
||||
/**
|
||||
* Create an instance with a Factorio server, downloading Factorio if needed
|
||||
*
|
||||
* The host reports the starting status for the instance while this runs.
|
||||
*/
|
||||
static async fromConfig(
|
||||
config: lib.InstanceConfig,
|
||||
host: Host,
|
||||
connector: lib.VirtualConnector,
|
||||
dir: string,
|
||||
factorioDir: string,
|
||||
) {
|
||||
const id = config.get("instance.id");
|
||||
// TODO: factorio.version is not validated
|
||||
const targetVersion = config.get("factorio.version") as lib.TargetVersion;
|
||||
const gamePort = config.get("factorio.game_port") ?? await host.assignFreeGamePort(id);
|
||||
host.send(new lib.InstanceStatusChangedEvent(id, "starting", gamePort, targetVersion));
|
||||
|
||||
let server: FactorioServer;
|
||||
let stats: Awaited<ReturnType<typeof Instance._readStats>>;
|
||||
try {
|
||||
if (this.config.get("factorio.game_port") === null) {
|
||||
this.server.gamePort = await this._host.assignFreeGamePort(this.id);
|
||||
}
|
||||
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
|
||||
await this._loadStats();
|
||||
const factorioVersions = await host.sendTo("controller", new lib.FactorioVersionsRequest());
|
||||
stats = await Instance._readStats(path.join(dir, "instance-stats.json"));
|
||||
// Resolve a release channel target (e.g. "stable") to a concrete
|
||||
// version using the controller's cached latest-releases data, so the
|
||||
// existing download/lookup logic can fetch and run it.
|
||||
const targetVersion = this.config.get("factorio.version") as string;
|
||||
let version = targetVersion;
|
||||
if (lib.isReleaseChannel(targetVersion)) {
|
||||
const releases = await this.sendTo("controller", new lib.LatestReleasesRequest());
|
||||
const releases = await host.sendTo("controller", new lib.LatestReleasesRequest());
|
||||
const resolved = lib.resolveReleaseChannel(releases, targetVersion);
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
@@ -716,16 +732,46 @@ end`.replace(/\r?\n/g, " ");
|
||||
"latest-releases unavailable and no cached copy on the controller."
|
||||
);
|
||||
}
|
||||
this.server.setTargetVersion(resolved);
|
||||
version = resolved;
|
||||
}
|
||||
await this.server.checkForUpdates(factorioVersions);
|
||||
await this.server.init();
|
||||
const logger = instanceLogger(config);
|
||||
await checkForUpdates(factorioDir, version, factorioVersions, logger);
|
||||
server = await FactorioServer.fromOptions(factorioDir, dir, {
|
||||
logger,
|
||||
version,
|
||||
executablePath: config.get("factorio.executable_path") ?? undefined,
|
||||
gamePort,
|
||||
rconPort: config.get("factorio.rcon_port") ?? undefined,
|
||||
rconPassword: config.get("factorio.rcon_password") ?? undefined,
|
||||
enableLuaUdp: config.get("factorio.enable_lua_udp"),
|
||||
luaUdpPort: config.get("factorio.lua_udp_port") ?? undefined,
|
||||
enableWhitelist: config.get("factorio.enable_whitelist"),
|
||||
enableAuthserverBans: config.get("factorio.enable_authserver_bans"),
|
||||
verboseLogging: config.get("factorio.verbose_logging"),
|
||||
consoleLogging: config.get("factorio.console_logging"),
|
||||
stripPaths: config.get("factorio.strip_paths"),
|
||||
maxConcurrentCommands: config.get("factorio.max_concurrent_commands"),
|
||||
shutdownTimeoutMs: config.get("factorio.shutdown_timeout") * 1000,
|
||||
});
|
||||
} catch (err) {
|
||||
this.notifyExit();
|
||||
await this.sendSaveListUpdate();
|
||||
host.send(new lib.InstanceStatusChangedEvent(id, "stopped", gamePort, targetVersion));
|
||||
await host.sendSaveListUpdate(id, path.join(dir, "saves"));
|
||||
throw err;
|
||||
}
|
||||
|
||||
const instance = new this(host, connector, dir, config, server);
|
||||
instance._status = "starting";
|
||||
instance.playerStats = stats.playerStats;
|
||||
instance._playerAutosaveSlot = stats.playerAutosaveSlot;
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the instance plugins and report the instance as initialised
|
||||
*
|
||||
* Must be called once the instance is connected to the host.
|
||||
*/
|
||||
async loadPlugins(pluginInfos: lib.PluginNodeEnvInfo[]) {
|
||||
// load plugins
|
||||
const plugins: Record<string, string> = {};
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
@@ -828,6 +874,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
this.server.writePath("server-settings.json"),
|
||||
JSON.stringify(serverSettings, null, "\t")
|
||||
);
|
||||
return serverSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -923,12 +970,12 @@ end`.replace(/\r?\n/g, " ");
|
||||
* Factorio refuses to read it if it is newer than the game. It then
|
||||
* loads default settings instead, which can corrupt the save.
|
||||
*
|
||||
* @throws {lib.RequestError} if the mod pack version is higher than the server version.
|
||||
* @throws {lib.ConflictError} if the mod pack version is higher than the server version.
|
||||
*/
|
||||
checkModPackVersion(modPack: lib.ModPack) {
|
||||
const serverVersion = this.server.version;
|
||||
if (serverVersion && modPack.integerFactorioVersion > lib.integerFullVersion(serverVersion)) {
|
||||
throw new lib.RequestError(
|
||||
throw new lib.ConflictError(
|
||||
`Mod pack ${modPack.name} is for Factorio ${modPack.factorioVersion} ` +
|
||||
`which is newer than the Factorio ${serverVersion} this instance runs`
|
||||
);
|
||||
@@ -942,7 +989,9 @@ end`.replace(/\r?\n/g, " ");
|
||||
*/
|
||||
async prepare() {
|
||||
this.logger.verbose("Writing server-settings.json");
|
||||
await this.writeServerSettings(true);
|
||||
// auto_pause is only read on startup
|
||||
const serverSettings = await this.writeServerSettings(true);
|
||||
this._autoPause = serverSettings.auto_pause === true;
|
||||
this._expectedUserUpdates = [];
|
||||
|
||||
if (this.config.get("factorio.sync_adminlist") !== "disabled") {
|
||||
@@ -996,7 +1045,16 @@ end`.replace(/\r?\n/g, " ");
|
||||
* @returns Name of the save prepared.
|
||||
*/
|
||||
async prepareSave(saveName?: string) {
|
||||
// Use latest save if no save was specified
|
||||
// Let plugins pick the save, otherwise use latest save if no save was specified
|
||||
if (saveName === undefined) {
|
||||
for (const result of await this.hooks.chooseSave.collect()) {
|
||||
if (result) {
|
||||
checkRequestSaveName(result);
|
||||
saveName = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (saveName === undefined) {
|
||||
saveName = await lib.getNewestFile(
|
||||
this.path("saves"), (name) => !name.endsWith(".tmp.zip")
|
||||
@@ -1331,7 +1389,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
async handleInstanceLoadScenarioRequest(request: lib.InstanceLoadScenarioRequest) {
|
||||
if (this.config.get("factorio.enable_save_patching")) {
|
||||
this.notifyExit();
|
||||
throw new lib.RequestError("Load scenario cannot be used with save patching enabled");
|
||||
throw new lib.ConflictError("Load scenario cannot be used with save patching enabled");
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1393,7 +1451,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
|
||||
async handleInstanceSaveGameRequest() {
|
||||
if (this._status !== "running") {
|
||||
throw new lib.RequestError("Instance is not running");
|
||||
throw new lib.UnavailableError("Instance is not running");
|
||||
}
|
||||
|
||||
const saved = new Promise<void>((resolve, reject) => {
|
||||
@@ -1403,7 +1461,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
};
|
||||
const onExit = () => {
|
||||
this.server.off("save-finished", onSaved);
|
||||
reject(new lib.RequestError("Instance stopped before the save finished"));
|
||||
reject(new lib.UnavailableError("Instance stopped before the save finished"));
|
||||
};
|
||||
this.server.once("save-finished", onSaved);
|
||||
this.server.once("exit", onExit);
|
||||
|
||||
+77
-27
@@ -465,37 +465,87 @@ export async function patch(savePath: string, modules: SaveModule[]) {
|
||||
|
||||
// Write back the save, keeping the order of the entries in the original
|
||||
const added = new Map([...files].map(([relativePath, contents]) => [rootPath(relativePath), contents]));
|
||||
const output = new lib.ZipWriter();
|
||||
let writeStream;
|
||||
[tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
|
||||
const writing = pipeline(output.outputStream, writeStream);
|
||||
// Awaited below, but a write error can reject it before then.
|
||||
writing.catch(() => {});
|
||||
try {
|
||||
for (const entry of zip.entries.values()) {
|
||||
const name = entry.fileName;
|
||||
if (removed.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const contents = added.get(name);
|
||||
if (contents !== undefined) {
|
||||
await output.addBuffer(contents, name);
|
||||
added.delete(name);
|
||||
} else {
|
||||
await output.addEntry(zip, entry);
|
||||
}
|
||||
tempSavePath = await writeSaveCopy(zip, savePath, added, removed);
|
||||
} finally {
|
||||
zip.close();
|
||||
}
|
||||
await fs.rename(tempSavePath, savePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a copy of a save with entries replaced, added and removed
|
||||
*
|
||||
* The copy is written next to the save, and must be renamed over it after
|
||||
* the save has been closed.
|
||||
*
|
||||
* @param zip - Opened save to copy.
|
||||
* @param savePath - Path the save was opened from.
|
||||
* @param added - Entries by full path in the zip to add or replace.
|
||||
* @param removed - Full paths of entries to leave out.
|
||||
* @returns path to the copy written.
|
||||
*/
|
||||
async function writeSaveCopy(
|
||||
zip: lib.ZipArchive,
|
||||
savePath: string,
|
||||
added: Map<string, Buffer>,
|
||||
removed = new Set<string>(),
|
||||
) {
|
||||
const output = new lib.ZipWriter();
|
||||
const [tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
|
||||
const writing = pipeline(output.outputStream, writeStream);
|
||||
// Awaited below, but a write error can reject it before then.
|
||||
writing.catch(() => {});
|
||||
try {
|
||||
for (const entry of zip.entries.values()) {
|
||||
const name = entry.fileName;
|
||||
if (removed.has(name)) {
|
||||
continue;
|
||||
}
|
||||
for (const [name, contents] of added) {
|
||||
const contents = added.get(name);
|
||||
if (contents !== undefined) {
|
||||
await output.addBuffer(contents, name);
|
||||
added.delete(name);
|
||||
} else {
|
||||
await output.addEntry(zip, entry);
|
||||
}
|
||||
await output.end();
|
||||
await writing;
|
||||
} catch (err) {
|
||||
output.outputStream.destroy();
|
||||
await writing.catch(() => {});
|
||||
await fs.rm(tempSavePath, { force: true });
|
||||
throw err;
|
||||
}
|
||||
for (const [name, contents] of added) {
|
||||
await output.addBuffer(contents, name);
|
||||
}
|
||||
await output.end();
|
||||
await writing;
|
||||
} catch (err) {
|
||||
output.outputStream.destroy();
|
||||
await writing.catch(() => {});
|
||||
await fs.rm(tempSavePath, { force: true });
|
||||
throw err;
|
||||
}
|
||||
return tempSavePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the scenario code the save patcher keeps loading in a save
|
||||
*
|
||||
* Saves created by Factorio run the freeplay scenario, which the patcher
|
||||
* keeps when patching them. This replaces it with the given scenario info,
|
||||
* for example a scenario that loads no modules to get a save without
|
||||
* freeplay. The save must not have been patched before.
|
||||
*
|
||||
* @param savePath - Path to the save to change.
|
||||
* @param scenario - Scenario modules to load in place of the original scenario.
|
||||
*/
|
||||
export async function setSaveScenario(savePath: string, scenario: lib.ModuleInfo) {
|
||||
const zip = await lib.ZipArchive.fromFile(savePath);
|
||||
let tempSavePath: string;
|
||||
try {
|
||||
const root = lib.findRoot(zip);
|
||||
if (await zip.readFile(path.posix.join(root, "clusterio.json")) !== null) {
|
||||
throw new Error("Unable to set scenario of a save that has already been patched");
|
||||
}
|
||||
const patchInfo = new PatchInfo(0, scenario, []);
|
||||
tempSavePath = await writeSaveCopy(zip, savePath, new Map([[
|
||||
path.posix.join(root, "clusterio.json"), Buffer.from(JSON.stringify(patchInfo, null, " "), "utf8"),
|
||||
]]));
|
||||
} finally {
|
||||
zip.close();
|
||||
}
|
||||
|
||||
+99
-95
@@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) {
|
||||
* @param factorioDir - Path to Factorio installation dirs.
|
||||
* @returns Set of installed factorio versions
|
||||
*/
|
||||
async function listFactorioVersions(factorioDir: string) {
|
||||
export async function listFactorioVersions(factorioDir: string) {
|
||||
const versions = new Set<lib.FullVersion>();
|
||||
|
||||
const directVersion = await getFactorioVersion(factorioDir);
|
||||
@@ -185,6 +185,73 @@ async function downloadAndExtractTar(url: string, targetDir: string) {
|
||||
// Factorio downloads in progress by target directory, shared by every server in the process
|
||||
const downloadsInProgress = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Download the newest Factorio version matching the target if not installed
|
||||
*
|
||||
* Call before {@link FactorioServer.fromOptions} for it to find the new version.
|
||||
*
|
||||
* @param factorioDir - Path to Factorio installation dirs.
|
||||
* @param targetVersion - Version to look for, or "latest".
|
||||
* @param availableVersions - Versions available for download.
|
||||
* @param logger - Logger to report the download on.
|
||||
*/
|
||||
export async function checkForUpdates(
|
||||
factorioDir: string,
|
||||
targetVersion: lib.TargetVersion,
|
||||
availableVersions: lib.ExternalFactorioVersion[],
|
||||
logger: lib.Logger = lib.logger,
|
||||
) {
|
||||
|
||||
const latestVersion = availableVersions
|
||||
.filter(v => (
|
||||
targetVersion === "latest" || v.version === targetVersion || v.version.startsWith(targetVersion)
|
||||
))
|
||||
.sort((v1, v2) => versionOrder(v1.version, v2.version))
|
||||
[0];
|
||||
|
||||
if (!latestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A version that is still being extracted can already look installed
|
||||
const dst = path.join(factorioDir, latestVersion.version);
|
||||
const installedVersions = await listFactorioVersions(factorioDir);
|
||||
if (!installedVersions.versions.has(latestVersion.version) || downloadsInProgress.has(dst)) {
|
||||
const v = latestVersion.version;
|
||||
// Can't download if not linux or this is a direct install
|
||||
if (installedVersions.direct) {
|
||||
logger.info(
|
||||
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
|
||||
`because ${factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
|
||||
`layout, where ${factorioDir} contains a subdirectory per version, to enable automatic ` +
|
||||
"downloads."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (process.platform !== "linux") {
|
||||
logger.info(
|
||||
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
|
||||
"(automatic downloads are only supported on Linux)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Download the newer version, or wait for another server already downloading it
|
||||
let download = downloadsInProgress.get(dst);
|
||||
if (download) {
|
||||
logger.info(`A newer version of factorio is available (${v}), waiting for download...`);
|
||||
} else {
|
||||
logger.info(`A newer version of factorio is available (${v}), starting download...`);
|
||||
download = downloadAndExtractTar(latestVersion.headlessUrl, dst).finally(() => {
|
||||
downloadsInProgress.delete(dst);
|
||||
});
|
||||
downloadsInProgress.set(dst, download);
|
||||
}
|
||||
await download;
|
||||
logger.info(`Downloaded factorio version ${v}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find factorio data directory of the given version
|
||||
*
|
||||
@@ -670,9 +737,8 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
_factorioDir: string;
|
||||
_writeDir: string;
|
||||
|
||||
// Resolved in init
|
||||
_version: lib.FullVersion | null = null;
|
||||
_dataDir: string | null = null;
|
||||
_version: lib.FullVersion;
|
||||
_dataDir: string;
|
||||
|
||||
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
|
||||
// https://forums.factorio.com/viewtopic.php?t=123673
|
||||
@@ -680,8 +746,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
_whitelist = new Set<string>();
|
||||
|
||||
_logger: lib.Logger;
|
||||
_targetVersion: lib.TargetVersion;
|
||||
_state: "new" | "init" | "create" | "running" | "stopping" = "new";
|
||||
_state: "init" | "create" | "running" | "stopping" = "init";
|
||||
_server: child_process.ChildProcessWithoutNullStreams | null = null;
|
||||
_rconClient: Rcon | null= null;
|
||||
_rconReady = false;
|
||||
@@ -697,27 +762,36 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
|
||||
|
||||
/**
|
||||
* Create a Factorio server interface
|
||||
* Create a Factorio server interface for the version found by {@link fromOptions}
|
||||
*
|
||||
* @param factorioDir - Directory of the Factorio install(s).
|
||||
* @param writeDir - Directory to write runtime data to.
|
||||
* @param options - Optional parameters.
|
||||
* @param dataDir - Data directory of the Factorio version to run.
|
||||
* @param version - Factorio version to run.
|
||||
* @param options - Optional parameters, `version` is ignored.
|
||||
*/
|
||||
constructor(factorioDir: string, writeDir: string, options: FactorioServerOptions) {
|
||||
constructor(
|
||||
factorioDir: string,
|
||||
writeDir: string,
|
||||
dataDir: string,
|
||||
version: lib.FullVersion,
|
||||
options: FactorioServerOptions & { rconPassword: string },
|
||||
) {
|
||||
super();
|
||||
|
||||
this._factorioDir = factorioDir;
|
||||
this._writeDir = writeDir;
|
||||
this._dataDir = dataDir;
|
||||
this._version = version;
|
||||
|
||||
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 */
|
||||
this.rconPort = options.rconPort || randomDynamicPort();
|
||||
/** Password used for RCON on the Factorio game server */
|
||||
this.rconPassword = options.rconPassword as string; // init will generate one if not there
|
||||
this.rconPassword = options.rconPassword;
|
||||
/** Enable UDP messaging with the Factorio game server */
|
||||
this.enableLuaUdp = options.enableLuaUdp || false;
|
||||
/** UDP port the Factorio game server receives Lua UDP messages on */
|
||||
@@ -886,7 +960,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
|
||||
// Factorio before 2.1.10 crashes when a packet is received on a headless server
|
||||
_checkLuaUdpVersion() {
|
||||
if (this.enableLuaUdp && lib.integerFullVersion(this._version!) < lib.integerFullVersion("2.1.10")) {
|
||||
if (this.enableLuaUdp && lib.integerFullVersion(this._version) < lib.integerFullVersion("2.1.10")) {
|
||||
throw new Error("Lua UDP requires Factorio 2.1.10 or later");
|
||||
}
|
||||
}
|
||||
@@ -1075,94 +1149,25 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the target version of Factorio to run
|
||||
* Create a Factorio server interface for an installed version
|
||||
*
|
||||
* Used to substitute a concrete version for a release channel target
|
||||
* (e.g. "stable" or "experimental") before {@link checkForUpdates} and
|
||||
* {@link init} run. Must be called before the server is initialized.
|
||||
*/
|
||||
setTargetVersion(version: lib.TargetVersion) {
|
||||
this._check(["new"]);
|
||||
this._targetVersion = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize class instance
|
||||
*
|
||||
* Must be called before instances of this class can be used.
|
||||
* @param factorioDir - Directory of the Factorio install(s).
|
||||
* @param writeDir - Directory to write runtime data to.
|
||||
* @param options - Optional parameters.
|
||||
* @throws {Error} if the requested version of Factorio was not found.
|
||||
*/
|
||||
async init() {
|
||||
this._check(["new"]);
|
||||
[this._dataDir, this._version] = await findVersion(this._factorioDir, this._targetVersion);
|
||||
this.rconPassword = this.rconPassword as string | undefined ?? await generatePassword(10);
|
||||
this._state = "init";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check of updated versions to download
|
||||
*
|
||||
* Should be called before init for the new version to be detected during init
|
||||
*/
|
||||
async checkForUpdates(availableVersions: lib.ExternalFactorioVersion[]) {
|
||||
const targetVersion = this._targetVersion;
|
||||
const latestVersion = availableVersions
|
||||
.filter(v => (
|
||||
targetVersion === "latest" || v.version === targetVersion || v.version.startsWith(targetVersion)
|
||||
))
|
||||
.sort((v1, v2) => versionOrder(v1.version, v2.version))
|
||||
[0];
|
||||
|
||||
if (!latestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A version that is still being extracted can already look installed
|
||||
const dst = path.join(this._factorioDir, latestVersion.version);
|
||||
const installedVersions = await listFactorioVersions(this._factorioDir);
|
||||
if (!installedVersions.versions.has(latestVersion.version) || downloadsInProgress.has(dst)) {
|
||||
const v = latestVersion.version;
|
||||
// Can't download if not linux or this is a direct install
|
||||
if (installedVersions.direct) {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
|
||||
`because ${this._factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
|
||||
`layout, where ${this._factorioDir} contains a subdirectory per version, to enable automatic ` +
|
||||
"downloads."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (process.platform !== "linux") {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
|
||||
"(automatic downloads are only supported on Linux)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Download the newer version, or wait for another server already downloading it
|
||||
let download = downloadsInProgress.get(dst);
|
||||
if (download) {
|
||||
this._logger.info(`A newer version of factorio is available (${v}), waiting for download...`);
|
||||
} else {
|
||||
this._logger.info(`A newer version of factorio is available (${v}), starting download...`);
|
||||
download = downloadAndExtractTar(latestVersion.headlessUrl, dst).finally(() => {
|
||||
downloadsInProgress.delete(dst);
|
||||
});
|
||||
downloadsInProgress.set(dst, download);
|
||||
}
|
||||
await download;
|
||||
this._logger.info(`Downloaded factorio version ${v}`);
|
||||
}
|
||||
static async fromOptions(factorioDir: string, writeDir: string, options: FactorioServerOptions) {
|
||||
const [dataDir, version] = await findVersion(factorioDir, options.version || "latest");
|
||||
const rconPassword = options.rconPassword ?? await generatePassword(10);
|
||||
return new this(factorioDir, writeDir, dataDir, version, { ...options, rconPassword });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* "latest" (the default) was passed to {@link fromOptions}.
|
||||
*/
|
||||
get version(): lib.FullVersion | undefined {
|
||||
return this._version ?? undefined;
|
||||
get version(): lib.FullVersion {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1681,14 +1686,13 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
* Return path in data directory
|
||||
*
|
||||
* Creates a path using path.join with the given parts that's relative to
|
||||
* the data directory of the Factorio server. Not valid before init has
|
||||
* been called.
|
||||
* the data directory of the Factorio server.
|
||||
*
|
||||
* @param parts - Extra parts to add to the data path.
|
||||
* @returns Data directory path.
|
||||
*/
|
||||
dataPath(...parts: string[]) {
|
||||
return path.join(this._dataDir!, ...parts);
|
||||
return path.join(this._dataDir, ...parts);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./src/browser_polyfills";
|
||||
export * from "./src/api";
|
||||
export * from "./src/AsyncHook";
|
||||
export * from "./src/SyncHook";
|
||||
|
||||
Vendored
+1
-1
@@ -6,5 +6,5 @@ export interface BuildArgs {
|
||||
build?: boolean;
|
||||
outputDir: string;
|
||||
sourceDir: string;
|
||||
};
|
||||
}
|
||||
export function build(args: BuildArgs): Promise<void>;
|
||||
@@ -35,6 +35,7 @@ export * from "./src/subscriptions.js";
|
||||
export * from "./src/datastore.js";
|
||||
export * from "./src/load_plugin_list.js";
|
||||
export * from "./src/rce_ops.js";
|
||||
export * from "./src/npm_registry.js";
|
||||
export * from "./src/LockFile.js";
|
||||
export * from "./src/AsyncHook.js";
|
||||
export * from "./src/SyncHook.js";
|
||||
|
||||
@@ -153,9 +153,10 @@ export class AsyncHookCollection {
|
||||
Args extends unknown[],
|
||||
Result = void,
|
||||
> (
|
||||
_logger = this._logger
|
||||
_logger = this._logger,
|
||||
timeoutMs?: number,
|
||||
) {
|
||||
const hook = new AsyncHook<Args, Result>(_logger);
|
||||
const hook = new AsyncHook<Args, Result>(_logger, timeoutMs);
|
||||
this.hooks.push(hook);
|
||||
return hook;
|
||||
}
|
||||
|
||||
+144
-31
@@ -1,5 +1,5 @@
|
||||
import { FileHandle, open, unlink, readFile } from "node:fs/promises";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import fs, { type FileHandle } from "node:fs/promises";
|
||||
import { readFileSync, unlinkSync } from "node:fs";
|
||||
import assert from "assert";
|
||||
|
||||
/**
|
||||
@@ -24,23 +24,99 @@ export class LockFileBusyError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a process exited while clearing a stale lock, which needs the recovery file removed by hand.
|
||||
*/
|
||||
export class LockFileRecoveryError extends Error {
|
||||
constructor(
|
||||
readonly recoveryPath: string
|
||||
) {
|
||||
super(`Lock file recovery was interrupted, remove ${recoveryPath} to allow the lock to be acquired`);
|
||||
this.name = "LockFileRecoveryError";
|
||||
}
|
||||
}
|
||||
|
||||
type LockState = "idle" | "acquiring" | "acquired" | "releasing";
|
||||
type LockSnapshot = { content: string, ino: bigint, mtimeNs: bigint };
|
||||
|
||||
/**
|
||||
* Cross-platform lock file utility for Node.js.
|
||||
*/
|
||||
export class LockFile {
|
||||
/**
|
||||
* Content written to the lock file. The start time tells this process apart from an earlier one with
|
||||
* the same pid, and is the same for every copy of lib loaded in this process.
|
||||
*/
|
||||
private static readonly content = `${process.pid} ${performance.timeOrigin}\n`;
|
||||
// A lock file without a valid pid is left alone for this long, as it may still be being written
|
||||
private static readonly writeGraceMs = 10e3;
|
||||
|
||||
private handle: FileHandle | null = null;
|
||||
private state: LockState = "idle";
|
||||
|
||||
/**
|
||||
* @param filePath - Path of the lock file
|
||||
* @param handleSignals -
|
||||
* Release the lock on SIGINT, SIGTERM, SIGHUP and uncaught exceptions. Listening to these
|
||||
* changes how the process exits, so without it the lock is only removed on exit.
|
||||
*/
|
||||
constructor(
|
||||
private readonly filePath: string
|
||||
private readonly filePath: string,
|
||||
private readonly handleSignals = true,
|
||||
) {}
|
||||
|
||||
/** Content and identity of a lock file, read through one handle so both belong to the same file */
|
||||
private static async readLock(filePath: string): Promise<LockSnapshot | undefined> {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(filePath, "r");
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat({ bigint: true });
|
||||
return { content: await handle.readFile("utf8"), ino: stat.ino, mtimeNs: stat.mtimeNs };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static isSameLock(a: LockSnapshot, b: LockSnapshot) {
|
||||
return a.content === b.content && a.ino === b.ino && a.mtimeNs === b.mtimeNs;
|
||||
}
|
||||
|
||||
private static lockPid(lock: LockSnapshot) {
|
||||
const pid = Number(lock.content.split(" ")[0]);
|
||||
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
||||
}
|
||||
|
||||
/** True if the lock names a process that is still running, or may still be written to */
|
||||
private static lockIsLive(lock: LockSnapshot) {
|
||||
const pid = LockFile.lockPid(lock);
|
||||
if (pid === undefined) {
|
||||
return Date.now() - Number(lock.mtimeNs / 1000000n) < LockFile.writeGraceMs;
|
||||
}
|
||||
if (pid === process.pid) {
|
||||
// Held by this process, or left by an earlier process with the same pid
|
||||
return lock.content === LockFile.content;
|
||||
}
|
||||
try {
|
||||
// Signal 0 only checks that the process exists
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
return err.code !== "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the lock file.
|
||||
* Throws LockFileExistsError if already locked.
|
||||
* Throws LockFileBusyError if not in idle state.
|
||||
* Throws LockFileRecoveryError if clearing a stale lock was interrupted.
|
||||
*/
|
||||
async acquire(): Promise<void> {
|
||||
if (this.state !== "idle") {
|
||||
@@ -49,13 +125,10 @@ export class LockFile {
|
||||
|
||||
this.state = "acquiring";
|
||||
|
||||
if (await this.isStale()) {
|
||||
await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed
|
||||
}
|
||||
|
||||
try {
|
||||
this.handle = await open(this.filePath, "wx");
|
||||
await this.handle.write(`${process.pid}\n`);
|
||||
await this.removeStale();
|
||||
this.handle = await fs.open(this.filePath, "wx");
|
||||
await this.handle.write(LockFile.content);
|
||||
this.attachExitHandlers();
|
||||
this.state = "acquired";
|
||||
} catch (err: any) {
|
||||
@@ -81,7 +154,9 @@ export class LockFile {
|
||||
|
||||
// Ignored: file may have already been removed by another process
|
||||
try { await this.handle?.close(); } catch {}
|
||||
try { await unlink(this.filePath); } catch {}
|
||||
if (await this.isHeldFile()) {
|
||||
await fs.unlink(this.filePath).catch(() => {});
|
||||
}
|
||||
|
||||
this.handle = null;
|
||||
this.state = "idle";
|
||||
@@ -89,19 +164,20 @@ export class LockFile {
|
||||
|
||||
/**
|
||||
* Check whether the lock is currently held by this process.
|
||||
* True only if the lock is acquired and the PID in the file
|
||||
* matches the current process.
|
||||
* True only if the lock is acquired and the lock file was written by this process.
|
||||
*/
|
||||
async isHeld(): Promise<boolean> {
|
||||
if (this.state !== "acquired") {
|
||||
return false;
|
||||
}
|
||||
return await this.isHeldFile();
|
||||
}
|
||||
|
||||
private async isHeldFile() {
|
||||
try {
|
||||
const pidInLock = Number(await readFile(this.filePath, "utf8"));
|
||||
return pidInLock === process.pid;
|
||||
return (await LockFile.readLock(this.filePath))?.content === LockFile.content;
|
||||
} catch {
|
||||
// Ignored: file may not exist or be unreadable, so lock is not held
|
||||
// Ignored: file may be unreadable, so lock is not held
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -110,14 +186,45 @@ export class LockFile {
|
||||
* Determine whether an existing lock is stale.
|
||||
*/
|
||||
async isStale(): Promise<boolean> {
|
||||
const lock = await LockFile.readLock(this.filePath);
|
||||
return lock === undefined || !LockFile.lockIsLive(lock);
|
||||
}
|
||||
|
||||
/** Pid of the process holding the lock, if it has written it */
|
||||
async holderPid(): Promise<number | undefined> {
|
||||
const lock = await LockFile.readLock(this.filePath);
|
||||
return lock && LockFile.lockPid(lock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a stale lock file if it is still the same file. Clearing is done while holding a separate
|
||||
* recovery lock, so a lock another process took after the stale check is never removed.
|
||||
*/
|
||||
private async removeStale(): Promise<void> {
|
||||
const stale = await LockFile.readLock(this.filePath);
|
||||
if (!stale || LockFile.lockIsLive(stale)) {
|
||||
return;
|
||||
}
|
||||
const recoveryPath = `${this.filePath}.recovery`;
|
||||
try {
|
||||
const pidInLock = Number(await readFile(this.filePath, "utf8"));
|
||||
if (!Number.isFinite(pidInLock)) { return true; }
|
||||
// signal 0 is a special case to test if a process exists
|
||||
process.kill(pidInLock, 0);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
await fs.writeFile(recoveryPath, LockFile.content, { flag: "wx" });
|
||||
} catch (err: any) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
const holder = await LockFile.readLock(recoveryPath);
|
||||
if (holder && !LockFile.lockIsLive(holder)) {
|
||||
throw new LockFileRecoveryError(recoveryPath);
|
||||
}
|
||||
return; // Another process is clearing it
|
||||
}
|
||||
try {
|
||||
const current = await LockFile.readLock(this.filePath);
|
||||
if (current && LockFile.isSameLock(current, stale)) {
|
||||
await fs.unlink(this.filePath);
|
||||
}
|
||||
} finally {
|
||||
await fs.unlink(recoveryPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,10 +233,12 @@ export class LockFile {
|
||||
*/
|
||||
private attachExitHandlers(): void {
|
||||
assert(this.state === "acquiring");
|
||||
process.on("SIGINT", this.handleExit);
|
||||
process.on("SIGTERM", this.handleExit);
|
||||
process.on("SIGHUP", this.handleExit);
|
||||
process.on("uncaughtException", this.handleExit);
|
||||
if (this.handleSignals) {
|
||||
process.on("SIGINT", this.handleExit);
|
||||
process.on("SIGTERM", this.handleExit);
|
||||
process.on("SIGHUP", this.handleExit);
|
||||
process.on("uncaughtException", this.handleExit);
|
||||
}
|
||||
// eslint-disable-next-line node/no-sync
|
||||
process.on("exit", this.handleExitSync);
|
||||
}
|
||||
@@ -137,7 +246,7 @@ export class LockFile {
|
||||
/**
|
||||
* Detach process handlers, called when transitioning to "idle"
|
||||
*/
|
||||
private async detachExitHandlers(): Promise<void> {
|
||||
private detachExitHandlers(): void {
|
||||
assert(this.state === "releasing");
|
||||
process.off("SIGINT", this.handleExit);
|
||||
process.off("SIGTERM", this.handleExit);
|
||||
@@ -147,17 +256,21 @@ export class LockFile {
|
||||
process.off("exit", this.handleExitSync);
|
||||
}
|
||||
|
||||
// Event handler for async signals / exceptions
|
||||
// Event handler for async signals / exceptions
|
||||
private handleExit = async (): Promise<void> => {
|
||||
await this.release();
|
||||
};
|
||||
|
||||
// Event handler for synchronous process exit
|
||||
// Event handler for synchronous process exit, readLock can't be used as it is async
|
||||
private handleExitSync = (): void => {
|
||||
try {
|
||||
unlinkSync(this.filePath);
|
||||
// eslint-disable-next-line node/no-sync
|
||||
if (readFileSync(this.filePath, "utf8") === LockFile.content) {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
unlinkSync(this.filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignored: file may have already been removed by another process
|
||||
// Ignored: file may have already been removed by another process
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -14,4 +14,4 @@ export type PluginWebApi = {
|
||||
* package.
|
||||
*/
|
||||
npmPackage?: string;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// Node.js globals used by lib and its dependencies when running in a browser.
|
||||
import process from "process";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
// Vite's development server may already have created process.env
|
||||
if (typeof globalThis.process?.nextTick !== "function") {
|
||||
globalThis.process = Object.assign(process, globalThis.process);
|
||||
}
|
||||
globalThis.Buffer ??= Buffer as any;
|
||||
@@ -9,43 +9,12 @@ import * as libSchema from "../schema.js";
|
||||
import { StringEnum } from "../data/composites.js";
|
||||
import { safeOutputFile } from "../file_ops.js";
|
||||
import * as validators from "./validators.js";
|
||||
import { RequestError } from "../errors.js";
|
||||
import { InvalidAccessError, InvalidFieldError, InvalidValueError } from "../errors.js";
|
||||
import type { SyncHook } from "../SyncHook.js";
|
||||
|
||||
const ConfigLocation = StringEnum(["controller", "host", "control"]);
|
||||
export type ConfigLocation = Static<typeof ConfigLocation>;
|
||||
|
||||
/**
|
||||
* Invalid Access exception
|
||||
*
|
||||
* Exception class for when an access attempt is made to a field that
|
||||
* does not permitt this access is done.
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidAccess extends RequestError { };
|
||||
|
||||
/**
|
||||
* Invalid Value exception
|
||||
*
|
||||
* Exception class for when invalid values are attempted to be set on a
|
||||
* field.
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidValue extends RequestError { };
|
||||
|
||||
/**
|
||||
* Invalid Field exception
|
||||
*
|
||||
* Exception class for when a group or field that does not exist is
|
||||
* attempted to be accessed.
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidField extends RequestError { };
|
||||
|
||||
|
||||
const OldConfigGroupSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"fields": Type.Record(Type.String(), Type.Unknown()),
|
||||
@@ -55,7 +24,7 @@ type OldConfigGroupSchema = Static<typeof OldConfigGroupSchema>;
|
||||
const OldConfigSchema = Type.Object({
|
||||
"groups": Type.Array(OldConfigGroupSchema),
|
||||
});
|
||||
export type OldConfigSchema = Static<typeof OldConfigSchema>
|
||||
export type OldConfigSchema = Static<typeof OldConfigSchema>;
|
||||
|
||||
export const ConfigSchema = Type.Record(Type.String(), Type.Union([
|
||||
Type.Null(),
|
||||
@@ -69,11 +38,11 @@ export type ConfigSchema = Static<typeof ConfigSchema>;
|
||||
type FieldValue = null | boolean | string | number | object;
|
||||
|
||||
type FieldType<T extends FieldValue> =
|
||||
T extends boolean ? "boolean" :
|
||||
T extends string ? "string" :
|
||||
T extends number ? "number" :
|
||||
T extends object ? "object" :
|
||||
never;
|
||||
T extends boolean ? "boolean"
|
||||
: T extends string ? "string"
|
||||
: T extends number ? "number"
|
||||
: T extends object ? "object"
|
||||
: never;
|
||||
|
||||
export interface FieldDefinition<
|
||||
Type extends FieldValue = any,
|
||||
@@ -165,7 +134,7 @@ export type ConfigDefs<
|
||||
Fields extends Record<keyof Fields, FieldValue>
|
||||
> = {
|
||||
[Prop in keyof Fields]: FieldDefinition<Fields[Prop], Fields>;
|
||||
}
|
||||
};
|
||||
|
||||
function defaultValue(def: FieldDefinition) {
|
||||
if (def.initialValue === undefined) {
|
||||
@@ -179,7 +148,7 @@ function defaultValue(def: FieldDefinition) {
|
||||
|
||||
export type ConfigEvents = {
|
||||
fieldChanged: [ name: string, curr: FieldValue, prev: FieldValue ],
|
||||
}
|
||||
};
|
||||
|
||||
export enum ConfigAccess {
|
||||
read = 0x1,
|
||||
@@ -368,7 +337,7 @@ export class Config<
|
||||
* @param name - Name of the field being validated.
|
||||
* @param def - Definition describing the field type and constraints.
|
||||
* @param value - Value to validate and coerce.
|
||||
* @throws {InvalidValue} if the value cannot be coerced to the correct type
|
||||
* @throws {InvalidValueError} if the value cannot be coerced to the correct type
|
||||
* @returns The validated and coerced value.
|
||||
* @private
|
||||
*/
|
||||
@@ -380,7 +349,7 @@ export class Config<
|
||||
|
||||
// If def is an enum then it must be a value within it
|
||||
if (def.enum && !def.enum.includes(value)) {
|
||||
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
|
||||
throw new InvalidValueError(`Expected one of [${def.enum.join(", ")}], not ${value}`);
|
||||
}
|
||||
|
||||
// If it is a string, then attempt to coerce it
|
||||
@@ -402,7 +371,7 @@ export class Config<
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
|
||||
throw new InvalidValueError(`Error parsing value for ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,12 +379,12 @@ export class Config<
|
||||
// Check if it is allowed to be null
|
||||
if (value === null) {
|
||||
if (!def.optional) {
|
||||
throw new InvalidValue(`Field ${name} cannot be null`);
|
||||
throw new InvalidValueError(`Field ${name} cannot be null`);
|
||||
}
|
||||
|
||||
// Check if is the correct type
|
||||
} else if (basicType(value) !== def.type) {
|
||||
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
||||
throw new InvalidValueError(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
||||
}
|
||||
|
||||
return value as FieldType<T["type"]>;
|
||||
@@ -429,7 +398,7 @@ export class Config<
|
||||
* @param location - location to check access for.
|
||||
* @param mode - type of access to check for.
|
||||
* @param error - throw on failure to pass check.
|
||||
* @throws {InvalidAccess}
|
||||
* @throws {InvalidAccessError}
|
||||
* if access is not granted and error is true.
|
||||
* @returns true if access is granted
|
||||
* @private
|
||||
@@ -448,7 +417,7 @@ export class Config<
|
||||
)
|
||||
) {
|
||||
if (error) {
|
||||
throw new InvalidAccess(`Field '${name}' is not accessible from ${location}`);
|
||||
throw new InvalidAccessError(`Field '${name}' is not accessible from ${location}`);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -648,7 +617,7 @@ export class Config<
|
||||
}
|
||||
let def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
return this._checkAccess(name, def, location, mode, false);
|
||||
}
|
||||
@@ -663,7 +632,7 @@ export class Config<
|
||||
get<Field extends keyof Fields & string>(name: Field, remote = this.location) {
|
||||
let def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
// If remote access is requested require it to be able to read the field
|
||||
if (remote !== this.location) {
|
||||
@@ -675,7 +644,7 @@ export class Config<
|
||||
!this._checkAccess(name, def, this.location, ConfigAccess.write, false)
|
||||
&& !this._checkAccess(name, def, this.location, ConfigAccess.read, false)
|
||||
) {
|
||||
throw new InvalidAccess(`Field '${name}' is not accessible from ${this.location}`);
|
||||
throw new InvalidAccessError(`Field '${name}' is not accessible from ${this.location}`);
|
||||
}
|
||||
|
||||
return this.fields[name];
|
||||
@@ -690,13 +659,13 @@ export class Config<
|
||||
* @param name - Name of field to set.
|
||||
* @param newValue - Value to set for field.
|
||||
* @param remote - Location this field is being set from.
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if value is not allowed for the field.
|
||||
* @throws {InvalidFieldError} if field is not defined.
|
||||
* @throws {InvalidValueError} if value is not allowed for the field.
|
||||
*/
|
||||
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
@@ -714,7 +683,7 @@ export class Config<
|
||||
try {
|
||||
def.validator(value, this);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
|
||||
throw new InvalidValueError(`Failed validation of ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -732,7 +701,7 @@ export class Config<
|
||||
try {
|
||||
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
throw new InvalidValueError(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -753,8 +722,8 @@ export class Config<
|
||||
* @param prop - Name of property to set on field.
|
||||
* @param value - the value to set the property to.
|
||||
* @param remote - Location this property is being set from
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if field is not an object.
|
||||
* @throws {InvalidFieldError} if field is not defined.
|
||||
* @throws {InvalidValueError} if field is not an object.
|
||||
*/
|
||||
setProp<Field extends keyof Fields & string>(
|
||||
name: Field,
|
||||
@@ -764,7 +733,7 @@ export class Config<
|
||||
) {
|
||||
let def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
@@ -773,7 +742,7 @@ export class Config<
|
||||
}
|
||||
|
||||
if (def.type !== "object") {
|
||||
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
|
||||
throw new InvalidValueError(`Cannot set property on non-object field '${name}'`);
|
||||
}
|
||||
|
||||
let prev = this.fields[name] as Record<string, unknown>;
|
||||
@@ -793,7 +762,7 @@ export class Config<
|
||||
try {
|
||||
def.validator(updated, this);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
|
||||
throw new InvalidValueError(`Failed validation of ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -811,7 +780,7 @@ export class Config<
|
||||
try {
|
||||
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
throw new InvalidValueError(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -829,8 +798,8 @@ export class Config<
|
||||
* @param name - Name of field to set.
|
||||
* @param newValue - Value to set for field.
|
||||
* @param remote - Location this field is being set from.
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if value is not allowed for the field.
|
||||
* @throws {InvalidFieldError} if field is not defined.
|
||||
* @throws {InvalidValueError} if value is not allowed for the field.
|
||||
*/
|
||||
stage<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
||||
if (this._readonly) {
|
||||
@@ -839,7 +808,7 @@ export class Config<
|
||||
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
@@ -859,8 +828,8 @@ export class Config<
|
||||
* @param prop - Name of property to set on field.
|
||||
* @param value - the value to set the property to.
|
||||
* @param remote - Location this property is being set from
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if field is not an object.
|
||||
* @throws {InvalidFieldError} if field is not defined.
|
||||
* @throws {InvalidValueError} if field is not an object.
|
||||
*/
|
||||
stageProp<Field extends keyof Fields & string>(
|
||||
name: Field,
|
||||
@@ -874,7 +843,7 @@ export class Config<
|
||||
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
@@ -883,7 +852,7 @@ export class Config<
|
||||
}
|
||||
|
||||
if (def.type !== "object") {
|
||||
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
|
||||
throw new InvalidValueError(`Cannot set property on non-object field '${name}'`);
|
||||
}
|
||||
|
||||
const prev = this.staging.get(name) as Record<string, unknown>;
|
||||
@@ -901,7 +870,7 @@ export class Config<
|
||||
/**
|
||||
* Commit all changes in stating after they have been validated.
|
||||
*
|
||||
* @throws {InvalidValue} if a value is not allowed for its field.
|
||||
* @throws {InvalidValueError} if a value is not allowed for its field.
|
||||
*/
|
||||
commitStaging() {
|
||||
if (this._readonly) {
|
||||
@@ -923,7 +892,7 @@ export class Config<
|
||||
try {
|
||||
fieldDef.validator(config.fields[fieldName as keyof Fields], config);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of ${fieldName}: ${err.message}`);
|
||||
throw new InvalidValueError(`Failed validation of ${fieldName}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Core definitions for the configuration system
|
||||
import * as classes from "./classes.js";
|
||||
import * as validators from "./validators.js";
|
||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin.js";
|
||||
import type { PluginNodeEnvInfo, PluginWebEnvInfo } from "../plugin.js";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import { isTargetVersion } from "../data/version.js";
|
||||
import { SyncHook } from "../SyncHook.js";
|
||||
@@ -31,6 +31,9 @@ export interface ControllerConfigFields {
|
||||
"controller.max_subscription_filters": number;
|
||||
"controller.system_metrics_interval": number;
|
||||
"controller.proxy_stream_timeout": number;
|
||||
"controller.player_proxy_port": number | null;
|
||||
"controller.player_proxy_default_instance_id": number | null;
|
||||
"controller.player_proxy_public_address": string | null;
|
||||
"controller.factorio_username": string | null;
|
||||
"controller.factorio_token": string | null;
|
||||
"controller.share_factorio_credentials_with_hosts": boolean;
|
||||
@@ -151,7 +154,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
optional: true,
|
||||
validator: (value) => {
|
||||
if (value) {
|
||||
// This is unfortunately a limitiation of how the webpack build is set up right now.
|
||||
// This is unfortunately a limitiation of how the web build is set up right now.
|
||||
const pathname = new URL(value, "http://localhost/").pathname;
|
||||
if (pathname !== "/static/") {
|
||||
throw new Error(`Path component of static_url must be /static/ not ${pathname}.`);
|
||||
@@ -243,6 +246,34 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
initialValue: 15,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.player_proxy_port": {
|
||||
title: "Player Proxy Port",
|
||||
description:
|
||||
"UDP port the controller accepts Factorio player connections on and forwards them to an " +
|
||||
"instance, starting it if needed. Disabled when null.",
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
optional: true,
|
||||
initialValue: 34197,
|
||||
validator: validators.optional(validators.greaterThanZero),
|
||||
},
|
||||
"controller.player_proxy_default_instance_id": {
|
||||
title: "Player Proxy Default Instance",
|
||||
description:
|
||||
"Instance ID to send players connecting through the player proxy to when no plugin picks one " +
|
||||
"and they have not been on any instance before.",
|
||||
type: "number",
|
||||
optional: true,
|
||||
},
|
||||
"controller.player_proxy_public_address": {
|
||||
title: "Player Proxy Public Address",
|
||||
description:
|
||||
"Address players reach the player proxy on, for example play.example.com:34197. Used by plugins " +
|
||||
"to tell players where to connect. If null plugins may assume the host name of " +
|
||||
"controller.public_url and controller.player_proxy_port.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
},
|
||||
"controller.factorio_username": {
|
||||
title: "Factorio Username",
|
||||
description: "Username to authenticate with Factorio API with.",
|
||||
@@ -413,7 +444,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
"Range of UDP ports to use for game connections. Supports both comma separated values and " +
|
||||
"ranges separated with a dash.",
|
||||
type: "string",
|
||||
initialValue: "34100-34199",
|
||||
initialValue: "34200-34299",
|
||||
// TODO extendedValidation, port range
|
||||
},
|
||||
"host.factorio_username": {
|
||||
@@ -471,6 +502,7 @@ export interface InstanceConfigFields {
|
||||
"instance.id": number;
|
||||
"instance.assigned_host": number | null;
|
||||
"instance.auto_start": boolean;
|
||||
"instance.player_proxy_port": number | null;
|
||||
"instance.exclude_from_start_all": boolean;
|
||||
[key: `${string}.load_plugin`]: boolean;
|
||||
[key: `${string}.version`]: string;
|
||||
@@ -545,6 +577,14 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "boolean",
|
||||
initialValue: false,
|
||||
},
|
||||
"instance.player_proxy_port": {
|
||||
description:
|
||||
"Extra UDP port on the controller's player proxy that sends players straight to this instance, " +
|
||||
"starting it if needed. Requires controller.player_proxy_port to be set.",
|
||||
type: "number",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.greaterThanZero),
|
||||
},
|
||||
"instance.exclude_from_start_all": {
|
||||
description: "Exclude this instance from the 'Start all' button operation",
|
||||
type: "boolean",
|
||||
@@ -786,7 +826,7 @@ function validateFieldNames(
|
||||
* Add the config fields defined by the by a specific plugin infos
|
||||
*/
|
||||
export function addPluginFieldDefinitions(
|
||||
pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo,
|
||||
pluginInfo: PluginNodeEnvInfo | PluginWebEnvInfo,
|
||||
kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields",
|
||||
Config: typeof classes.Config<any>
|
||||
) {
|
||||
@@ -858,7 +898,7 @@ export function addPluginFieldDefinitions(
|
||||
*
|
||||
* @param {Array<Object>} pluginInfos - Array of plugin info objects.
|
||||
*/
|
||||
export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) {
|
||||
export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginWebEnvInfo[]) {
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
addPluginFieldDefinitions(pluginInfo, "controllerConfigFields", ControllerConfig);
|
||||
if (pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { JsonString, StringEnum, StringKey, plainJson } from "./composites.js";
|
||||
import { type LogEntry, levels } from "../logging.js";
|
||||
import { ControllerConfig, HostConfig } from "../config/index.js";
|
||||
import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external/index.js";
|
||||
import { PluginDetails } from "./messages_plugin.js";
|
||||
|
||||
const LogEntrySchema = Type.Object({
|
||||
"level": StringKey(levels),
|
||||
@@ -298,6 +299,8 @@ export class SystemInfo {
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs: number,
|
||||
public isDeleted: boolean,
|
||||
/** Plugins on the machine when last collected, kept while it is offline */
|
||||
public plugins?: PluginDetails[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -318,6 +321,7 @@ export class SystemInfo {
|
||||
"processStartedAtMs": Type.Number(),
|
||||
"updatedAtMs": Type.Number(),
|
||||
"isDeleted": Type.Boolean(),
|
||||
"plugins": Type.Optional(Type.Array(PluginDetails.jsonSchema)),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -339,6 +343,7 @@ export class SystemInfo {
|
||||
json.processStartedAtMs,
|
||||
json.updatedAtMs,
|
||||
json.isDeleted,
|
||||
json.plugins?.map(plugin => PluginDetails.fromJSON(plugin)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -435,7 +440,7 @@ export class DebugWsMessageEvent {
|
||||
export class FactorioVersionsRequest {
|
||||
declare ["constructor"]: typeof FactorioVersionsRequest;
|
||||
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 = "core.external.get_factorio_versions" as const;
|
||||
|
||||
@@ -457,7 +462,7 @@ export class FactorioVersionsRequest {
|
||||
export class LatestReleasesRequest {
|
||||
declare ["constructor"]: typeof LatestReleasesRequest;
|
||||
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 = "core.external.get_latest_releases" as const;
|
||||
|
||||
|
||||
@@ -36,6 +36,19 @@ export class HostUpdateRequest {
|
||||
static permission = "core.host.update" as const;
|
||||
}
|
||||
|
||||
/** Factorio versions installed on a host, used by the controller to place instances */
|
||||
export class HostFactorioVersionsRequest {
|
||||
declare ["constructor"]: typeof HostFactorioVersionsRequest;
|
||||
static type = "request" as const;
|
||||
static src = "controller" as const;
|
||||
static dst = "host" as const;
|
||||
static Response = plainJson(Type.Object({
|
||||
"versions": Type.Array(Type.String()),
|
||||
/** True if the host downloads versions that are not installed when an instance needs them */
|
||||
"canDownload": Type.Boolean(),
|
||||
}));
|
||||
}
|
||||
|
||||
export class HostConfigGetRequest {
|
||||
declare ["constructor"]: typeof HostConfigGetRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -251,7 +251,6 @@ export class ModPortalGetAllRequest {
|
||||
|
||||
// Define the Response class inline
|
||||
static Response = class ModPortalGetAllResponse {
|
||||
declare ["constructor"]: typeof ModPortalGetAllResponse;
|
||||
constructor(
|
||||
public mods: Static<typeof ModPortalDetailsSchema>[],
|
||||
) { }
|
||||
|
||||
@@ -14,6 +14,18 @@ export class PluginDetails {
|
||||
public enabled: boolean,
|
||||
public description?: string,
|
||||
public npmPackage?: string,
|
||||
/** Version installed on disk, differs from version when an update is waiting for a restart */
|
||||
public installedVersion?: string,
|
||||
/** Installed after the machine started, will be loaded on the next restart */
|
||||
public pendingInstall?: boolean,
|
||||
/** Uninstalled after the machine started, will not be loaded on the next restart */
|
||||
public removed?: boolean,
|
||||
/** Name in the root package.json dependencies, differs from npmPackage for aliases */
|
||||
public dependencyName?: string,
|
||||
/** Has no entrypoint for this machine, so it is never loaded here */
|
||||
public noEntrypoint?: boolean,
|
||||
/** Not loaded as another package provides a plugin with the same name */
|
||||
public nameTaken?: boolean,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -24,12 +36,19 @@ export class PluginDetails {
|
||||
enabled: Type.Boolean(),
|
||||
description: Type.Optional(Type.String()),
|
||||
npmPackage: Type.Optional(Type.String()),
|
||||
installedVersion: Type.Optional(Type.String()),
|
||||
pendingInstall: Type.Optional(Type.Boolean()),
|
||||
removed: Type.Optional(Type.Boolean()),
|
||||
dependencyName: Type.Optional(Type.String()),
|
||||
noEntrypoint: Type.Optional(Type.Boolean()),
|
||||
nameTaken: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.name, json.title, json.version, json.loaded, json.enabled,
|
||||
json.description, json.npmPackage
|
||||
json.description, json.npmPackage, json.installedVersion, json.pendingInstall, json.removed,
|
||||
json.dependencyName, json.noEntrypoint, json.nameTaken,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +58,15 @@ export class PluginDetails {
|
||||
pluginInfo.description, pluginInfo.npmPackage,
|
||||
);
|
||||
}
|
||||
|
||||
/** Plugin package installed since startup, it is loaded on the next restart */
|
||||
static fromPackageJson(packageName: string, packageJson: any, npmPackage?: string, dependencyName?: string) {
|
||||
const description = typeof packageJson.description === "string" ? packageJson.description : undefined;
|
||||
return new this(
|
||||
packageName, packageName, packageJson.version, false, false,
|
||||
description, npmPackage, packageJson.version, true, undefined, dependencyName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginListRequest {
|
||||
@@ -94,6 +122,114 @@ export class PluginInstallRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/* Plugin package listed on the npm registry */
|
||||
export class PluginSearchResult {
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: string,
|
||||
public description?: string,
|
||||
public publisher?: string,
|
||||
public date?: string,
|
||||
public homepage?: string,
|
||||
public repository?: string,
|
||||
public npm?: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
version: Type.String(),
|
||||
description: Type.Optional(Type.String()),
|
||||
publisher: Type.Optional(Type.String()),
|
||||
date: Type.Optional(Type.String()),
|
||||
homepage: Type.Optional(Type.String()),
|
||||
repository: Type.Optional(Type.String()),
|
||||
npm: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.name, json.version, json.description, json.publisher,
|
||||
json.date, json.homepage, json.repository, json.npm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginSearchRequest {
|
||||
declare ["constructor"]: typeof PluginSearchRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.plugin.search";
|
||||
|
||||
constructor(
|
||||
public query: string = "",
|
||||
public page: number = 1,
|
||||
public pageSize: number = 20,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
query: Type.String(),
|
||||
page: Type.Integer(),
|
||||
pageSize: Type.Integer(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.query, json.page, json.pageSize);
|
||||
}
|
||||
|
||||
static Response = class Response {
|
||||
constructor(
|
||||
public total: number,
|
||||
public results: PluginSearchResult[],
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
total: Type.Integer(),
|
||||
results: Type.Array(PluginSearchResult.jsonSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.total, json.results.map(r => PluginSearchResult.fromJSON(r)));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* Latest version of each package published on npm, used to offer plugin updates */
|
||||
export class PluginLatestVersionsRequest {
|
||||
declare ["constructor"]: typeof PluginLatestVersionsRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.plugin.list";
|
||||
|
||||
constructor(
|
||||
public packages: string[],
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
packages: Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.packages);
|
||||
}
|
||||
|
||||
static Response = class Response {
|
||||
constructor(
|
||||
/** Package name to its latest version, packages not found on npm are left out */
|
||||
public versions: Record<string, string>,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
versions: Type.Record(Type.String(), Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.versions);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class UpdateAllRequest {
|
||||
declare ["constructor"]: typeof UpdateAllRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
|
||||
import UserDetails, { IUser } from "./UserDetails.js";
|
||||
import { StringEnum, jsonArray, plainJson } from "./composites.js";
|
||||
import { MessageRequest } from "./messages_core.js";
|
||||
import { PermissionError } from "../errors.js";
|
||||
import { ValidationError } from "../errors.js";
|
||||
|
||||
export class UserGetRequest {
|
||||
declare ["constructor"]: typeof UserGetRequest;
|
||||
@@ -277,9 +277,9 @@ export class UserBulkImportRequest {
|
||||
user.checkPermission("core.user.set_whitelisted");
|
||||
break;
|
||||
default:
|
||||
// Unvalidated input during permission check: deny, don't throw a bare Error
|
||||
// Unvalidated input during permission check: reject, don't throw a bare Error
|
||||
// @ts-expect-error Unreachable
|
||||
throw new PermissionError(`Unknown import / restore type: ${data.importType}`);
|
||||
throw new ValidationError(`Unknown import / restore type: ${data.importType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,8 @@ export abstract class DatastoreProvider<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue,
|
||||
> {
|
||||
abstract save(data: Map<K, V>): Promise<void>
|
||||
abstract load(): Promise<Map<K, V>>
|
||||
|
||||
// Load data from the source and return it, this should be passed to the constructor of a datastore
|
||||
async bootstrap() {
|
||||
return [this, await this.load()] as const;
|
||||
}
|
||||
abstract save(data: Map<K, V>): Promise<void>;
|
||||
abstract load(): Promise<Map<K, V>>;
|
||||
}
|
||||
|
||||
// Store all data in memory, used as a default and should be avoided
|
||||
@@ -251,6 +246,11 @@ export class KeyValueDatastore<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue,
|
||||
> extends Datastore<K, V, [key: K, value: V, isDeleted?: boolean]> {
|
||||
// Create a datastore populated with the data loaded from the provider
|
||||
static async fromProvider<K extends DatastoreKey, V extends DatastoreValue>(provider: DatastoreProvider<K, V>) {
|
||||
return new this(provider, await provider.load());
|
||||
}
|
||||
|
||||
// Set the value in the datastore, be careful of race conditions if you await any functions before calling set
|
||||
set(key: K, value: V) {
|
||||
this.data.set(key, value);
|
||||
@@ -316,6 +316,11 @@ export class SubscribableDatastore<
|
||||
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
|
||||
}
|
||||
|
||||
// Create a datastore populated with the data loaded from the provider
|
||||
static async fromProvider<V extends SubscribableValue>(provider: DatastoreProvider<V["id"], V>) {
|
||||
return new this(provider, await provider.load());
|
||||
}
|
||||
|
||||
async load() {
|
||||
await super.load();
|
||||
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
|
||||
|
||||
+67
-25
@@ -18,40 +18,82 @@ export class InstallationError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown from requests sent when an error occured handling it
|
||||
* Base class for errors caused by the request, such as invalid input,
|
||||
* missing permissions or a missing resource. Corresponds to the HTTP 4xx
|
||||
* range.
|
||||
*
|
||||
* Link does not log these as unexpected when a request handler throws them.
|
||||
*/
|
||||
export class RequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code = "RequestError",
|
||||
public stack?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export class ClientError extends Error { }
|
||||
|
||||
/**
|
||||
* Thrown when a permission check fails.
|
||||
*
|
||||
* Is a subclass of RequestError to prevent logging stack traces when
|
||||
* requests fail due to permission denied.
|
||||
*
|
||||
* Thrown when the request contains invalid data (HTTP 400)
|
||||
*/
|
||||
export class PermissionError extends RequestError {
|
||||
constructor(
|
||||
message: string,
|
||||
code = "PermissionError",
|
||||
stack?: string,
|
||||
) {
|
||||
super(message, code, stack);
|
||||
}
|
||||
export class ValidationError extends ClientError { }
|
||||
|
||||
}
|
||||
/**
|
||||
* Thrown when a config value cannot be used for its field
|
||||
*/
|
||||
export class InvalidValueError extends ValidationError { }
|
||||
|
||||
/**
|
||||
* Thrown when a config field does not exist
|
||||
*/
|
||||
export class InvalidFieldError extends ValidationError { }
|
||||
|
||||
/**
|
||||
* Thrown when the request is not allowed (HTTP 403)
|
||||
*/
|
||||
export class ForbiddenError extends ClientError { }
|
||||
|
||||
/**
|
||||
* Thrown when a permission check fails
|
||||
*/
|
||||
export class PermissionError extends ForbiddenError { }
|
||||
|
||||
/**
|
||||
* Thrown when a config field is not accessible from the current location
|
||||
*/
|
||||
export class InvalidAccessError extends ForbiddenError { }
|
||||
|
||||
/**
|
||||
* Thrown when a resource the request refers to does not exist (HTTP 404)
|
||||
*/
|
||||
export class NotFoundError extends ClientError { }
|
||||
|
||||
/**
|
||||
* Thrown when an operation for a request timed out (HTTP 408)
|
||||
*/
|
||||
export class TimeoutError extends ClientError { }
|
||||
|
||||
/**
|
||||
* Thrown when the request conflicts with the current state, such as an
|
||||
* instance that is running or a name that is taken (HTTP 409)
|
||||
*/
|
||||
export class ConflictError extends ClientError { }
|
||||
|
||||
/**
|
||||
* Base class for errors caused by the side handling the request.
|
||||
* Corresponds to the HTTP 5xx range.
|
||||
*/
|
||||
export class ServerError extends Error { }
|
||||
|
||||
/**
|
||||
* Received in place of an error response whose class is not registered,
|
||||
* such as a TypeError thrown by a request handler (HTTP 500)
|
||||
*/
|
||||
export class InternalServerError extends ServerError { }
|
||||
|
||||
/**
|
||||
* Thrown when the host or instance that should handle the request is not
|
||||
* connected, not running or shutting down (HTTP 503)
|
||||
*/
|
||||
export class UnavailableError extends ServerError { }
|
||||
|
||||
/**
|
||||
* Signal for messages that fail validation
|
||||
*/
|
||||
export class InvalidMessage extends Error {
|
||||
export class InvalidMessage extends ValidationError {
|
||||
code = "InvalidMessage";
|
||||
constructor(
|
||||
message: string,
|
||||
|
||||
@@ -35,7 +35,7 @@ export type FactorioModSettingType = "startup" | "runtime-global" | "runtime-per
|
||||
* Boolean mod setting prototype.
|
||||
* @see [bool-setting](https://wiki.factorio.com/Tutorial:Mod_settings#bool-setting)
|
||||
*/
|
||||
export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
|
||||
export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase {
|
||||
type: "bool-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: boolean,
|
||||
@@ -46,7 +46,7 @@ export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
|
||||
* Integer mod setting prototype.
|
||||
* @see [int-setting](https://wiki.factorio.com/Tutorial:Mod_settings#int-setting)
|
||||
*/
|
||||
export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
|
||||
export interface FactorioIntSettingPrototype extends FactorioPrototypeBase {
|
||||
type: "int-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: number,
|
||||
@@ -59,7 +59,7 @@ export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
|
||||
* Real number mod setting prototype.
|
||||
* @see [double-setting](https://wiki.factorio.com/Tutorial:Mod_settings#double-setting)
|
||||
*/
|
||||
export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
|
||||
export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase {
|
||||
type: "double-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: number,
|
||||
@@ -72,7 +72,7 @@ export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
|
||||
* String mod setting prototype.
|
||||
* @see [string-setting](https://wiki.factorio.com/Tutorial:Mod_settings#string-setting)
|
||||
*/
|
||||
export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
|
||||
export interface FactorioStringSettingPrototype extends FactorioPrototypeBase {
|
||||
type: "string-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: string,
|
||||
@@ -85,7 +85,7 @@ export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
|
||||
* Color mod setting prototype.
|
||||
* @see [color-setting](https://wiki.factorio.com/Tutorial:Mod_settings#color-setting)
|
||||
*/
|
||||
export interface FactorioColorSettingPrototype extends FactorioPrototypeBase{
|
||||
export interface FactorioColorSettingPrototype extends FactorioPrototypeBase {
|
||||
type: "color-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: FactorioColor,
|
||||
@@ -104,7 +104,7 @@ export type FactorioModSettingPrototype =
|
||||
* A list of prototypes as it appears in Factorio's Lua API.
|
||||
* @see [Data.raw](https://lua-api.factorio.com/stable/types/Data.html#raw)
|
||||
*/
|
||||
export type FactorioPrototypes<Prototype = FactorioPrototypeBase> = Record<string, Record<string, Prototype>>
|
||||
export type FactorioPrototypes<Prototype = FactorioPrototypeBase> = Record<string, Record<string, Prototype>>;
|
||||
export type FactorioDefinesValue = number | string | { [index: string]: FactorioDefinesValue };
|
||||
export type FactorioDefines = {
|
||||
[index: string]: FactorioDefinesValue,
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ export const ExternalFactorioVersionSchema = Type.Object({
|
||||
headlessUrl: Type.String(),
|
||||
});
|
||||
|
||||
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
|
||||
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>;
|
||||
|
||||
/**
|
||||
* Fetch all factorio versions that support headless
|
||||
@@ -47,7 +47,7 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
|
||||
const versions = new Map<string, ExternalFactorioVersion>();
|
||||
|
||||
// Match <a class=" slot-button-inline version-button-stable " href="/download/archive/2.0.72">2.0.72</a>
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @stylistic/max-len
|
||||
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+.\d+)"[^>]*>/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export const LatestReleaseBuildsSchema = Type.Record(Type.String(), FullVersionS
|
||||
*/
|
||||
export const LatestReleasesSchema = Type.Record(Type.String(), LatestReleaseBuildsSchema);
|
||||
|
||||
export type LatestReleases = Static<typeof LatestReleasesSchema>
|
||||
export type LatestReleases = Static<typeof LatestReleasesSchema>;
|
||||
|
||||
/**
|
||||
* Fetch the latest stable and experimental factorio releases
|
||||
|
||||
@@ -153,7 +153,7 @@ function readMapPosition(state: MapReaderState) {
|
||||
y = state.last_position.y + y_diff;
|
||||
}
|
||||
state.last_position.x = x;
|
||||
state.last_position.x = y;
|
||||
state.last_position.y = y;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
|
||||
@@ -70,48 +70,7 @@ export async function directorySize(directory: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies name in case it already exisist at the given directory
|
||||
*
|
||||
* Checks the directory passed if it already contains a file or folder with
|
||||
* the given name, if it does not returns name unmodified, otherwise it
|
||||
* returns a modified name that does not exist in the directory.
|
||||
*
|
||||
* Warning: this function should not be relied upon to be accurate for
|
||||
* security sensitive applications. The selection process is inherently
|
||||
* racy and a file or folder may have been created in the folder by the time
|
||||
* this function returns.
|
||||
*
|
||||
* @param directory - directory to check in.
|
||||
* @param name - file name to check for, may have extension.
|
||||
* @param extension - dot extension used for the file name.
|
||||
* @returns modified name with extension that likely does
|
||||
* not exist in the folder
|
||||
*/
|
||||
export async function findUnusedName(directory: string, name: string, extension = "") {
|
||||
if (extension && name.endsWith(extension)) {
|
||||
name = name.slice(0, -extension.length);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await fs.access(path.join(directory, `${name}${extension}`));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return `${name}${extension}`;
|
||||
}
|
||||
}
|
||||
|
||||
let match = /^(.*?)(-(\d+))?$/.exec(name)!;
|
||||
if (!match[2]) {
|
||||
name = `${match[1]}-2`;
|
||||
} else {
|
||||
name = `${match[1]}-${Number.parseInt(match[3], 10) + 1}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate collision resistante temporary file name
|
||||
* Generate collision resistant temporary file name
|
||||
*
|
||||
* Warning: this function should not be relied upon to be accurate for
|
||||
* security sensitive applications. The selection process is inherently
|
||||
@@ -125,8 +84,7 @@ export async function findUnusedName(directory: string, name: string, extension
|
||||
*/
|
||||
export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
|
||||
let fileName = path.join(prefix + crypto.randomBytes(16).toString("hex") + suffix);
|
||||
let freeFile = await findUnusedName(tmpdir, fileName);
|
||||
let fullPath = path.join(tmpdir, freeFile);
|
||||
let fullPath = path.join(tmpdir, fileName);
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -321,6 +321,57 @@ export function redactUrl(url: URL | string) {
|
||||
return isPath ? parsed.pathname + parsed.search + parsed.hash : parsed.toString();
|
||||
}
|
||||
|
||||
const gitProtocols = new Set(["git:", "git+http:", "git+https:", "git+ssh:", "ssh:"]);
|
||||
const repositoryShorthands = new Map([
|
||||
["github:", "https://github.com/"],
|
||||
["gitlab:", "https://gitlab.com/"],
|
||||
["bitbucket:", "https://bitbucket.org/"],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Convert a link from untrusted metadata, like a package's homepage or repository, into a URL
|
||||
* that is safe to use as the href of a link
|
||||
*
|
||||
* Only http and https URLs are returned, so links like `javascript:` can't run code when
|
||||
* clicked. Accepts the formats npm uses for repositories: a string or an object with a `url`
|
||||
* property, `git+https://`, `git://` and `git+ssh://` URLs, `git@host:path` and shorthands
|
||||
* like `github:owner/repo`, which are converted to https URLs. Credentials are removed.
|
||||
*
|
||||
* @param link - URL string or object with a `url` property.
|
||||
* @returns http(s) URL, or undefined if the link can't be converted to one.
|
||||
*/
|
||||
export function safeExternalUrl(link: unknown): string | undefined {
|
||||
const value = typeof link === "object" && link !== null ? (link as { url?: unknown }).url : link;
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
let url = value.trim();
|
||||
const scpLike = /^[\w.-]+@([\w.-]+):(?!\/)(.+)$/.exec(url);
|
||||
if (scpLike) {
|
||||
url = `ssh://${scpLike[1]}/${scpLike[2]}`;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
const shorthand = repositoryShorthands.get(parsed.protocol);
|
||||
if (shorthand) {
|
||||
parsed = new URL(url.slice(parsed.protocol.length).replace(/^\/+/, ""), shorthand);
|
||||
} else if (gitProtocols.has(parsed.protocol)) {
|
||||
// The protocol setter can't change a URL into an https URL, so parse it again
|
||||
parsed = new URL(`https://${parsed.host}${parsed.pathname.replace(/\.git$/, "")}`);
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy of parsed query parameters with credentials replaced by `REDACTED`
|
||||
*
|
||||
|
||||
+102
-27
@@ -3,10 +3,10 @@ import * as libData from "../data/index.js";
|
||||
import * as libErrors from "../errors.js";
|
||||
import { logger } from "../logging.js";
|
||||
import * as libSchema from "../schema.js";
|
||||
import { dataClasses } from "./messages.js";
|
||||
import { dataClasses, errorClasses } from "./messages.js";
|
||||
import { BaseConnector, WebSocketBaseConnector } from "./connectors.js";
|
||||
import { strict as assert } from "assert";
|
||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin.js";
|
||||
import type { PluginNodeEnvInfo, PluginWebEnvInfo } from "../plugin.js";
|
||||
import type {
|
||||
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IUser,
|
||||
} from "../data/index.js";
|
||||
@@ -38,6 +38,12 @@ export interface Event<T> {
|
||||
}
|
||||
export type EventClass<T> = Event<T>["constructor"];
|
||||
|
||||
export interface ErrorClass {
|
||||
new (message: string): Error;
|
||||
name: string;
|
||||
plugin?: string;
|
||||
}
|
||||
|
||||
export interface RequestEntry {
|
||||
Request: RequestClass<unknown, unknown>;
|
||||
name: string,
|
||||
@@ -156,7 +162,7 @@ export class Link {
|
||||
return this._pendingRequests.size + this._forwardedRequests.size;
|
||||
}
|
||||
|
||||
_clearPendingRequests(err: Error & { code?: string }) {
|
||||
_clearPendingRequests(err: Error) {
|
||||
for (let pending of this._pendingRequests.values()) {
|
||||
pending.reject(err);
|
||||
}
|
||||
@@ -170,9 +176,7 @@ export class Link {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
pending.origin.connector.sendResponseError(
|
||||
new libData.ResponseError(err.message, err.code), pending.src, pending.dst
|
||||
);
|
||||
pending.origin.connector.sendResponseError(Link.errorToResponse(err, false), pending.src, pending.dst);
|
||||
}
|
||||
this._pendingRequests.clear();
|
||||
this._forwardedRequests.clear();
|
||||
@@ -200,10 +204,10 @@ export class Link {
|
||||
try {
|
||||
this.validatePermission(permissionMessage, entry); // Somewhat hacky, defined in ControlConnection
|
||||
} catch (err) {
|
||||
// A non-PermissionError means the permission check itself is
|
||||
// A non-ClientError means the permission check itself is
|
||||
// broken. validatePermission (control connections only) already
|
||||
// sent a response error, so log and drop instead of crashing.
|
||||
if (!(err instanceof libErrors.PermissionError)) {
|
||||
if (!(err instanceof libErrors.ClientError)) {
|
||||
logger.error(
|
||||
`Unexpected error checking permission for ${permissionMessage.name}:\n` +
|
||||
`${(err as Error).stack ?? (err as Error).message}`
|
||||
@@ -280,7 +284,7 @@ export class Link {
|
||||
this.validateIngress(message);
|
||||
} catch (err: any) {
|
||||
if (message.type === "request") {
|
||||
this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src);
|
||||
this.connector.sendResponseError(Link.errorToResponse(err, false), message.src);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -290,17 +294,17 @@ export class Link {
|
||||
let entry = Link._requestsByName.get(message.name);
|
||||
if (!entry) {
|
||||
let err = new libErrors.InvalidMessage(`Unrecognized request ${message.name}`);
|
||||
this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src);
|
||||
this.connector.sendResponseError(Link.errorToResponse(err, false), message.src);
|
||||
throw err;
|
||||
}
|
||||
if (!entry.allowedSrcTypes.has(message.src.type)) {
|
||||
let err = new libErrors.InvalidMessage(`Source ${message.src} is not allowed for ${message.name}`);
|
||||
this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src);
|
||||
this.connector.sendResponseError(Link.errorToResponse(err, false), message.src);
|
||||
throw err;
|
||||
}
|
||||
if (!entry.allowedDstTypes.has(message.dst.type)) {
|
||||
let err = new libErrors.InvalidMessage(`Destination ${message.dst} is not allowed for ${message.name}`);
|
||||
this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src);
|
||||
this.connector.sendResponseError(Link.errorToResponse(err, false), message.src);
|
||||
throw err;
|
||||
}
|
||||
return entry;
|
||||
@@ -356,7 +360,7 @@ export class Link {
|
||||
);
|
||||
if (message.type === "request") {
|
||||
this.connector.sendResponseError(
|
||||
new libData.ResponseError(err.message, err.code), (message as libData.MessageRequest).src
|
||||
Link.errorToResponse(err, false), (message as libData.MessageRequest).src
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
@@ -399,13 +403,7 @@ export class Link {
|
||||
try {
|
||||
response = handler(entry.requestFromJSON(message.data), message.src, message.dst);
|
||||
} catch (err: any) {
|
||||
if (err.errors) {
|
||||
logger.error(JSON.stringify(err.errors, null, "\t"));
|
||||
}
|
||||
this.connector.sendResponseError(
|
||||
new libData.ResponseError(err.message, err.code, err.stack), message.src
|
||||
);
|
||||
return;
|
||||
response = Promise.reject(err);
|
||||
}
|
||||
|
||||
if (!(response instanceof Promise)) {
|
||||
@@ -424,19 +422,17 @@ export class Link {
|
||||
this.connector.sendResponse(result, message.src, spoofedSrc);
|
||||
}
|
||||
).catch(
|
||||
(err: Error & { code?: string }) => {
|
||||
(err: Error) => {
|
||||
if (err instanceof libErrors.InvalidMessage) {
|
||||
logger.error(err.message);
|
||||
if (err.errors) {
|
||||
logger.error(JSON.stringify(err.errors, null, "\t"));
|
||||
}
|
||||
} else if (!(err instanceof libErrors.RequestError)) {
|
||||
} else if (Link.errorName(err) === undefined) {
|
||||
logger.error(`Unexpected error responding to ${message.name}:\n${err.stack}`);
|
||||
}
|
||||
try {
|
||||
this.connector.sendResponseError(
|
||||
new libData.ResponseError(err.message, err.code, err.stack), message.src, spoofedSrc
|
||||
);
|
||||
this.connector.sendResponseError(Link.errorToResponse(err), message.src, spoofedSrc);
|
||||
} catch (subErr: any) {
|
||||
logger.error(`Unexpected error sending error response for ${message.name}:\n${subErr.stack}`);
|
||||
}
|
||||
@@ -474,7 +470,7 @@ export class Link {
|
||||
}
|
||||
|
||||
this._pendingRequests.delete(message.dst.requestId!);
|
||||
pending.reject(new libErrors.RequestError(message.data.message, message.data.code, message.data.stack));
|
||||
pending.reject(Link.errorFromResponse(message.data));
|
||||
}
|
||||
|
||||
_processEvent(message: libData.MessageEvent, entry: EventEntry) {
|
||||
@@ -823,13 +819,83 @@ export class Link {
|
||||
this._eventsByName.set(name, entry);
|
||||
this._eventsByClass.set(Event as EventClass<unknown>, entry);
|
||||
}
|
||||
|
||||
static _errorsByName = new Map<string, ErrorClass>();
|
||||
static _errorNamesByClass = new Map<ErrorClass, string>();
|
||||
|
||||
/**
|
||||
* Register an error class to be sent and received over links
|
||||
*
|
||||
* Error responses carry the registered name of the thrown error and the
|
||||
* receiving side rejects the request with an instance of the same class.
|
||||
*
|
||||
* @param Class - Error class, must take the message as the first
|
||||
* constructor argument.
|
||||
*/
|
||||
static registerError(Class: ErrorClass) {
|
||||
const name = Class.plugin ? `${Class.plugin}:${Class.name}` : Class.name;
|
||||
if (this._errorsByName.has(name)) {
|
||||
throw new Error(`Error ${name} is already registered`);
|
||||
}
|
||||
this._errorsByName.set(name, Class);
|
||||
this._errorNamesByClass.set(Class, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered name of the closest registered class of an error
|
||||
*
|
||||
* @param err - Error to look up.
|
||||
* @returns name or undefined if no class in its prototype chain is registered.
|
||||
*/
|
||||
static errorName(err: Error) {
|
||||
for (let proto = Object.getPrototypeOf(err); proto; proto = Object.getPrototypeOf(proto)) {
|
||||
const name = this._errorNamesByClass.get(proto.constructor);
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an error into the data of an error response
|
||||
*
|
||||
* Errors with no registered class are sent as InternalServerError.
|
||||
*
|
||||
* @param err - Error to convert.
|
||||
* @param withStack - Include the stack trace of the error.
|
||||
*/
|
||||
static errorToResponse(err: Error, withStack = true) {
|
||||
return new libData.ResponseError(
|
||||
err.message, this.errorName(err) ?? "InternalServerError", withStack ? err.stack : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the data of an error response back into an error
|
||||
*
|
||||
* @param response - Received error response data.
|
||||
* @returns instance of the registered class, or InternalServerError if
|
||||
* the class is not registered on this side.
|
||||
*/
|
||||
static errorFromResponse(response: libData.ResponseError) {
|
||||
const Class = response.code && this._errorsByName.get(response.code) || libErrors.InternalServerError;
|
||||
const err = new Class(response.message);
|
||||
if (response.stack !== undefined) {
|
||||
err.stack = response.stack;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
for (let Class of dataClasses) {
|
||||
Link.register(Class as RequestClass<unknown, unknown>);
|
||||
}
|
||||
for (let Class of errorClasses) {
|
||||
Link.registerError(Class);
|
||||
}
|
||||
|
||||
export function registerPluginMessages(pluginInfos: PluginNodeEnvInfo[]|PluginWebpackEnvInfo[]) {
|
||||
export function registerPluginMessages(pluginInfos: PluginNodeEnvInfo[]|PluginWebEnvInfo[]) {
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
for (let Class of pluginInfo.messages || []) {
|
||||
if (Class.plugin !== pluginInfo.name) {
|
||||
@@ -840,5 +906,14 @@ export function registerPluginMessages(pluginInfos: PluginNodeEnvInfo[]|PluginWe
|
||||
}
|
||||
Link.register(Class);
|
||||
}
|
||||
for (let Class of pluginInfo.errors || []) {
|
||||
if (Class.plugin !== pluginInfo.name) {
|
||||
throw new Error(
|
||||
`Expected error ${Class.name} from ${pluginInfo.name} to have a static ` +
|
||||
`plugin property set to "${pluginInfo.name}"`
|
||||
);
|
||||
}
|
||||
Link.registerError(Class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import * as role from "../data/messages_role.js";
|
||||
import * as note from "../data/messages_note.js";
|
||||
import * as plugin from "../data/messages_plugin.js";
|
||||
import * as subscriptions from "../subscriptions.js";
|
||||
import type { RequestClass, EventClass } from "./link.js";
|
||||
import * as errors from "../errors.js";
|
||||
import type { RequestClass, EventClass, ErrorClass } from "./link.js";
|
||||
|
||||
/**
|
||||
* Event and Request payloads registered with the Link class. Used to decode
|
||||
@@ -55,6 +56,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
host.HostInfoUpdateEvent,
|
||||
host.HostUpdatesEvent,
|
||||
host.HostMetricsRequest,
|
||||
host.HostFactorioVersionsRequest,
|
||||
host.ControllerConnectionEvent,
|
||||
host.PrepareControllerDisconnectRequest,
|
||||
host.SyncUserListsEvent,
|
||||
@@ -147,5 +149,30 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
plugin.PluginListRequest,
|
||||
plugin.PluginUpdateRequest,
|
||||
plugin.PluginInstallRequest,
|
||||
plugin.PluginSearchRequest,
|
||||
plugin.PluginLatestVersionsRequest,
|
||||
plugin.UpdateAllRequest,
|
||||
];
|
||||
|
||||
/**
|
||||
* Error classes registered with the Link class. Used to send errors thrown
|
||||
* by request handlers back as instances of the same class.
|
||||
* @internal
|
||||
*/
|
||||
export const errorClasses: ErrorClass[] = [
|
||||
errors.ClientError,
|
||||
errors.ValidationError,
|
||||
errors.InvalidValueError,
|
||||
errors.InvalidFieldError,
|
||||
errors.ForbiddenError,
|
||||
errors.PermissionError,
|
||||
errors.InvalidAccessError,
|
||||
errors.NotFoundError,
|
||||
errors.TimeoutError,
|
||||
errors.ConflictError,
|
||||
errors.ServerError,
|
||||
errors.InternalServerError,
|
||||
errors.UnavailableError,
|
||||
errors.InvalidMessage,
|
||||
errors.SessionLost,
|
||||
];
|
||||
@@ -5,6 +5,96 @@ import { logger } from "./logging.js";
|
||||
import * as libFileOps from "./file_ops.js";
|
||||
import vm from "vm";
|
||||
|
||||
const corePackages = new Set([
|
||||
"@clusterio/controller",
|
||||
"@clusterio/ctl",
|
||||
"@clusterio/host",
|
||||
"@clusterio/lib",
|
||||
"@clusterio/web_ui",
|
||||
]);
|
||||
|
||||
function isPluginPackageJson(packageJson: any): boolean {
|
||||
return Boolean(packageJson?.keywords?.includes?.("clusterio-plugin"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the package.json in a package directory.
|
||||
* @param packagePath - Directory of the package
|
||||
* @param tolerant - Return undefined instead of throwing if the file can't be parsed, as npm may be writing it
|
||||
* @returns The parsed package.json, or undefined if it does not exist
|
||||
*/
|
||||
export async function readPackageJson(packagePath: string, tolerant: boolean): Promise<any> {
|
||||
let content;
|
||||
try {
|
||||
content = await fs.readFile(path.join(packagePath, "package.json"), "utf8");
|
||||
} catch (err: any) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (err: any) {
|
||||
if (tolerant) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Dependencies npm installs, an optionalDependencies entry wins over a dependencies entry of the same name */
|
||||
export function installedDependencies(packageJson: any): Record<string, unknown> {
|
||||
return { ...packageJson?.dependencies, ...packageJson?.optionalDependencies };
|
||||
}
|
||||
|
||||
/** Whether a dependency spec installs from the registry, as opposed to a path, git, url, tarball or alias */
|
||||
export function isRegistrySpec(spec: unknown) {
|
||||
return typeof spec === "string" && !/[:/\\]/.test(spec) && !spec.startsWith(".")
|
||||
&& !/\.(?:tgz|tar\.gz|tar)$/i.test(spec);
|
||||
}
|
||||
|
||||
/** Real path of a directory, used to compare packages as npm and pnpm install them behind symlinks */
|
||||
export async function realDir(dir: string) {
|
||||
try {
|
||||
return await fs.realpath(dir);
|
||||
} catch {
|
||||
// Removed since, nothing else can be at this path
|
||||
return path.resolve(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/** Directory of a package as require resolves it from fromPath, or undefined if not found */
|
||||
export function resolvePackagePath(packageName: string, fromPath: string, tolerant: boolean): string | undefined {
|
||||
const packageRequire = createRequire(path.join(fromPath, "package.json"));
|
||||
try {
|
||||
return path.dirname(packageRequire.resolve(`${packageName}/package.json`));
|
||||
} catch (err: any) {
|
||||
// A package.json npm is still writing fails with ERR_INVALID_PACKAGE_CONFIG
|
||||
if (tolerant || err.code === "MODULE_NOT_FOUND" || err.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function pluginPathExists(pluginPath: string) {
|
||||
if (!path.isAbsolute(pluginPath)) {
|
||||
return resolvePackagePath(pluginPath, process.cwd(), true) !== undefined;
|
||||
}
|
||||
try {
|
||||
await fs.access(path.join(pluginPath, "package.json"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
function getPluginName(requireSpec: string) {
|
||||
const context = vm.createContext({ require: require, pluginInfo: null });
|
||||
const code = `pluginInfo = require(${JSON.stringify(requireSpec)}).plugin;`;
|
||||
vm.runInContext(code, context);
|
||||
return context.pluginInfo.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for local plugins in the plugins/ and external_plugins/ directories
|
||||
* and adds them to the plugin list if they contain a package.json file.
|
||||
@@ -49,7 +139,7 @@ async function findLocalPlugins(pluginList: Map<string, string>): Promise<boolea
|
||||
const stats = await fs.stat(pluginPath);
|
||||
if (stats.isDirectory()) {
|
||||
// Check for package.json and plugin keyword
|
||||
if (await checkPackageJson(pluginPath)) {
|
||||
if (isPluginPackageJson(await readPackageJson(pluginPath, false))) {
|
||||
pluginList.set(pluginName, path.resolve(pluginPath));
|
||||
logger.info(`Added ${pluginName} from ${pluginFolder}`);
|
||||
hasChanged += 1;
|
||||
@@ -67,24 +157,85 @@ async function findLocalPlugins(pluginList: Map<string, string>): Promise<boolea
|
||||
return hasChanged > 0;
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
function getPluginName(requireSpec: string) {
|
||||
const context = vm.createContext({ require: require, pluginInfo: null });
|
||||
const code = `pluginInfo = require(${JSON.stringify(requireSpec)}).plugin;`;
|
||||
vm.runInContext(code, context);
|
||||
return context.pluginInfo.name;
|
||||
type NpmPluginPackage = {
|
||||
packageName: string,
|
||||
packageVersion: unknown,
|
||||
packagePath: string,
|
||||
packageJson: any,
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk the dependencies of the root package.json and of every plugin package found, without
|
||||
* traversing packages that aren't plugins. Does not execute any plugin code.
|
||||
*
|
||||
* @param dependencies - Dependencies of the root package.json
|
||||
* @param tolerant - Skip packages with a package.json that can't be parsed instead of throwing,
|
||||
* for use while npm may be rewriting them
|
||||
* @returns Plugin packages found, and root dependencies that are not plugins
|
||||
*/
|
||||
export async function discoverNpmPluginPackages(dependencies: Record<string, unknown>, tolerant = false) {
|
||||
const plugins: NpmPluginPackage[] = [];
|
||||
const nonPluginRoots: { packageName: string, packageVersion: unknown }[] = [];
|
||||
const pendingPackages = Object.entries(dependencies).map(([packageName, packageVersion]) => ({
|
||||
packageName,
|
||||
packageVersion,
|
||||
parentPath: process.cwd(),
|
||||
isRootDependency: true,
|
||||
}));
|
||||
const visitedPackages = new Set<string>();
|
||||
// Directories some dependent installs from a path, git, url, tarball or alias
|
||||
const nonRegistryDirs = new Set<string>();
|
||||
|
||||
while (pendingPackages.length) {
|
||||
const { packageName, packageVersion, parentPath, isRootDependency } = pendingPackages.shift()!;
|
||||
const packagePath = resolvePackagePath(packageName, parentPath, tolerant);
|
||||
if (!packagePath) {
|
||||
continue;
|
||||
}
|
||||
if (!isRegistrySpec(packageVersion)) {
|
||||
nonRegistryDirs.add(await realDir(packagePath));
|
||||
}
|
||||
if (visitedPackages.has(packageName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visitedPackages.add(packageName);
|
||||
|
||||
const packageJson = await readPackageJson(packagePath, tolerant);
|
||||
if (packageJson && isPluginPackageJson(packageJson)) {
|
||||
plugins.push({ packageName, packageVersion, packagePath, packageJson });
|
||||
for (const [dependencyName, dependencyVersion] of Object.entries(installedDependencies(packageJson))) {
|
||||
pendingPackages.push({
|
||||
packageName: dependencyName,
|
||||
packageVersion: dependencyVersion,
|
||||
parentPath: packagePath,
|
||||
isRootDependency: false,
|
||||
});
|
||||
}
|
||||
} else if (isRootDependency) {
|
||||
nonPluginRoots.push({ packageName, packageVersion });
|
||||
}
|
||||
}
|
||||
return { plugins, nonPluginRoots, nonRegistryDirs };
|
||||
}
|
||||
|
||||
function resolvePackagePath(packageName: string, fromPath: string): string | undefined {
|
||||
const packageRequire = createRequire(path.join(fromPath, "package.json"));
|
||||
try {
|
||||
return path.dirname(packageRequire.resolve(`${packageName}/package.json`));
|
||||
} catch (err: any) {
|
||||
if (err.code === "MODULE_NOT_FOUND" || err.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
/** Real paths of plugin packages some dependent installs from a path, git, url, tarball or alias */
|
||||
export async function findNonRegistryDirs() {
|
||||
const dependencies = installedDependencies(await readPackageJson(process.cwd(), true));
|
||||
return (await discoverNpmPluginPackages(dependencies, true)).nonRegistryDirs;
|
||||
}
|
||||
|
||||
/** Whether npm install name would replace a plugin package not installed from the registry */
|
||||
export async function isNonRegistryPackage(name: string) {
|
||||
const dependencies = installedDependencies(await readPackageJson(process.cwd(), true));
|
||||
if (Object.hasOwn(dependencies, name) && !isRegistrySpec(dependencies[name])) {
|
||||
return true;
|
||||
}
|
||||
const packagePath = resolvePackagePath(name, process.cwd(), true);
|
||||
if (!packagePath) {
|
||||
return false;
|
||||
}
|
||||
return (await findNonRegistryDirs()).has(await realDir(packagePath));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,79 +243,46 @@ function resolvePackagePath(packageName: string, fromPath: string): string | und
|
||||
* Adds discovered plugins to the plugin list.
|
||||
*
|
||||
* @param pluginList - Map of plugin names to their file paths
|
||||
* @param skippedNpmPlugins - Filled with the package directory by plugin name for packages
|
||||
* skipped because their plugin name was already in the list
|
||||
* @returns Promise that resolves to true if any plugins were added to the list, false otherwise
|
||||
*/
|
||||
async function findNpmPlugins(pluginList: Map<string, string>): Promise<boolean> {
|
||||
let dependencies;
|
||||
try {
|
||||
const rootPackageJson = JSON.parse(await fs.readFile(path.resolve("package.json"), { encoding: "utf8" }));
|
||||
dependencies = rootPackageJson.dependencies;
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
// Skip if package.json doesn't exist
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!dependencies) {
|
||||
async function findNpmPlugins(
|
||||
pluginList: Map<string, string>,
|
||||
skippedNpmPlugins?: Map<string, string>,
|
||||
): Promise<boolean> {
|
||||
const rootPackageJson = await readPackageJson(process.cwd(), false);
|
||||
if (!rootPackageJson) {
|
||||
return false;
|
||||
}
|
||||
const dependencies = installedDependencies(rootPackageJson);
|
||||
|
||||
let hasChanged = 0;
|
||||
const pendingPackages = Object.entries(dependencies).map(([packageName, packageVersion]) => ({
|
||||
packageName,
|
||||
packageVersion,
|
||||
parentPath: process.cwd(),
|
||||
isRootDependency: true,
|
||||
}));
|
||||
const corePackages = new Set([
|
||||
"@clusterio/controller",
|
||||
"@clusterio/ctl",
|
||||
"@clusterio/host",
|
||||
"@clusterio/lib",
|
||||
"@clusterio/web_ui",
|
||||
]);
|
||||
const visitedPackages = new Set<string>();
|
||||
|
||||
while (pendingPackages.length) {
|
||||
const { packageName, packageVersion, parentPath, isRootDependency } = pendingPackages.shift()!;
|
||||
if (visitedPackages.has(packageName)) {
|
||||
const { plugins, nonPluginRoots } = await discoverNpmPluginPackages(dependencies);
|
||||
for (const { packageName, packageVersion, packagePath } of plugins) {
|
||||
const pluginName = getPluginName(packagePath);
|
||||
const listedPath = pluginList.get(pluginName);
|
||||
if (listedPath === packageName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const npmPackagePath = resolvePackagePath(packageName, parentPath);
|
||||
if (!npmPackagePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visitedPackages.add(packageName);
|
||||
|
||||
if (await checkPackageJson(npmPackagePath)) {
|
||||
const pluginName = getPluginName(npmPackagePath);
|
||||
if (pluginList.has(pluginName)) {
|
||||
logger.warn(
|
||||
`${pluginName} provided by ${packageName}@${packageVersion} is already in the plugin list, ` +
|
||||
`but with a different path - using ${pluginList.get(pluginName)}`
|
||||
);
|
||||
} else {
|
||||
pluginList.set(pluginName, packageName);
|
||||
logger.info(`Added ${pluginName} from node modules`);
|
||||
hasChanged += 1;
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
await fs.readFile(path.join(npmPackagePath, "package.json"), { encoding: "utf8" })
|
||||
if (listedPath !== undefined && !await pluginPathExists(listedPath)) {
|
||||
logger.warn(`${pluginName} at ${listedPath} no longer exists, using ${packageName}`);
|
||||
pluginList.set(pluginName, packageName);
|
||||
hasChanged += 1;
|
||||
} else if (pluginList.has(pluginName)) {
|
||||
logger.warn(
|
||||
`${pluginName} provided by ${packageName}@${packageVersion} is already in the plugin list, ` +
|
||||
`but with a different path - using ${pluginList.get(pluginName)}`
|
||||
);
|
||||
for (const [dependencyName, dependencyVersion] of Object.entries(packageJson.dependencies ?? {})) {
|
||||
pendingPackages.push({
|
||||
packageName: dependencyName,
|
||||
packageVersion: dependencyVersion,
|
||||
parentPath: npmPackagePath,
|
||||
isRootDependency: false,
|
||||
});
|
||||
}
|
||||
} else if (isRootDependency && !corePackages.has(packageName)) {
|
||||
skippedNpmPlugins?.set(pluginName, await realDir(packagePath));
|
||||
} else {
|
||||
pluginList.set(pluginName, packageName);
|
||||
logger.info(`Added ${pluginName} from node modules`);
|
||||
hasChanged += 1;
|
||||
}
|
||||
}
|
||||
for (const { packageName, packageVersion } of nonPluginRoots) {
|
||||
if (!corePackages.has(packageName)) {
|
||||
logger.warn(`${packageName}@${packageVersion} is not a clusterio-plugin`);
|
||||
}
|
||||
}
|
||||
@@ -176,9 +294,15 @@ async function findNpmPlugins(pluginList: Map<string, string>): Promise<boolean>
|
||||
*
|
||||
* @param pluginListPath - Path to the plugin list JSON file
|
||||
* @param findLocal - Whether to look for local plugins in plugins/ and external_plugins/ directories
|
||||
* @param skippedNpmPlugins - Filled with the package directory by plugin name for npm packages
|
||||
* skipped because another plugin has the same name, for loadPluginInfos
|
||||
* @returns Map of plugin name to plugin path
|
||||
*/
|
||||
export async function loadPluginList(pluginListPath: string, findLocal = true): Promise<Map<string, string>> {
|
||||
export async function loadPluginList(
|
||||
pluginListPath: string,
|
||||
findLocal = true,
|
||||
skippedNpmPlugins?: Map<string, string>,
|
||||
): Promise<Map<string, string>> {
|
||||
let pluginList = new Map<string, string>();
|
||||
|
||||
// Try to load existing plugin list
|
||||
@@ -201,7 +325,7 @@ export async function loadPluginList(pluginListPath: string, findLocal = true):
|
||||
}
|
||||
|
||||
// Discover NPM plugins
|
||||
hasChanges = await findNpmPlugins(pluginList) || hasChanges;
|
||||
hasChanges = await findNpmPlugins(pluginList, skippedNpmPlugins) || hasChanges;
|
||||
|
||||
if (hasChanges) {
|
||||
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
|
||||
@@ -209,24 +333,3 @@ export async function loadPluginList(pluginListPath: string, findLocal = true):
|
||||
|
||||
return pluginList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if a package.json file exists and contains the clusterio-plugin keyword
|
||||
* @param pluginPath - Path to the plugin directory
|
||||
* @returns Promise that resolves to true if package.json exists and has clusterio-plugin keyword
|
||||
*/
|
||||
async function checkPackageJson(pluginPath: string): Promise<boolean> {
|
||||
const packageJsonPath = path.join(pluginPath, "package.json");
|
||||
|
||||
// Read and parse package.json
|
||||
let content;
|
||||
try {
|
||||
content = await fs.readFile(packageJsonPath, { encoding: "utf8" });
|
||||
} catch (err: any) {
|
||||
return false;
|
||||
}
|
||||
const packageJson = JSON.parse(content);
|
||||
|
||||
// Check if package has the clusterio-plugin keyword
|
||||
return packageJson.keywords?.includes("clusterio-plugin");
|
||||
}
|
||||
@@ -177,9 +177,9 @@ export const logger = winston.createLogger({
|
||||
// Who in their right mind thought that log levels should be hard coded
|
||||
// into the type when you can define custom levels you want to support?
|
||||
export type Logger = Omit<winston.Logger,
|
||||
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "verbose" | "input" | "silly" |
|
||||
"emerg" | "alert" | "crit" | "warning" | "notice" |
|
||||
"child"
|
||||
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "verbose" | "input" | "silly"
|
||||
| "emerg" | "alert" | "crit" | "warning" | "notice"
|
||||
| "child"
|
||||
> & {
|
||||
fatal: winston.LeveledLogMethod,
|
||||
error: winston.LeveledLogMethod,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { logger } from "./logging.js";
|
||||
import { NotFoundError, UnavailableError, ValidationError } from "./errors.js";
|
||||
import { PluginSearchRequest, PluginSearchResult } from "./data/messages_plugin.js";
|
||||
|
||||
const registryTimeoutMs = 10e3;
|
||||
|
||||
// One name segment, optionally after an @scope/ segment. Not starting with . or - keeps npm from
|
||||
// reading the spec as a path, a git repository or a command line option.
|
||||
// https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name
|
||||
const packageNamePattern = /^(?:@[a-zA-Z0-9][a-zA-Z0-9\-._~]*\/)?[a-zA-Z0-9][a-zA-Z0-9\-._~]*$/;
|
||||
|
||||
/**
|
||||
* Split a package spec into its name and optional version, as in name@version. The name itself
|
||||
* may start with @ for scoped packages.
|
||||
*/
|
||||
export function parsePackageSpec(spec: string) {
|
||||
const versionSeparator = spec.lastIndexOf("@");
|
||||
const [name, version] = versionSeparator > 0
|
||||
? [spec.slice(0, versionSeparator), spec.slice(versionSeparator + 1)]
|
||||
: [spec, undefined];
|
||||
if (name.length > 214 || !packageNamePattern.test(name)) {
|
||||
throw new ValidationError(`Invalid plugin name: ${spec}`);
|
||||
}
|
||||
if (version !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9\-.+]*$/.test(version)) {
|
||||
throw new ValidationError(`Invalid plugin version: ${version}`);
|
||||
}
|
||||
return { name, version };
|
||||
}
|
||||
|
||||
/** Throws unless the package, and version if given, is published on npm, returns the spec to install */
|
||||
export async function checkRegistry(name: string, version?: string) {
|
||||
const packageName = encodeURI(name);
|
||||
const spec = version !== undefined ? `${packageName}@${version}` : packageName;
|
||||
const registryPath = version !== undefined ? `${packageName}/${encodeURIComponent(version)}` : packageName;
|
||||
const npmRequest = await fetch(`https://registry.npmjs.com/${registryPath}`, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(registryTimeoutMs),
|
||||
});
|
||||
if (!npmRequest.ok) {
|
||||
throw new NotFoundError(`Unknown plugin: ${spec}`);
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
const latestVersionCacheMs = 5 * 60e3;
|
||||
const latestVersionCacheMaxSize = 1000;
|
||||
const latestVersionConcurrency = 8;
|
||||
|
||||
/** Latest version of each package by name, as found on npm, with when it was looked up */
|
||||
export type LatestVersionCache = Map<string, { version?: string, fetchedAt: number }>;
|
||||
|
||||
/**
|
||||
* Look up the latest published version of each package on npm, packages that can't be
|
||||
* found are left out. Lookups are kept in the given cache for a few minutes.
|
||||
*/
|
||||
export async function fetchLatestVersions(packages: string[], cache: LatestVersionCache) {
|
||||
if (packages.length > 500) {
|
||||
throw new ValidationError("Too many packages");
|
||||
}
|
||||
const names = [...new Set(packages.map(packageName => {
|
||||
const { name, version: pinned } = parsePackageSpec(packageName);
|
||||
if (pinned !== undefined) {
|
||||
throw new ValidationError(`Invalid plugin name: ${packageName}`);
|
||||
}
|
||||
return name;
|
||||
}))];
|
||||
|
||||
const now = Date.now();
|
||||
for (const [name, cached] of cache) {
|
||||
if (now - cached.fetchedAt > latestVersionCacheMs) {
|
||||
cache.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
const versions: Record<string, string> = {};
|
||||
const queue = [...names];
|
||||
async function lookUp(name: string) {
|
||||
let cached = cache.get(name);
|
||||
if (!cached) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`https://registry.npmjs.com/${name}/latest`, {
|
||||
signal: AbortSignal.timeout(registryTimeoutMs),
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to look up latest version of ${name}: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
if (!response.ok && response.status !== 404) {
|
||||
// Rate limits and outages are retried on the next lookup instead of cached
|
||||
logger.warn(`Failed to look up latest version of ${name}: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
const version = response.ok ? (await response.json() as { version?: string }).version : undefined;
|
||||
cached = { version, fetchedAt: now };
|
||||
for (const oldest of cache.keys()) {
|
||||
if (cache.size < latestVersionCacheMaxSize) {
|
||||
break;
|
||||
}
|
||||
cache.delete(oldest);
|
||||
}
|
||||
cache.set(name, cached);
|
||||
}
|
||||
if (cached.version) {
|
||||
versions[name] = cached.version;
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: latestVersionConcurrency }, async () => {
|
||||
for (let name = queue.shift(); name !== undefined; name = queue.shift()) {
|
||||
await lookUp(name);
|
||||
}
|
||||
}));
|
||||
return versions;
|
||||
}
|
||||
|
||||
const npmSearchMaxSize = 250; // Limit imposed by the registry
|
||||
|
||||
export async function searchPlugins(query: string, page: number, pageSize: number) {
|
||||
if (query.length > 214) {
|
||||
throw new ValidationError("Search query too long");
|
||||
}
|
||||
if (page < 1 || pageSize < 1 || pageSize > npmSearchMaxSize) {
|
||||
throw new ValidationError("Invalid page or page size");
|
||||
}
|
||||
|
||||
const url = new URL("https://registry.npmjs.com/-/v1/search");
|
||||
url.searchParams.set("text", `keywords:clusterio-plugin ${query}`.trim());
|
||||
url.searchParams.set("size", String(pageSize));
|
||||
url.searchParams.set("from", String((page - 1) * pageSize));
|
||||
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(registryTimeoutMs) });
|
||||
if (!response.ok) {
|
||||
throw new UnavailableError(`npm registry search failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const json = await response.json() as {
|
||||
total: number,
|
||||
objects: {
|
||||
package: {
|
||||
name: string,
|
||||
version: string,
|
||||
description?: string | null,
|
||||
date?: string | null,
|
||||
publisher?: { username?: string | null } | null,
|
||||
links?: { npm?: string | null, homepage?: string | null, repository?: string | null } | null,
|
||||
},
|
||||
}[],
|
||||
};
|
||||
|
||||
return new PluginSearchRequest.Response(
|
||||
json.total,
|
||||
json.objects.map(({ package: pkg }) => new PluginSearchResult(
|
||||
// The registry sends null for some missing fields
|
||||
pkg.name, pkg.version, pkg.description ?? undefined, pkg.publisher?.username ?? undefined,
|
||||
pkg.date ?? undefined, pkg.links?.homepage ?? undefined, pkg.links?.repository ?? undefined,
|
||||
pkg.links?.npm ?? undefined,
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* @author Hornwitser
|
||||
* @module lib/users
|
||||
*/
|
||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "./plugin.js";
|
||||
import type { PluginNodeEnvInfo, PluginWebEnvInfo } from "./plugin.js";
|
||||
import { Permission } from "./data/index.js";
|
||||
|
||||
export const permissions = new Map<string, Permission>();
|
||||
@@ -88,7 +88,7 @@ export function definePermission({
|
||||
*
|
||||
* @param pluginInfos - Array of plugin info objects.
|
||||
*/
|
||||
export function registerPluginPermissions(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) {
|
||||
export function registerPluginPermissions(pluginInfos: PluginNodeEnvInfo[] | PluginWebEnvInfo[]) {
|
||||
for (const pluginInfo of pluginInfos) {
|
||||
for (const permission of pluginInfo.permissions ?? []) {
|
||||
if (typeof permission.name !== "string" || !permission.name.startsWith(`${pluginInfo.name}.`)) {
|
||||
@@ -583,6 +583,11 @@ export const corePermissions = [
|
||||
title: "Install plugin",
|
||||
description: "Remotely install a plugin if the target allows remote installs of plugins.",
|
||||
},
|
||||
{
|
||||
name: "core.plugin.search",
|
||||
title: "Search plugins",
|
||||
description: "Search the npm registry for plugins via the controller.",
|
||||
},
|
||||
|
||||
{
|
||||
name: "core.debug.dump_ws",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ConfigSchema, FieldDefinition } from "./config/index.js";
|
||||
import type { PermissionDefinition } from "./permissions.js";
|
||||
import type { PlayerStats, Role } from "./data/index.js";
|
||||
import type { SyncHook } from "./SyncHook.js";
|
||||
import type { ErrorClass } from "./link/index.js";
|
||||
|
||||
export const PluginFeatureFlags = [
|
||||
/** The plugin requires module code to be patched into the save */
|
||||
@@ -42,9 +43,10 @@ export type PluginDeclaration = {
|
||||
features?: (typeof PluginFeatureFlags)[number][];
|
||||
|
||||
messages?: any[];
|
||||
errors?: ErrorClass[];
|
||||
permissions?: PermissionDefinition[];
|
||||
routes?: string[];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a plugin is expected to ship a web build.
|
||||
@@ -82,9 +84,14 @@ export type PluginNodeEnvInfo = PluginDeclaration & {
|
||||
* package.
|
||||
*/
|
||||
npmPackage?: string;
|
||||
/**
|
||||
* Directory of an npm package providing a plugin with the same name
|
||||
* that was skipped on startup as this plugin was already in the list.
|
||||
*/
|
||||
skippedNpmPackage?: string;
|
||||
};
|
||||
|
||||
export type PluginWebpackEnvInfo = PluginDeclaration & {
|
||||
export type PluginWebEnvInfo = PluginDeclaration & {
|
||||
container?: any;
|
||||
package?: any;
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -13,6 +13,10 @@ import { loadPluginEntrypoint, loadPluginClass } from "./loadPlugin.js";
|
||||
import { Config } from "./config/classes.js";
|
||||
import { ControllerConfig, HostConfig, InstanceConfig } from "./config/definitions.js";
|
||||
import Role from "./data/Role.js";
|
||||
import { PluginDetails } from "./data/messages_plugin.js";
|
||||
import {
|
||||
discoverNpmPluginPackages, findNonRegistryDirs, installedDependencies, readPackageJson, realDir, resolvePackagePath,
|
||||
} from "./load_plugin_list.js";
|
||||
|
||||
|
||||
/**
|
||||
@@ -24,10 +28,13 @@ import Role from "./data/Role.js";
|
||||
*
|
||||
* @param pluginList -
|
||||
* Mapping of plugin name to require path for the plugins to load.
|
||||
* @param skippedNpmPlugins -
|
||||
* Directory of the npm package skipped by plugin name, from loadPluginList.
|
||||
* @returns Array of plugin info modules.
|
||||
*/
|
||||
export async function loadPluginInfos(pluginList: Map<string, string>) {
|
||||
export async function loadPluginInfos(pluginList: Map<string, string>, skippedNpmPlugins?: Map<string, string>) {
|
||||
let plugins: libPlugin.PluginNodeEnvInfo[] = [];
|
||||
const nonRegistryDirs = await findNonRegistryDirs();
|
||||
for (let [pluginName, pluginPath] of pluginList) {
|
||||
let pluginInfo: libPlugin.PluginNodeEnvInfo;
|
||||
let pluginPackage: { name?: string, version: string, main?: string, private?: boolean };
|
||||
@@ -87,12 +94,106 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
|
||||
pluginInfo.packagePath = packagePath;
|
||||
pluginInfo.requirePath = pluginPath;
|
||||
pluginInfo.version = pluginPackage.version;
|
||||
pluginInfo.npmPackage = !pluginPackage.private && pluginPath === pluginPackage.name ? pluginPath : undefined;
|
||||
pluginInfo.npmPackage = !pluginPackage.private && pluginPath === pluginPackage.name
|
||||
&& !nonRegistryDirs.has(await fs.realpath(path.dirname(packagePath))) ? pluginPath : undefined;
|
||||
pluginInfo.skippedNpmPackage = skippedNpmPlugins?.get(pluginName);
|
||||
plugins.push(pluginInfo);
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the plugins on this machine along with the versions currently installed on disk,
|
||||
* including npm plugins installed since startup that will only be loaded after a restart.
|
||||
*
|
||||
* @param pluginInfos - Plugins discovered when this machine started
|
||||
* @param isLoaded - Whether the plugin is loaded
|
||||
* @param isEnabled - Whether the plugin is enabled
|
||||
* @param hasEntrypoint - Whether the plugin has an entrypoint this machine loads
|
||||
* @returns Details of every plugin on this machine
|
||||
*/
|
||||
export async function listPluginDetails(
|
||||
pluginInfos: libPlugin.PluginNodeEnvInfo[],
|
||||
isLoaded: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean,
|
||||
isEnabled: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean,
|
||||
hasEntrypoint: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean = () => true,
|
||||
): Promise<PluginDetails[]> {
|
||||
const plugins: PluginDetails[] = [];
|
||||
const dependencies = installedDependencies(await readPackageJson(process.cwd(), true));
|
||||
// By directory, as a plugin loaded from a path may also be an npm dependency
|
||||
const knownDirs = new Set<string>();
|
||||
// Packages skipped on startup by directory, for plugins still installed
|
||||
const takenDirs = new Set<string>();
|
||||
const { plugins: npmPlugins, nonRegistryDirs } = await discoverNpmPluginPackages(dependencies, true);
|
||||
for (const pluginInfo of pluginInfos) {
|
||||
const dir = await realDir(path.dirname(pluginInfo.packagePath));
|
||||
knownDirs.add(dir);
|
||||
// Without an entrypoint for this machine the plugin has no load_plugin field here
|
||||
const loadable = hasEntrypoint(pluginInfo);
|
||||
const plugin = PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo, isLoaded(pluginInfo), loadable && isEnabled(pluginInfo),
|
||||
);
|
||||
if (!loadable) {
|
||||
plugin.noEntrypoint = true;
|
||||
}
|
||||
if (Object.hasOwn(dependencies, pluginInfo.requirePath)) {
|
||||
plugin.dependencyName = pluginInfo.requirePath;
|
||||
}
|
||||
if (nonRegistryDirs.has(dir)) {
|
||||
plugin.npmPackage = undefined;
|
||||
}
|
||||
let packageJson;
|
||||
try {
|
||||
packageJson = await readPackageJson(path.dirname(pluginInfo.packagePath), false);
|
||||
if (packageJson === undefined) {
|
||||
// Uninstalled since startup, it won't be found on the next restart
|
||||
plugin.removed = true;
|
||||
}
|
||||
} catch {
|
||||
// Ignored: npm is still writing it
|
||||
}
|
||||
if (packageJson) {
|
||||
plugin.installedVersion = packageJson.version;
|
||||
if (packageJson.name !== plugin.npmPackage) {
|
||||
// Replaced by an alias since startup
|
||||
plugin.npmPackage = undefined;
|
||||
}
|
||||
}
|
||||
if (!plugin.removed && pluginInfo.skippedNpmPackage !== undefined) {
|
||||
takenDirs.add(pluginInfo.skippedNpmPackage);
|
||||
}
|
||||
plugins.push(plugin);
|
||||
}
|
||||
|
||||
for (const { packageName, packagePath, packageJson } of npmPlugins) {
|
||||
const dir = await realDir(packagePath);
|
||||
if (knownDirs.has(dir)) {
|
||||
continue;
|
||||
}
|
||||
// Startup loads it by package name from the root, which doesn't reach nested packages
|
||||
const rootPath = resolvePackagePath(packageName, process.cwd(), true);
|
||||
if (!rootPath || await realDir(rootPath) !== dir) {
|
||||
continue;
|
||||
}
|
||||
if (typeof packageJson.version !== "string") {
|
||||
continue;
|
||||
}
|
||||
// Same rule as loadPluginInfos, aliases, private and non-registry packages can't be installed by this name
|
||||
const npmPackage = !packageJson.private && packageJson.name === packageName && !nonRegistryDirs.has(dir)
|
||||
? packageName : undefined;
|
||||
const dependencyName = Object.hasOwn(dependencies, packageName) ? packageName : undefined;
|
||||
const plugin = PluginDetails.fromPackageJson(packageName, packageJson, npmPackage, dependencyName);
|
||||
if (takenDirs.has(dir)) {
|
||||
// Skipped on startup as its plugin name is taken, will be again
|
||||
plugin.pendingInstall = undefined;
|
||||
plugin.nameTaken = true;
|
||||
}
|
||||
plugins.push(plugin);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the migration entrypoints of plugins
|
||||
*
|
||||
|
||||
+80
-25
@@ -2,8 +2,11 @@ import util from "util";
|
||||
import path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { logger } from "./logging.js";
|
||||
import { RequestError } from "./errors.js";
|
||||
import { ConflictError, NotFoundError, UnavailableError } from "./errors.js";
|
||||
import type { PluginNodeEnvInfo } from "./plugin.js";
|
||||
import { isNonRegistryPackage } from "./load_plugin_list.js";
|
||||
import { LockFile, LockFileExistsError, LockFileRecoveryError } from "./LockFile.js";
|
||||
import { checkRegistry, parsePackageSpec } from "./npm_registry.js";
|
||||
const execAsync = util.promisify(exec);
|
||||
|
||||
function isDev() {
|
||||
@@ -12,10 +15,55 @@ function isDev() {
|
||||
return import.meta.dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio"
|
||||
}
|
||||
|
||||
async function logExec(cmd: string) {
|
||||
logger.audit(`RCE | ${cmd}`);
|
||||
if (!isDev()) {
|
||||
await execAsync(cmd);
|
||||
const operationInProgressMessage = "Another package operation is already in progress on this machine";
|
||||
|
||||
let operationInProgress = false;
|
||||
|
||||
/**
|
||||
* Run an npm command, at most one at a time per installation. npm runs in the working directory,
|
||||
* which a standalone controller and host share, so a lock file there guards against operations
|
||||
* from other processes and other copies of lib. The flag makes concurrent calls through this copy
|
||||
* reject without racing a release of the lock.
|
||||
*
|
||||
* @param cmd - Command to run
|
||||
* @param check - Called while holding the lock before running the command, may throw to cancel it
|
||||
*/
|
||||
async function logExec(cmd: string, check?: () => Promise<void>) {
|
||||
// Checked and set before the first await so concurrent calls in this process can't interleave
|
||||
if (operationInProgress) {
|
||||
throw new ConflictError(operationInProgressMessage);
|
||||
}
|
||||
operationInProgress = true;
|
||||
const lockPath = path.resolve("npm-operation.lock");
|
||||
// Signal listeners would change how the process exits. If only this process is killed npm keeps
|
||||
// running unlocked, Ctrl+C, systemd and containers stop it too.
|
||||
const lock = new LockFile(lockPath, false);
|
||||
try {
|
||||
try {
|
||||
await lock.acquire();
|
||||
} catch (err) {
|
||||
if (err instanceof LockFileRecoveryError) {
|
||||
throw new UnavailableError(err.message);
|
||||
}
|
||||
if (!(err instanceof LockFileExistsError)) {
|
||||
throw err;
|
||||
}
|
||||
const holder = await lock.holderPid();
|
||||
throw new ConflictError(
|
||||
`${operationInProgressMessage}${holder ? ` (${lockPath} is held by process ${holder})` : ""}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
await check?.();
|
||||
logger.audit(`RCE | ${cmd}`);
|
||||
if (!isDev()) {
|
||||
await execAsync(cmd);
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
} finally {
|
||||
operationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +71,13 @@ export async function updatePackage(...names: string[]) {
|
||||
return logExec(`npm update --save ${names.join(" ")}`);
|
||||
}
|
||||
|
||||
export async function installPackage(name: string) {
|
||||
return logExec(`npm install --save ${name}`);
|
||||
export async function installPackage(name: string, check?: () => Promise<void>) {
|
||||
return logExec(`npm install --save ${name}`, check);
|
||||
}
|
||||
|
||||
export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) {
|
||||
if (!pluginInfos.some(plugin => plugin.npmPackage === pluginName)) {
|
||||
throw new RequestError(`Plugin ${pluginName} is not installed on this machine`);
|
||||
}
|
||||
|
||||
return await updatePackage(pluginName);
|
||||
/** Whether an npm operation is running in this installation, from this or another process */
|
||||
export async function packageOperationRunning() {
|
||||
return operationInProgress || !await new LockFile(path.resolve("npm-operation.lock")).isStale();
|
||||
}
|
||||
|
||||
export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNodeEnvInfo[]) {
|
||||
@@ -42,20 +87,30 @@ export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNo
|
||||
return await updatePackage(corePackage, ...pluginPackages);
|
||||
}
|
||||
|
||||
export async function handlePluginInstall(pluginName: string) {
|
||||
if (pluginName.length > 214 || /[^a-zA-Z0-9\-_.+@\/]/.test(pluginName)) {
|
||||
// https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name
|
||||
// https://www.npmjs.com/package/validate-npm-package-name
|
||||
throw new RequestError(`Invalid plugin name: ${pluginName}`);
|
||||
/** Throws if the dependency by this name is a path, git, url or alias that installing by name would replace */
|
||||
async function checkNotLocalDependency(name: string) {
|
||||
if (await isNonRegistryPackage(name)) {
|
||||
throw new ConflictError(`${name} is not installed from npm on this machine`);
|
||||
}
|
||||
}
|
||||
|
||||
const packageName = encodeURI(pluginName);
|
||||
const npmRequest = await fetch(`https://registry.npmjs.com/${packageName}`, {
|
||||
method: "HEAD",
|
||||
});
|
||||
if (!npmRequest.ok) {
|
||||
throw new RequestError(`Unknown plugin: ${packageName}`);
|
||||
export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) {
|
||||
// Optionally pinned to a version as name@version to update past the range in package.json
|
||||
const { name, version } = parsePackageSpec(pluginName);
|
||||
if (!pluginInfos.some(plugin => plugin.npmPackage === name)) {
|
||||
throw new NotFoundError(`Plugin ${name} is not installed on this machine`);
|
||||
}
|
||||
|
||||
return await installPackage(packageName);
|
||||
if (version === undefined) {
|
||||
return await updatePackage(name);
|
||||
}
|
||||
// Checked under the lock as another operation may change where it's installed from
|
||||
return await installPackage(await checkRegistry(name, version), () => checkNotLocalDependency(name));
|
||||
}
|
||||
|
||||
/** Install a plugin, optionally pinned to a version as name@version, returns the package name */
|
||||
export async function handlePluginInstall(pluginName: string) {
|
||||
const { name, version } = parsePackageSpec(pluginName);
|
||||
await installPackage(await checkRegistry(name, version), () => checkNotLocalDependency(name));
|
||||
return name;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { spawn } from "child_process";
|
||||
import { loadPluginList } from "./load_plugin_list.js";
|
||||
import * as libConfig from "./config/index.js";
|
||||
import * as libFileOps from "./file_ops.js";
|
||||
import { InvalidFieldError, InvalidValueError } from "./errors.js";
|
||||
import { logger } from "./logging.js";
|
||||
import * as libHelpers from "./helpers.js";
|
||||
import { LockFile } from "./LockFile.js";
|
||||
@@ -174,7 +175,7 @@ export async function handlePluginCommand(
|
||||
try {
|
||||
const pluginPackage = (await import(
|
||||
pathToFileURL(path.posix.join(pluginPath, "package.json")).href,
|
||||
{ with: { type: "json" }},
|
||||
{ with: { type: "json" }}
|
||||
)).default;
|
||||
pluginInfo = (await import(
|
||||
pathToFileURL(path.posix.join(pluginPath, pluginPackage.main ?? "index.js")).href
|
||||
@@ -271,7 +272,7 @@ export async function handleConfigCommand(
|
||||
try {
|
||||
print(instance.get(args.field as string));
|
||||
} catch (err) {
|
||||
if (err instanceof libConfig.InvalidField) {
|
||||
if (err instanceof InvalidFieldError) {
|
||||
logger.error(err.message);
|
||||
} else {
|
||||
throw err;
|
||||
@@ -292,7 +293,7 @@ export async function handleConfigCommand(
|
||||
instance.set(args.field as string, args.value);
|
||||
await instance.save();
|
||||
} catch (err) {
|
||||
if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) {
|
||||
if (err instanceof InvalidFieldError || err instanceof InvalidValueError) {
|
||||
logger.error(err.message);
|
||||
} else {
|
||||
throw err;
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from
|
||||
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum } from "./data/index.js";
|
||||
import isDeepStrictEqual from "#is_deep_strict_equal";
|
||||
import type { PermissionName } from "./permissions.js";
|
||||
import { RequestError } from "./errors.js";
|
||||
import { SessionLost, ValidationError } from "./errors.js";
|
||||
import { logger } from "./logging.js";
|
||||
|
||||
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
|
||||
export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => void
|
||||
export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => void;
|
||||
|
||||
/**
|
||||
* Represents a set of string subscription filters.
|
||||
@@ -241,7 +241,7 @@ type Subscriber = {
|
||||
link: Link,
|
||||
address: Address,
|
||||
filters: SubscriptionFilters,
|
||||
}
|
||||
};
|
||||
|
||||
type EventData = {
|
||||
subscriptionUpdate?: SubscriptionRequestHandler<unknown>,
|
||||
@@ -351,7 +351,7 @@ export class SubscriptionController {
|
||||
}
|
||||
}
|
||||
if (count > this._maxFilters) {
|
||||
throw new RequestError(`Subscription exceeds the limit of ${this._maxFilters} filters`);
|
||||
throw new ValidationError(`Subscription exceeds the limit of ${this._maxFilters} filters`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,7 +685,7 @@ export class EventSubscriber<E, S = null> {
|
||||
this._notify(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!(err instanceof RequestError) || err.code !== "SessionLost") {
|
||||
if (!(err instanceof SessionLost)) {
|
||||
logger.error(`Unexpected error updating ${entry.name} subscription:\n${err.stack}`);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "os";
|
||||
import util from "util";
|
||||
import { Gauge } from "./prometheus.js";
|
||||
import { SystemInfo } from "./data/index.js";
|
||||
import { PluginDetails, SystemInfo } from "./data/index.js";
|
||||
|
||||
function filterCpuModel() {
|
||||
const model = os.cpus()[0].model;
|
||||
@@ -125,7 +125,8 @@ export async function gatherSystemInfo(
|
||||
id: SystemInfo["id"],
|
||||
canRestart: boolean,
|
||||
restartRequired: boolean,
|
||||
startedAtMs: number
|
||||
startedAtMs: number,
|
||||
plugins?: PluginDetails[],
|
||||
) {
|
||||
const cpus = os.cpus();
|
||||
const currentTotalCpuMs = cpus.map(({ times }) => times.user + times.idle + times.irq + times.nice + times.sys);
|
||||
@@ -160,5 +161,6 @@ export async function gatherSystemInfo(
|
||||
startedAtMs,
|
||||
now,
|
||||
false,
|
||||
plugins,
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"./src/config/*.ts",
|
||||
"./src/data/*.ts",
|
||||
"./src/external/*.ts",
|
||||
"./src/browser_polyfills.ts",
|
||||
"./src/check_singleton_import.ts",
|
||||
"./src/datastore.ts",
|
||||
"./src/errors.ts",
|
||||
|
||||
Vendored
-3
@@ -22,6 +22,3 @@ declare module "*.md" {
|
||||
declare const webRoot: string;
|
||||
/** Path static assets are served under, ends with a slash. */
|
||||
declare const staticRoot: string;
|
||||
|
||||
declare const __webpack_init_sharing__: (shareScope: string) => Promise<void>;
|
||||
declare const __webpack_share_scopes__: { default: any };
|
||||
@@ -33,19 +33,15 @@
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@ant-design/icons": "catalog:",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"@swc/core": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"antd": "catalog:",
|
||||
"assert": "^2.1.0",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
"buffer": "^6.0.3",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"css-loader": "^7.1.5",
|
||||
"events": "^3.3.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"null-loader": "^4.0.1",
|
||||
"os-browserify": "^0.3.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"process": "^0.11.10",
|
||||
@@ -53,20 +49,18 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
"react-router": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"typescript": "catalog:",
|
||||
"util": "^0.12.5",
|
||||
"webpack": "catalog:",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-manifest-plugin": "^6.0.1"
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "^18.3.7"
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/semver": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export type WebPluginContext = lib.PluginLoadContext<{
|
||||
control: Control;
|
||||
container: any;
|
||||
package: any;
|
||||
}, lib.PluginWebpackEnvInfo>;
|
||||
}, lib.PluginWebEnvInfo>;
|
||||
|
||||
/**
|
||||
* Collection of web plugin hooks
|
||||
@@ -258,7 +258,7 @@ export class BaseWebPlugin {
|
||||
constructor(
|
||||
public container: any,
|
||||
packageData: any,
|
||||
public info: lib.PluginWebpackEnvInfo,
|
||||
public info: lib.PluginWebEnvInfo,
|
||||
public control: Control,
|
||||
public logger: lib.Logger,
|
||||
) {
|
||||
|
||||
@@ -16,22 +16,7 @@ import { pages } from "./pages";
|
||||
|
||||
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
|
||||
|
||||
async function loadScript(url: string) {
|
||||
let script = document.createElement("script");
|
||||
script.src = url;
|
||||
script.type = "text/javascript";
|
||||
script.async = true;
|
||||
|
||||
let result = new Promise((resolve, reject) => {
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
});
|
||||
|
||||
document.head.appendChild(script);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]> {
|
||||
async function loadPluginInfos(): Promise<[lib.PluginWebEnvInfo[], string]> {
|
||||
let response = await fetch(`${webRoot}api/plugins`);
|
||||
let pluginList: lib.PluginWebApi[];
|
||||
if (response.ok) {
|
||||
@@ -43,8 +28,7 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
|
||||
}
|
||||
let pluginSetKey = loadedPluginSetKey(pluginList);
|
||||
|
||||
let pluginInfos: lib.PluginWebpackEnvInfo[] = [];
|
||||
await __webpack_init_sharing__("default");
|
||||
let pluginInfos: lib.PluginWebEnvInfo[] = [];
|
||||
for (let meta of pluginList) {
|
||||
if (meta.web.error) {
|
||||
logger.error(`Failed to load plugin ${meta.name}: ${meta.web.error}`);
|
||||
@@ -54,15 +38,13 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await loadScript(`${staticRoot}${meta.web.main}`);
|
||||
let container: any = (window as { [key: string]: any })[`plugin_${meta.name}`];
|
||||
if (!container) {
|
||||
throw new Error(`Plugin did not expose its container via plugin_${meta.name}`);
|
||||
let container = await import(/* @vite-ignore */ `${staticRoot}${meta.web.main}`);
|
||||
if (typeof container.get !== "function") {
|
||||
throw new Error("Plugin web bundle does not export get");
|
||||
}
|
||||
await container.init(__webpack_share_scopes__.default);
|
||||
let pluginInfo = (await container.get("./"))().plugin;
|
||||
let pluginInfo = (await container.get("./")).plugin;
|
||||
pluginInfo.container = container;
|
||||
pluginInfo.package = (await container.get("./package.json"))();
|
||||
pluginInfo.package = (await container.get("./package.json")).default;
|
||||
pluginInfo.enabled = meta.enabled;
|
||||
pluginInfos.push(pluginInfo);
|
||||
|
||||
@@ -83,7 +65,7 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
|
||||
}
|
||||
|
||||
async function loadPlugins(
|
||||
pluginInfos: lib.PluginWebpackEnvInfo[],
|
||||
pluginInfos: lib.PluginWebEnvInfo[],
|
||||
control: Control
|
||||
) {
|
||||
const plugins = new Map<string, WebPlugin.BaseWebPlugin>();
|
||||
@@ -94,8 +76,7 @@ async function loadPlugins(
|
||||
}
|
||||
|
||||
try {
|
||||
const moduleFactory = await pluginInfo.container.get(pluginInfo.webEntrypoint);
|
||||
const webModule = moduleFactory();
|
||||
const webModule = await pluginInfo.container.get(pluginInfo.webEntrypoint);
|
||||
|
||||
const pluginContext: WebPlugin.WebPluginContext = {
|
||||
control,
|
||||
|
||||
@@ -157,7 +157,7 @@ npm init "@clusterio" -- --controller-token ${token} --mode "host" --download-he
|
||||
</>;
|
||||
}
|
||||
|
||||
function CopyButton({ text, message }: { text:string, message:string }) {
|
||||
function CopyButton({ text, message }: { text: string, message: string }) {
|
||||
let [clipboardPermision, setClipboardPermission] = useState<PermissionState>("granted");
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
|
||||
@@ -264,7 +264,7 @@ type DownloadDependenciesProps = {
|
||||
mods: lib.ModRecord[];
|
||||
onChange: (change: ModChange) => void;
|
||||
builtInModNames: string[];
|
||||
}
|
||||
};
|
||||
function DownloadDependenciesButton(props: DownloadDependenciesProps) {
|
||||
const control = useContext(ControlContext);
|
||||
const [modInfos] = useMods();
|
||||
@@ -904,7 +904,7 @@ type SettingsTableFieldProps = {
|
||||
allowedValues: any;
|
||||
locale: Map<string, any>;
|
||||
changed: boolean;
|
||||
}
|
||||
};
|
||||
const SettingsTableField = memo((props: SettingsTableFieldProps) => {
|
||||
const name = props.name;
|
||||
const isBoolean = props.type === "bool-setting";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Button, Checkbox, Form, FormInstance, Input, Modal, Radio, Select } from "antd";
|
||||
import { Button, Checkbox, Modal, Tooltip } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -7,92 +7,7 @@ import { useAccount } from "../model/account";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import ControlContext from "./ControlContext";
|
||||
|
||||
type NpmActionProps = {
|
||||
setApplyAction(func: () => Promise<void>): void,
|
||||
form: FormInstance,
|
||||
target: lib.AddressShorthand,
|
||||
}
|
||||
|
||||
function NpmRemoteUpdate({ setApplyAction, form, target }: NpmActionProps) {
|
||||
const control = useContext(ControlContext);
|
||||
|
||||
setApplyAction(async () => {
|
||||
if (target === "controller") {
|
||||
return await control.send(new lib.ControllerUpdateRequest());
|
||||
}
|
||||
return await control.sendTo(target, new lib.HostUpdateRequest());
|
||||
});
|
||||
|
||||
return <>
|
||||
</>;
|
||||
}
|
||||
|
||||
function NpmUpdateAll({ setApplyAction, form, target }: NpmActionProps) {
|
||||
const control = useContext(ControlContext);
|
||||
|
||||
setApplyAction(async () => {
|
||||
await control.sendTo(target, new lib.UpdateAllRequest());
|
||||
});
|
||||
|
||||
return <>
|
||||
</>;
|
||||
}
|
||||
|
||||
function NpmPluginUpdate({ setApplyAction, form, target }: NpmActionProps) {
|
||||
const control = useContext(ControlContext);
|
||||
const [plugins, setPlugins] = useState<lib.PluginDetails[]>([]);
|
||||
|
||||
setApplyAction(async () => {
|
||||
const values = form.getFieldsValue();
|
||||
if (!values.plugin || values.plugin === "") {
|
||||
throw new Error("Plugin not provided");
|
||||
}
|
||||
await control.sendTo(target, new lib.PluginUpdateRequest(values.plugin));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
control.sendTo(target, new lib.PluginListRequest()).then(newPlugins => {
|
||||
setPlugins(newPlugins);
|
||||
}).catch(notifyErrorHandler("Error fetching role list"));
|
||||
}, [target]);
|
||||
|
||||
return <>
|
||||
<Form.Item label="Plugin" name="plugin">
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Select a plugin"
|
||||
optionLabelProp="label"
|
||||
options={plugins
|
||||
.filter(p => p.npmPackage !== undefined)
|
||||
.map(plugin => ({
|
||||
value: plugin.npmPackage,
|
||||
label: `${plugin.title} (v${plugin.version})`,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>;
|
||||
}
|
||||
|
||||
function NpmPluginInstall({ setApplyAction, form, target }: NpmActionProps) {
|
||||
const control = useContext(ControlContext);
|
||||
|
||||
setApplyAction(async () => {
|
||||
const values = form.getFieldsValue();
|
||||
if (!values.newPlugin || values.newPlugin === "") {
|
||||
throw new Error("Plugin not provided");
|
||||
}
|
||||
await control.sendTo(target, new lib.PluginInstallRequest(values.newPlugin));
|
||||
});
|
||||
|
||||
// TODO custom npm search based on tags
|
||||
return <>
|
||||
<Form.Item label="Plugin" name="newPlugin">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>;
|
||||
}
|
||||
|
||||
/** Updates Clusterio on a machine, plugins are installed and updated from the plugins page */
|
||||
export function NpmButton(props: {
|
||||
target: "controller" | { hostId: number },
|
||||
canRestart?: boolean,
|
||||
@@ -101,138 +16,72 @@ export function NpmButton(props: {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formAction, setFormAction] = useState<string | undefined>(undefined);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [allows, setAllows] = useState({
|
||||
remoteUpdates: true, pluginUpdates: true, pluginInstall: true,
|
||||
});
|
||||
const [restart, setRestart] = useState(false);
|
||||
const [allowed, setAllowed] = useState(true);
|
||||
|
||||
const isController = props.target === "controller";
|
||||
const hostId = props.target === "controller" ? undefined : props.target.hostId;
|
||||
useEffect(() => {
|
||||
// Without config permission assume it's allowed so the request can still be sent
|
||||
if (!account.hasPermission(isController ? "core.controller.get_config" : "core.host.get_config")) {
|
||||
setAllowed(true);
|
||||
return;
|
||||
}
|
||||
if (props.target === "controller") {
|
||||
if (!account.hasPermission("core.controller.get_config")) {
|
||||
setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true });
|
||||
return; // If a user doesn't have config perms, then assume true to allow the sending the request
|
||||
}
|
||||
control.send(new lib.ControllerConfigGetRequest()).then(async (serializedConfig) => {
|
||||
control.send(new lib.ControllerConfigGetRequest()).then(serializedConfig => {
|
||||
const config = lib.ControllerConfig.fromJSON(serializedConfig, "control");
|
||||
setAllows({
|
||||
remoteUpdates: config.get("controller.allow_remote_updates"),
|
||||
pluginUpdates: config.get("controller.allow_plugin_updates"),
|
||||
pluginInstall: config.get("controller.allow_plugin_install"),
|
||||
});
|
||||
setAllowed(config.get("controller.allow_remote_updates"));
|
||||
}).catch(notifyErrorHandler("Failed to fetch controller config for remote updates"));
|
||||
} else {
|
||||
if (!account.hasPermission("core.host.get_config")) {
|
||||
setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true });
|
||||
return; // If a user doesn't have config perms, then assume true to allow the sending the request
|
||||
}
|
||||
control.sendTo(props.target, new lib.HostConfigGetRequest()).then(async (serializedConfig) => {
|
||||
control.sendTo(props.target, new lib.HostConfigGetRequest()).then(serializedConfig => {
|
||||
const config = lib.HostConfig.fromJSON(serializedConfig, "control");
|
||||
setAllows({
|
||||
remoteUpdates: config.get("host.allow_remote_updates"),
|
||||
pluginUpdates: config.get("host.allow_plugin_updates"),
|
||||
pluginInstall: config.get("host.allow_plugin_install"),
|
||||
});
|
||||
setAllowed(config.get("host.allow_remote_updates"));
|
||||
}).catch(notifyErrorHandler("Failed to fetch host config for remote updates"));
|
||||
}
|
||||
}, [hostId]);
|
||||
|
||||
const coreUpdatePermission = `core.${props.target === "controller" ? "controller" : "host"}.update` as const;
|
||||
|
||||
function onValuesChange({ action } : { action?: string }) {
|
||||
if (action) {
|
||||
setFormAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
let applyAction: () => Promise<void>;
|
||||
const setApplyAction = (func: () => Promise<void>) => { applyAction = func; };
|
||||
const canRestart = account.hasPermission(isController ? "core.controller.restart" : "core.host.restart");
|
||||
async function onOk() {
|
||||
if (!applyAction) {
|
||||
form.setFields([{ name: "action", errors: ["Action is required"] }]);
|
||||
return;
|
||||
if (props.target === "controller") {
|
||||
await control.send(new lib.ControllerUpdateRequest());
|
||||
} else {
|
||||
await control.sendTo(props.target, new lib.HostUpdateRequest());
|
||||
}
|
||||
await applyAction();
|
||||
|
||||
const values = form.getFieldsValue();
|
||||
if (values.restart) {
|
||||
// Ternary not used because TS disliked it
|
||||
if (restart) {
|
||||
if (props.target === "controller") {
|
||||
await control.sendTo(props.target, new lib.ControllerRestartRequest());
|
||||
await control.send(new lib.ControllerRestartRequest());
|
||||
} else {
|
||||
await control.sendTo(props.target, new lib.HostRestartRequest());
|
||||
}
|
||||
}
|
||||
|
||||
setFormAction(undefined);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={props.disabled}
|
||||
onClick={() => { setOpen(true); }}
|
||||
>Updates</Button>
|
||||
<Tooltip title={allowed ? undefined : "Remote updates are disabled on this machine"}>
|
||||
<Button disabled={props.disabled || !allowed} onClick={() => { setOpen(true); }}>
|
||||
Update Clusterio
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Modal
|
||||
title="Updates"
|
||||
okText="Apply"
|
||||
title="Update Clusterio"
|
||||
okText="Update"
|
||||
open={open}
|
||||
okButtonProps={{disabled: formAction === undefined}}
|
||||
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
|
||||
onOk={() => { onOk().catch(notifyErrorHandler("Error updating Clusterio")); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
|
||||
<Form.Item label="Action" name="action">
|
||||
<Radio.Group value={formAction}>
|
||||
{account.hasPermission(coreUpdatePermission)
|
||||
? <Radio.Button value="remote_update" disabled={!allows.remoteUpdates}>
|
||||
Update Clusterio
|
||||
</Radio.Button>: undefined}
|
||||
{account.hasPermission(coreUpdatePermission) && account.hasPermission("core.plugin.update")
|
||||
? <Radio.Button
|
||||
value="update_all"
|
||||
disabled={!allows.remoteUpdates || !allows.pluginUpdates}
|
||||
>
|
||||
Update All
|
||||
</Radio.Button> : undefined}
|
||||
{account.hasPermission("core.plugin.update")
|
||||
? <Radio.Button value="plugin_update" disabled={!allows.pluginUpdates}>
|
||||
Update Plugin
|
||||
</Radio.Button> : undefined}
|
||||
{account.hasPermission("core.plugin.install")
|
||||
? <Radio.Button value="plugin_install" disabled={!allows.pluginInstall}>
|
||||
Install Plugin
|
||||
</Radio.Button> : undefined}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{
|
||||
formAction !== undefined
|
||||
&& account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.restart`)
|
||||
&& <Form.Item label="Restart on completion" name="restart" valuePropName="checked">
|
||||
<Checkbox disabled={props.canRestart === false}/>
|
||||
</Form.Item>
|
||||
}
|
||||
{formAction === "remote_update"
|
||||
? <NpmRemoteUpdate {...{setApplyAction, form, target: props.target}}/> : undefined}
|
||||
{formAction === "update_all"
|
||||
? <NpmUpdateAll {...{setApplyAction, form, target: props.target}}/> : undefined}
|
||||
{formAction === "plugin_update"
|
||||
? <NpmPluginUpdate {...{setApplyAction, form, target: props.target}}/> : undefined}
|
||||
{formAction === "plugin_install"
|
||||
? <NpmPluginInstall {...{setApplyAction, form, target: props.target}}/> : undefined}
|
||||
</Form>
|
||||
<p>Updates Clusterio within the version range in the machine's package.json.</p>
|
||||
{canRestart && <Checkbox
|
||||
checked={restart}
|
||||
disabled={props.canRestart === false}
|
||||
onChange={event => { setRestart(event.target.checked); }}
|
||||
>Restart on completion</Checkbox>}
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function hasNpmButtonPermission(controller: boolean) {
|
||||
const account = useAccount();
|
||||
return account.hasAnyPermission(
|
||||
controller ? "core.controller.update" : "core.host.update",
|
||||
"core.plugin.update", "core.plugin.install"
|
||||
);
|
||||
return account.hasPermission(controller ? "core.controller.update" : "core.host.update");
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Button, Descriptions, Input, Modal, Select, Space, Table, Typography } from "antd";
|
||||
import SearchOutlined from "@ant-design/icons/SearchOutlined";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import { useHosts } from "../model/host";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { InstallTarget, PluginInstaller, RemoteInstallDisabledNotice, addressKey } from "./PluginInstall";
|
||||
import { PluginInstalledTag, PluginUpdateButton, PluginVersions } from "./PluginClusterStatus";
|
||||
import type { PluginStatus } from "../util/plugin_status";
|
||||
|
||||
const { Text, Link } = Typography;
|
||||
|
||||
/** Statuses are undefined until the plugin lists of the machines have loaded, or without core.plugin.list */
|
||||
type BrowserProps = { statuses?: Map<string, PluginStatus>, installer: PluginInstaller };
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
// Installs on the chosen machines, defaulting to every connected machine that allows installs.
|
||||
function InstallControls(props: { packageSpec: string, installer: PluginInstaller }) {
|
||||
const account = useAccount();
|
||||
const [hosts] = useHosts();
|
||||
// Tracks what was deselected so machines connecting later, or allowing installs, start selected
|
||||
const [deselected, setDeselected] = useState(new Set<string>());
|
||||
const { access, installing, restarting } = props.installer;
|
||||
|
||||
const machines: (InstallTarget & { key: string, allowed: boolean })[] = [
|
||||
{ name: "Controller", address: "controller" as lib.AddressShorthand },
|
||||
...[...hosts.values()]
|
||||
.filter(host => host.connected)
|
||||
.sort((a, b) => strcmp(a.name, b.name))
|
||||
.map(host => ({ name: host.name, address: { hostId: host.id } as lib.AddressShorthand })),
|
||||
].map(target => ({ ...target, key: addressKey(target.address), allowed: access.canInstall(target.address) }));
|
||||
const selected = machines.filter(machine => machine.allowed && !deselected.has(machine.key));
|
||||
const blocked = machines.filter(machine => !machine.allowed);
|
||||
|
||||
return <Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Space wrap>
|
||||
<Text>Install on</Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ minWidth: 260 }}
|
||||
placeholder="Select machines"
|
||||
optionFilterProp="label"
|
||||
value={selected.map(machine => machine.key)}
|
||||
onChange={keys => {
|
||||
setDeselected(new Set(machines.filter(machine => !keys.includes(machine.key)).map(m => m.key)));
|
||||
}}
|
||||
options={machines.map(machine => ({
|
||||
value: machine.key,
|
||||
label: machine.allowed ? machine.name : `${machine.name} (remote installs disabled)`,
|
||||
disabled: !machine.allowed,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={installing}
|
||||
disabled={!account.hasPermission("core.plugin.install") || !selected.length || restarting}
|
||||
onClick={() => { props.installer.install(props.packageSpec, selected); }}
|
||||
>Install</Button>
|
||||
</Space>
|
||||
{blocked.length > 0 && <RemoteInstallDisabledNotice
|
||||
controller={blocked.some(machine => machine.address === "controller")}
|
||||
hosts={blocked.filter(machine => machine.address !== "controller").map(machine => machine.name)}
|
||||
/>}
|
||||
</Space>;
|
||||
}
|
||||
|
||||
function PluginDetails(props: { plugin: lib.PluginSearchResult } & BrowserProps) {
|
||||
const account = useAccount();
|
||||
const { plugin } = props;
|
||||
const status = props.statuses?.get(plugin.name);
|
||||
// Without list permission the statuses never load, so offer installs without them
|
||||
const statusesKnown = props.statuses !== undefined || !account.hasPermission("core.plugin.list");
|
||||
// Links come from package metadata on npm, only http(s) links are shown so they can't run scripts
|
||||
const links = [
|
||||
{ label: "npm", url: lib.safeExternalUrl(plugin.npm) },
|
||||
{ label: "Homepage", url: lib.safeExternalUrl(plugin.homepage) },
|
||||
{ label: "Repository", url: lib.safeExternalUrl(plugin.repository) },
|
||||
].filter(link => link.url !== undefined);
|
||||
return <>
|
||||
<Descriptions size="small" column={1} bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="Package">{plugin.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="Version">{plugin.version}</Descriptions.Item>
|
||||
{status && <Descriptions.Item label="Installed">
|
||||
<PluginVersions status={status} latest={plugin.version} />
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<PluginUpdateButton status={status} latest={plugin.version} installer={props.installer} />
|
||||
</div>
|
||||
</Descriptions.Item>}
|
||||
{plugin.description && <Descriptions.Item label="Description">{plugin.description}</Descriptions.Item>}
|
||||
{plugin.publisher && <Descriptions.Item label="Publisher">{plugin.publisher}</Descriptions.Item>}
|
||||
{plugin.date && <Descriptions.Item label="Published">
|
||||
{new Date(plugin.date).toLocaleString()}
|
||||
</Descriptions.Item>}
|
||||
{links.length > 0 && <Descriptions.Item label="Links">
|
||||
<Space wrap>
|
||||
{links.map(link => <Link key={link.label} href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.label}
|
||||
</Link>)}
|
||||
</Space>
|
||||
</Descriptions.Item>}
|
||||
</Descriptions>
|
||||
{/* Installed plugins are brought in line by the update action above instead */}
|
||||
{statusesKnown && !status && <InstallControls packageSpec={plugin.name} installer={props.installer} />}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function PluginBrowserButton(props: BrowserProps) {
|
||||
const control = useContext(ControlContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [results, setResults] = useState<lib.PluginSearchResult[]>([]);
|
||||
const [selected, setSelected] = useState<lib.PluginSearchResult | undefined>();
|
||||
// Package entered by name, for plugins the search doesn't find
|
||||
const [manual, setManual] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
let canceled = false;
|
||||
control.send(new lib.PluginSearchRequest(query, page, pageSize)).then(response => {
|
||||
if (canceled) { return; }
|
||||
setTotal(response.total);
|
||||
setResults(response.results);
|
||||
setSelected(current => response.results.find(r => r.name === current?.name));
|
||||
}).catch(notifyErrorHandler("Error searching for plugins")).finally(() => {
|
||||
if (!canceled) { setLoading(false); }
|
||||
});
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => { canceled = true; };
|
||||
}, [open, query, page, pageSize]);
|
||||
|
||||
return <>
|
||||
<Button icon={<SearchOutlined />} onClick={() => { setOpen(true); }}>Browse plugins</Button>
|
||||
<Modal
|
||||
title="Browse plugins"
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
width={1000}
|
||||
footer={<Button onClick={() => { setOpen(false); }}>Close</Button>}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder="Search npm for plugins"
|
||||
allowClear
|
||||
enterButton
|
||||
style={{ marginBottom: 16 }}
|
||||
onSearch={value => { setQuery(value); setPage(1); }}
|
||||
/>
|
||||
<Input.Search
|
||||
placeholder="Or install a package by name, as name or name@version"
|
||||
allowClear
|
||||
enterButton="Select"
|
||||
style={{ marginBottom: 16 }}
|
||||
onSearch={value => { setManual(value.trim() || undefined); setSelected(undefined); }}
|
||||
/>
|
||||
<Table
|
||||
size="small"
|
||||
loading={loading}
|
||||
dataSource={results}
|
||||
rowKey="name"
|
||||
rowClassName={plugin => (plugin.name === selected?.name ? "ant-table-row-selected" : "")}
|
||||
onRow={plugin => ({
|
||||
onClick: () => { setSelected(plugin); setManual(undefined); },
|
||||
style: { cursor: "pointer" },
|
||||
})}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (newPage, newPageSize) => { setPage(newPage); setPageSize(newPageSize); },
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
render: (_, plugin) => {
|
||||
const status = props.statuses?.get(plugin.name);
|
||||
return <>
|
||||
{plugin.name}
|
||||
{status && <span style={{ marginLeft: 8 }}>
|
||||
<PluginInstalledTag status={status} latest={plugin.version} />
|
||||
</span>}
|
||||
</>;
|
||||
},
|
||||
},
|
||||
{ title: "Version", dataIndex: "version", key: "version", width: 120 },
|
||||
{ title: "Publisher", dataIndex: "publisher", key: "publisher", width: 160, responsive: ["md"] },
|
||||
{
|
||||
title: "Description",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
ellipsis: true,
|
||||
responsive: ["lg"],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{selected && <PluginDetails plugin={selected} statuses={props.statuses} installer={props.installer} />}
|
||||
{manual && <>
|
||||
<Descriptions size="small" column={1} bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="Package">{manual}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<InstallControls packageSpec={manual} installer={props.installer} />
|
||||
</>}
|
||||
{!selected && !manual && <Text type="secondary">Select a plugin to see details and install it.</Text>}
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import { ReactNode, useContext, useEffect, useState } from "react";
|
||||
import { Button, Checkbox, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { useAccount } from "../model/account";
|
||||
import { useHosts } from "../model/host";
|
||||
import { useInstances } from "../model/instance";
|
||||
import { useSystems } from "../model/system";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import { poll } from "../util/poll";
|
||||
import ControlContext from "./ControlContext";
|
||||
import Link from "./Link";
|
||||
import { PackageOperation, PluginInstaller, addressKey } from "./PluginInstall";
|
||||
import {
|
||||
Machine, MachinePlugin, MachinePlugins, PluginStatus, SkippedOperation, UpdatePlan,
|
||||
compareVersions, machineStatusLabels, planMachineUpdate, planUpdate,
|
||||
} from "../util/plugin_status";
|
||||
|
||||
const { Text } = Typography;
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
const latestVersionsRefreshMs = 60e3;
|
||||
|
||||
function plural(count: number, word: string) {
|
||||
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function machinePath(machine: Machine) {
|
||||
return machine.isController ? "/controller" : `/hosts/${(machine.address as { hostId: number }).hostId}/view`;
|
||||
}
|
||||
|
||||
type ListedPlugins = { plugins: lib.PluginDetails[], systemUpdatedAtMs?: number };
|
||||
type MachinePluginsState = {
|
||||
machines?: MachinePlugins[],
|
||||
unknown: Machine[],
|
||||
/** Fetch the plugin lists of connected machines now, instead of waiting for the next system metrics */
|
||||
refresh: (addresses?: lib.AddressShorthand[]) => Promise<void>,
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugins of the controller and every host, as last collected with the system metrics. Offline
|
||||
* hosts keep the plugins they had when last seen.
|
||||
*/
|
||||
export function useMachinePlugins(): MachinePluginsState {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [hosts, hostsSynced] = useHosts();
|
||||
const [systems, systemsSynced] = useSystems();
|
||||
// Lists fetched directly, used until the system metrics of the machine update
|
||||
const [listed, setListed] = useState(new Map<string, ListedPlugins>());
|
||||
const canList = account.hasPermission("core.plugin.list");
|
||||
|
||||
const allMachines: Machine[] = [
|
||||
{ name: "Controller", isController: true, address: "controller" },
|
||||
...[...hosts.values()]
|
||||
.filter(host => !host.isDeleted)
|
||||
.sort((a, b) => strcmp(a.name, b.name))
|
||||
.map(host => ({
|
||||
name: host.name, isController: false, address: { hostId: host.id }, offline: !host.connected,
|
||||
})),
|
||||
];
|
||||
const systemOf = (machine: Machine) => systems.get(
|
||||
machine.isController ? "controller" : (machine.address as { hostId: number }).hostId
|
||||
);
|
||||
|
||||
async function refresh(addresses?: lib.AddressShorthand[]) {
|
||||
const keys = addresses && new Set(addresses.map(addressKey));
|
||||
const targets = allMachines.filter(
|
||||
machine => !machine.offline && (!keys || keys.has(addressKey(machine.address)))
|
||||
);
|
||||
const results = await Promise.all(targets.map(async machine => {
|
||||
try {
|
||||
const plugins = await control.sendTo(machine.address, new lib.PluginListRequest());
|
||||
const systemUpdatedAtMs = systemOf(machine)?.updatedAtMs;
|
||||
return [addressKey(machine.address), { plugins, systemUpdatedAtMs }] as const;
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}));
|
||||
setListed(current => new Map([...current, ...results.filter(result => result !== undefined)]));
|
||||
}
|
||||
|
||||
// Covers machines that don't report plugins in their metrics, and metrics being turned off
|
||||
const connectedKey = allMachines.filter(machine => !machine.offline).map(m => addressKey(m.address)).join(",");
|
||||
useEffect(() => {
|
||||
if (canList && hostsSynced) {
|
||||
refresh();
|
||||
}
|
||||
}, [canList, hostsSynced, connectedKey]);
|
||||
|
||||
if (!canList || !hostsSynced || (!systemsSynced && !listed.size)) {
|
||||
return { machines: undefined, unknown: [], refresh };
|
||||
}
|
||||
const machines: MachinePlugins[] = [];
|
||||
const unknown: Machine[] = [];
|
||||
for (const machine of allMachines) {
|
||||
const system = systemOf(machine);
|
||||
const fresh = listed.get(addressKey(machine.address));
|
||||
const plugins = fresh && (!system?.plugins || fresh.systemUpdatedAtMs === system.updatedAtMs)
|
||||
? fresh.plugins
|
||||
: system?.plugins;
|
||||
if (plugins) {
|
||||
machines.push({ ...machine, plugins });
|
||||
} else {
|
||||
unknown.push(machine);
|
||||
}
|
||||
}
|
||||
return { machines, unknown, refresh };
|
||||
}
|
||||
|
||||
/** Latest version published on npm of each package */
|
||||
export function useLatestVersions(packages: string[]) {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [versions, setVersions] = useState<Record<string, string>>({});
|
||||
const packagesKey = [...new Set(packages)].sort().join(",");
|
||||
const canList = account.hasPermission("core.plugin.list");
|
||||
|
||||
useEffect(() => {
|
||||
if (!packagesKey || !canList) {
|
||||
return undefined;
|
||||
}
|
||||
let canceled = false;
|
||||
let notified = false;
|
||||
// Packages the registry couldn't be reached for are left out, so look them up again later.
|
||||
// The controller caches the ones it found.
|
||||
const stop = poll(async () => {
|
||||
try {
|
||||
const response = await control.send(new lib.PluginLatestVersionsRequest(packagesKey.split(",")));
|
||||
if (!canceled) {
|
||||
// Merged so a lookup failing on a later refresh doesn't hide what was found before
|
||||
setVersions(current => ({ ...current, ...response.versions }));
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!canceled && !notified) {
|
||||
notified = true;
|
||||
notifyErrorHandler("Failed to look up latest plugin versions")(err);
|
||||
}
|
||||
}
|
||||
}, latestVersionsRefreshMs);
|
||||
return () => { canceled = true; stop(); };
|
||||
}, [packagesKey, canList]);
|
||||
|
||||
return versions;
|
||||
}
|
||||
|
||||
function isPending(machine: MachinePlugin) {
|
||||
return machine.running !== machine.afterRestart;
|
||||
}
|
||||
|
||||
function CountLine(props: { label: string, count: number, type?: "warning" | "secondary" }) {
|
||||
return <div><Text type={props.type}>{props.label} {props.count}</Text></div>;
|
||||
}
|
||||
|
||||
/** One line per version running now or after a restart, plus restart pending, disabled and missing counts */
|
||||
export function PluginVersions(props: { status: PluginStatus, latest?: string }) {
|
||||
const { machines, unknown } = props.status;
|
||||
const versions = [...new Set(machines.flatMap(machine => [machine.running, machine.afterRestart]))]
|
||||
.filter((version): version is string => version !== undefined)
|
||||
.sort(compareVersions)
|
||||
.reverse();
|
||||
const count = (filter: (machine: MachinePlugin) => boolean) => machines.filter(filter).length;
|
||||
const missing = count(machine => (!machine.plugin || Boolean(machine.plugin.removed)) && !machine.nameTaken);
|
||||
|
||||
return <>
|
||||
{versions.map(version => {
|
||||
const now = count(machine => machine.running === version);
|
||||
const after = count(machine => machine.afterRestart === version);
|
||||
return <div key={version}>
|
||||
<Text>{version}</Text>
|
||||
{props.latest === version && <Tag color="success" style={{ marginLeft: 6 }}>latest</Tag>}
|
||||
<Text type="secondary" style={{ marginLeft: 12 }}>
|
||||
{plural(now, "machine")}{now !== after && ` → ${after} after restart`}
|
||||
</Text>
|
||||
</div>;
|
||||
})}
|
||||
{count(isPending) > 0 && <CountLine label="Restart pending on" count={count(isPending)} />}
|
||||
{count(machine => machine.disabled) > 0 && <CountLine
|
||||
label="Disabled on" count={count(machine => machine.disabled)} type="secondary"
|
||||
/>}
|
||||
{count(machine => machine.noEntrypoint) > 0 && <CountLine
|
||||
label="No entrypoint on" count={count(machine => machine.noEntrypoint)} type="secondary"
|
||||
/>}
|
||||
{count(machine => machine.nameTaken) > 0 && <CountLine
|
||||
label="From another package on" count={count(machine => machine.nameTaken)} type="secondary"
|
||||
/>}
|
||||
{missing > 0 && <CountLine label="Not installed on" count={missing} type="warning" />}
|
||||
{unknown.length > 0 && <CountLine label="Plugins not reported by" count={unknown.length} type="warning" />}
|
||||
</>;
|
||||
}
|
||||
|
||||
type ChecklistItem = { key: string, label: ReactNode, disabledReason?: string };
|
||||
type ChecklistModalProps = {
|
||||
open: boolean,
|
||||
title: string,
|
||||
okText: string,
|
||||
danger?: boolean,
|
||||
items: ChecklistItem[],
|
||||
extra?: ReactNode,
|
||||
onCancel: () => void,
|
||||
onOk: (keys: string[]) => void,
|
||||
};
|
||||
|
||||
/** Confirms a list of actions, all ticked by default. Actions that can't be done are disabled with the reason. */
|
||||
function ChecklistModal(props: ChecklistModalProps) {
|
||||
const [unchecked, setUnchecked] = useState(new Set<string>());
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
setUnchecked(new Set());
|
||||
}
|
||||
}, [props.open]);
|
||||
const possible = props.items.filter(item => !item.disabledReason);
|
||||
const selected = possible.filter(item => !unchecked.has(item.key));
|
||||
const toggle = (key: string, checked: boolean) => setUnchecked(current => {
|
||||
const next = new Set(current);
|
||||
if (checked) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
return <Modal
|
||||
title={props.title}
|
||||
open={props.open}
|
||||
okText={props.okText}
|
||||
okButtonProps={{ danger: props.danger, disabled: !selected.length }}
|
||||
onOk={() => props.onOk(selected.map(item => item.key))}
|
||||
onCancel={props.onCancel}
|
||||
destroyOnHidden
|
||||
>
|
||||
{props.extra}
|
||||
{possible.length > 1 && <Checkbox
|
||||
style={{ margin: "8px 0" }}
|
||||
checked={selected.length === possible.length}
|
||||
indeterminate={selected.length > 0 && selected.length < possible.length}
|
||||
onChange={event => setUnchecked(new Set(event.target.checked ? [] : possible.map(item => item.key)))}
|
||||
>All ({selected.length}/{possible.length})</Checkbox>}
|
||||
<div style={{ maxHeight: 320, overflowY: "auto" }}>
|
||||
{props.items.map(item => <div key={item.key}>
|
||||
<Checkbox
|
||||
checked={!item.disabledReason && !unchecked.has(item.key)}
|
||||
disabled={Boolean(item.disabledReason)}
|
||||
onChange={event => toggle(item.key, event.target.checked)}
|
||||
>
|
||||
{item.label}
|
||||
{item.disabledReason && <Text type="secondary"> ({item.disabledReason})</Text>}
|
||||
</Checkbox>
|
||||
</div>)}
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
type Account = ReturnType<typeof useAccount>;
|
||||
|
||||
function canChangePlugins(account: Account) {
|
||||
return account.hasPermission("core.plugin.install") || account.hasPermission("core.plugin.update");
|
||||
}
|
||||
|
||||
function operationKey(change: PackageOperation | SkippedOperation, title: string) {
|
||||
return `${title}:${change.kind}:${addressKey(change.target.address)}`;
|
||||
}
|
||||
|
||||
type PlannedUpdate = { title: string, latest: string, plan: UpdatePlan };
|
||||
|
||||
/** One checklist item per operation and skipped operation */
|
||||
function planItems(updates: PlannedUpdate[], withTitle: boolean) {
|
||||
const operations = new Map<string, PackageOperation>();
|
||||
const items: ChecklistItem[] = [];
|
||||
for (const { title, latest, plan } of updates) {
|
||||
const label = (change: PackageOperation | SkippedOperation) => <>
|
||||
{withTitle && <Text strong>{title}: </Text>}
|
||||
{change.kind === "install" ? "Install" : "Update to"} {latest} on {change.target.name}
|
||||
</>;
|
||||
for (const operation of plan.operations) {
|
||||
const key = operationKey(operation, title);
|
||||
operations.set(key, operation);
|
||||
items.push({ key, label: label(operation) });
|
||||
}
|
||||
for (const skipped of plan.skipped) {
|
||||
items.push({ key: operationKey(skipped, title), label: label(skipped), disabledReason: skipped.reason });
|
||||
}
|
||||
}
|
||||
return { items, operations };
|
||||
}
|
||||
|
||||
type UpdateButtonProps = { status: PluginStatus, title?: string, latest?: string, installer: PluginInstaller };
|
||||
|
||||
/** Updates a plugin to the latest version on every machine */
|
||||
export function PluginUpdateButton(props: UpdateButtonProps) {
|
||||
const account = useAccount();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { status, latest, installer } = props;
|
||||
if (!latest || !canChangePlugins(account)) {
|
||||
return null;
|
||||
}
|
||||
const plan = planUpdate(status, latest, installer.access, account);
|
||||
const changes = [...plan.operations, ...plan.skipped];
|
||||
if (!changes.length) {
|
||||
return <Tooltip title="Up to date on every machine">
|
||||
<Button size="small" disabled>Update to {latest}</Button>
|
||||
</Tooltip>;
|
||||
}
|
||||
const onlyInstalls = changes.every(change => change.kind === "install");
|
||||
const label = onlyInstalls ? `Install on missing (${changes.length})` : `Update to ${latest}`;
|
||||
const title = props.title ?? status.title;
|
||||
const { items, operations } = planItems([{ title, latest, plan }], false);
|
||||
|
||||
return <>
|
||||
<Button
|
||||
size="small"
|
||||
loading={installer.installing}
|
||||
disabled={installer.restarting}
|
||||
onClick={() => setOpen(true)}
|
||||
>{label}</Button>
|
||||
<ChecklistModal
|
||||
open={open}
|
||||
title={`${onlyInstalls ? "Install" : "Update"} ${title} ${latest}`}
|
||||
okText={onlyInstalls ? "Install" : "Update"}
|
||||
items={items}
|
||||
extra={<p>Machines need a restart afterwards.</p>}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={keys => {
|
||||
setOpen(false);
|
||||
installer.run(keys.map(key => operations.get(key)!));
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
type UpdateAllProps = {
|
||||
statuses: PluginStatus[],
|
||||
/** Display names of plugins by status key, falling back to the title reported by the machines */
|
||||
titles?: Map<string, string>,
|
||||
latestVersions: Record<string, string>,
|
||||
installer: PluginInstaller,
|
||||
};
|
||||
|
||||
/** Updates every plugin with a newer version to the latest version on every machine */
|
||||
export function UpdateAllButton(props: UpdateAllProps) {
|
||||
const account = useAccount();
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!canChangePlugins(account)) {
|
||||
return null;
|
||||
}
|
||||
const updates = props.statuses.flatMap(status => {
|
||||
const latest = status.npmPackage && props.latestVersions[status.npmPackage];
|
||||
if (!latest) {
|
||||
return [];
|
||||
}
|
||||
const plan = planUpdate(status, latest, props.installer.access, account);
|
||||
const title = props.titles?.get(status.key) ?? status.title;
|
||||
return plan.operations.length || plan.skipped.length ? [{ title, latest, plan }] : [];
|
||||
});
|
||||
const pluginCount = updates.filter(update => update.plan.operations.length).length;
|
||||
const { items, operations } = planItems(updates, true);
|
||||
|
||||
return <>
|
||||
<Tooltip title={pluginCount ? undefined : "No plugin updates can be done"}>
|
||||
<Button
|
||||
loading={props.installer.installing}
|
||||
disabled={!pluginCount || props.installer.restarting}
|
||||
onClick={() => setOpen(true)}
|
||||
>Update all ({pluginCount})</Button>
|
||||
</Tooltip>
|
||||
<ChecklistModal
|
||||
open={open}
|
||||
title={`Update ${plural(pluginCount, "plugin")} to the latest version`}
|
||||
okText="Update"
|
||||
items={items}
|
||||
extra={<p>Machines need a restart afterwards.</p>}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={keys => {
|
||||
setOpen(false);
|
||||
props.installer.run(keys.map(key => operations.get(key)!));
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
type RestartPendingProps = {
|
||||
statuses: PluginStatus[],
|
||||
machines: MachinePlugins[],
|
||||
installer: PluginInstaller,
|
||||
};
|
||||
|
||||
/** Restarts the chosen machines with a pending restart, hosts first and the controller last */
|
||||
export function RestartPendingButton(props: RestartPendingProps) {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [systems] = useSystems();
|
||||
const [instances] = useInstances();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { installer } = props;
|
||||
const permitted = (machine: Machine) => account.hasPermission(
|
||||
machine.isController ? "core.controller.restart" : "core.host.restart"
|
||||
);
|
||||
if (!account.hasPermission("core.controller.restart") && !account.hasPermission("core.host.restart")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pendingKeys = new Set(props.statuses.flatMap(
|
||||
status => status.machines.filter(isPending).map(machine => addressKey(machine.address))
|
||||
));
|
||||
const systemOf = (machine: Machine) => systems.get(
|
||||
machine.isController ? "controller" : (machine.address as { hostId: number }).hostId
|
||||
);
|
||||
const pending = props.machines.filter(
|
||||
machine => pendingKeys.has(addressKey(machine.address)) || systemOf(machine)?.restartRequired
|
||||
);
|
||||
const cannotRestart = (machine: Machine) => {
|
||||
if (machine.offline) {
|
||||
return "offline";
|
||||
}
|
||||
if (!permitted(machine)) {
|
||||
return "no restart permission";
|
||||
}
|
||||
if (!systemOf(machine)?.canRestart) {
|
||||
return "not running under a process monitor";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const runningInstances = (machine: Machine) => [...instances.values()].filter(instance => (
|
||||
!machine.isController
|
||||
&& instance.assignedHost === (machine.address as { hostId: number }).hostId
|
||||
&& !["unknown", "unassigned", "stopped", "deleted"].includes(instance.status)
|
||||
)).length;
|
||||
const items = pending.map(machine => {
|
||||
const count = runningInstances(machine);
|
||||
return {
|
||||
key: addressKey(machine.address),
|
||||
label: <>
|
||||
{machine.name}
|
||||
{count > 0 && <Text type="warning"> stops {plural(count, "running instance")}</Text>}
|
||||
</>,
|
||||
disabledReason: cannotRestart(machine),
|
||||
};
|
||||
});
|
||||
const blocked = items.filter(item => item.disabledReason).length;
|
||||
|
||||
async function restartMachines(restart: MachinePlugins[]) {
|
||||
const hosts = restart.filter(machine => !machine.isController);
|
||||
const results = await Promise.allSettled(hosts.map(
|
||||
machine => control.sendTo(machine.address, new lib.HostRestartRequest())
|
||||
));
|
||||
const started: Machine[] = [];
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === "rejected") {
|
||||
notifyErrorHandler(`Failed to restart ${hosts[i].name}`)(result.reason);
|
||||
} else {
|
||||
started.push(hosts[i]);
|
||||
}
|
||||
});
|
||||
// The controller goes last as restarting it disconnects this page until it's back
|
||||
const controller = restart.find(machine => machine.isController);
|
||||
if (controller) {
|
||||
try {
|
||||
await control.send(new lib.ControllerRestartRequest());
|
||||
started.push(controller);
|
||||
} catch (err: any) {
|
||||
notifyErrorHandler("Failed to restart the controller")(err);
|
||||
}
|
||||
}
|
||||
if (started.length) {
|
||||
notify(`Restarting ${plural(started.length, "machine")}`, "info");
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<Tooltip title={pending.length
|
||||
? `${plural(pending.length, "machine")} need a restart`
|
||||
: "No machine needs a restart"
|
||||
}>
|
||||
<Button
|
||||
loading={installer.restarting}
|
||||
disabled={!pending.length || installer.installing}
|
||||
onClick={() => setOpen(true)}
|
||||
>Restart pending ({pending.length})</Button>
|
||||
</Tooltip>
|
||||
<ChecklistModal
|
||||
open={open}
|
||||
title="Restart machines with a pending restart"
|
||||
okText="Restart"
|
||||
danger
|
||||
items={items}
|
||||
extra={blocked > 0 && <p>
|
||||
{plural(blocked, "machine")} can't be restarted from here and must be restarted by hand.
|
||||
The <Link to="/hosts">hosts list</Link> will show which need to be restarted.
|
||||
</p>}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={keys => {
|
||||
setOpen(false);
|
||||
const restart = pending.filter(machine => keys.includes(addressKey(machine.address)));
|
||||
installer.restart(() => restartMachines(restart));
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
type MachineRow = MachinePlugin | (Machine & { unknown: true });
|
||||
|
||||
type MachineActionProps = { status: PluginStatus, machine: MachinePlugin, latest: string, installer: PluginInstaller };
|
||||
|
||||
/** Install or update action for one machine, disabled with the reason when it can't be done */
|
||||
function MachineAction(props: MachineActionProps) {
|
||||
const account = useAccount();
|
||||
const { status, machine, latest, installer } = props;
|
||||
const plan = planMachineUpdate(status, machine, latest, installer.access, account);
|
||||
const [operation] = plan.operations;
|
||||
const [skipped] = plan.skipped;
|
||||
const change = operation ?? skipped;
|
||||
if (!change) {
|
||||
return null;
|
||||
}
|
||||
const label = `${change.kind === "install" ? "Install" : "Update to"} ${latest}`;
|
||||
if (!operation) {
|
||||
return <Tooltip title={skipped.reason}><Button size="small" disabled>{label}</Button></Tooltip>;
|
||||
}
|
||||
return <Popconfirm
|
||||
title={`${label} on ${machine.name}?`}
|
||||
description="The machine needs a restart afterwards."
|
||||
onConfirm={() => installer.run([operation])}
|
||||
>
|
||||
<Button size="small" loading={installer.installing} disabled={installer.restarting}>{label}</Button>
|
||||
</Popconfirm>;
|
||||
}
|
||||
|
||||
/** Version and status of a plugin on each machine */
|
||||
export function PluginMachinesTable(props: { status: PluginStatus, latest?: string, installer: PluginInstaller }) {
|
||||
const account = useAccount();
|
||||
const { status, latest, installer } = props;
|
||||
const rows: MachineRow[] = [
|
||||
...status.machines,
|
||||
...status.unknown.map(machine => ({ ...machine, unknown: true as const })),
|
||||
];
|
||||
return <Table
|
||||
size="small"
|
||||
dataSource={rows}
|
||||
rowKey={machine => addressKey(machine.address)}
|
||||
pagination={{ defaultPageSize: 20, hideOnSinglePage: true }}
|
||||
columns={[
|
||||
{
|
||||
title: "Machine",
|
||||
key: "name",
|
||||
render: (_, machine) => <Link to={machinePath(machine)}>{machine.name}</Link>,
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
},
|
||||
{
|
||||
title: "Running",
|
||||
key: "running",
|
||||
render: (_, machine) => ("unknown" in machine ? null : <>
|
||||
{machine.running ?? <Text type="secondary">None</Text>}
|
||||
{machine.running !== undefined && machine.running === latest
|
||||
&& <Tag color="success" style={{ marginLeft: 6 }}>latest</Tag>}
|
||||
</>),
|
||||
},
|
||||
{
|
||||
title: "After restart",
|
||||
key: "afterRestart",
|
||||
render: (_, machine) => ("unknown" in machine || !isPending(machine)
|
||||
? null
|
||||
: machine.afterRestart ?? <Text type="secondary">None</Text>),
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
key: "status",
|
||||
render: (_, machine) => <Space size={[0, 4]} wrap>
|
||||
{("unknown" in machine ? ["Plugins not reported"] : machineStatusLabels(machine))
|
||||
.map(label => <Tag key={label}>{label}</Tag>)}
|
||||
</Space>,
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
align: "right",
|
||||
render: (_, machine) => (latest && !("unknown" in machine) && canChangePlugins(account)
|
||||
? <MachineAction status={status} machine={machine} latest={latest} installer={installer} />
|
||||
: null),
|
||||
},
|
||||
]}
|
||||
/>;
|
||||
}
|
||||
|
||||
/** Compact summary for search results: how many machines have the plugin installed */
|
||||
export function PluginInstalledTag(props: { status: PluginStatus, latest?: string }) {
|
||||
const { machines } = props.status;
|
||||
const installed = machines.filter(machine => machine.plugin && !machine.plugin.removed);
|
||||
const current = machines.filter(machine => machine.afterRestart === props.latest);
|
||||
const color = current.length === machines.length ? "success" : "warning";
|
||||
return <Tooltip title={<PluginVersions status={props.status} latest={props.latest} />}>
|
||||
<Tag color={color}>Installed on {installed.length}</Tag>
|
||||
</Tooltip>;
|
||||
}
|
||||
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user