mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +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') }}
|
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
|
||||||
restore-keys: pnpm-store-${{ runner.os }}-
|
restore-keys: pnpm-store-${{ runner.os }}-
|
||||||
|
|
||||||
# The prepare scripts run tsc --build and webpack, both incremental when
|
# The prepare scripts run tsc --build, which is incremental when its
|
||||||
# their previous output is present. Every run saves its output and the
|
# previous output is present. Every run saves its output and the next run
|
||||||
# next run starts from the most recent one.
|
# starts from the most recent one.
|
||||||
- if: inputs.ignore-scripts != 'true'
|
- if: inputs.ignore-scripts != 'true'
|
||||||
uses: actions/cache@v6
|
uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
packages/*/dist
|
packages/*/dist
|
||||||
plugins/*/dist
|
plugins/*/dist
|
||||||
packages/*/node_modules/.cache
|
|
||||||
plugins/*/node_modules/.cache
|
|
||||||
key: build-${{ runner.os }}-${{ github.sha }}
|
key: build-${{ runner.os }}-${{ github.sha }}
|
||||||
restore-keys: build-${{ runner.os }}-
|
restore-keys: build-${{ runner.os }}-
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ jobs:
|
|||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
- run: pnpm install --no-frozen-lockfile
|
- run: pnpm install --no-frozen-lockfile
|
||||||
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish --no-git-checks
|
- 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
|
- uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: pnpm-lock.yaml
|
files: pnpm-lock.yaml
|
||||||
|
|||||||
+1
-3
@@ -23,9 +23,6 @@ coverage
|
|||||||
build/Release
|
build/Release
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# Autogenerated docs output
|
|
||||||
/docs/api
|
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
/factorio*
|
/factorio*
|
||||||
@@ -35,6 +32,7 @@ node_modules
|
|||||||
/plugin-list.json
|
/plugin-list.json
|
||||||
/config-*.json
|
/config-*.json
|
||||||
/config-*.json.lock
|
/config-*.json.lock
|
||||||
|
/npm-operation.lock*
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
mod_cache.json
|
mod_cache.json
|
||||||
.luarc.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.
|
- Read the relevant source and tests before changing behavior.
|
||||||
If documentation and implementation disagree, report the discrepancy and distinguish observed behavior from intended policy.
|
If documentation and implementation disagree, report the discrepancy and distinguish observed behavior from intended policy.
|
||||||
Update affected documentation in the same change.
|
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.
|
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).
|
- 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.
|
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)
|
[@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
|
## Version 2.0.3
|
||||||
|
|
||||||
### Features
|
### 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:
|
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.
|
- [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.
|
- [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.
|
- [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.
|
- [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"))
|
tryRemove(path.join(basePath, packageName, "dist"))
|
||||||
);
|
);
|
||||||
tasks.push(
|
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.
|
`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.
|
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)`.
|
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.
|
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"]
|
let value = configInstance.fields["group.field"]
|
||||||
|
|
||||||
The set method also takes an extra argument, which is the value to set the field to.
|
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
|
## 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.
|
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.
|
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.
|
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.
|
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
|
### controller.factorio_username
|
||||||
|
|
||||||
Username of the Factorio.com account to authenticate with the Factorio API with.
|
Username of the Factorio.com account to authenticate with the Factorio API with.
|
||||||
@@ -382,11 +401,13 @@ Defaults to "localhost".
|
|||||||
### host.factorio_port_range
|
### host.factorio_port_range
|
||||||
|
|
||||||
UDP ports the host hands out to instances that don't have factorio.game_port set.
|
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.
|
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.
|
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
|
### host.factorio_username
|
||||||
@@ -490,6 +511,15 @@ Does not affect start or stop of the instance while the host is running.
|
|||||||
Defaults to false.
|
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
|
### instance.exclude_from_start_all
|
||||||
|
|
||||||
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
|
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`.
|
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.
|
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)
|
- [Checking your code for errors](#checking-your-code-for-errors)
|
||||||
- [Cleaning generated and runtime files](#cleaning-generated-and-runtime-files)
|
- [Cleaning generated and runtime files](#cleaning-generated-and-runtime-files)
|
||||||
- [Submitting a Pull Request](#submitting-a-pull-request)
|
- [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)
|
- [Editing your Feature Branch](#editing-your-feature-branch)
|
||||||
- [Resolving Merge Conflicts](#resolving-merge-conflicts)
|
- [Resolving Merge Conflicts](#resolving-merge-conflicts)
|
||||||
- [Updating Your GitHub Fork](#updating-your-github-fork)
|
- [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/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/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/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. |
|
| `packages/create/` | Installer and JavaScript/TypeScript plugin templates. |
|
||||||
| `plugins/` | Bundled plugins, including their Node, web, Lua, and test code. |
|
| `plugins/` | Bundled plugins, including their Node, web, Lua, and test code. |
|
||||||
| `external_plugins/` | Plugins developed in independent repositories and included in the local workspace. |
|
| `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:
|
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.
|
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.
|
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
|
### 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 watch` | TypeScript compilation as files change; restart affected Node processes afterward. |
|
||||||
| `pnpm -r run prepare` | Compiled packages, generated validators, and production web bundles. |
|
| `pnpm -r run prepare` | Compiled packages, generated validators, and production web bundles. |
|
||||||
| `pnpm build-mod` | Factorio mod archives in root `dist/`. |
|
| `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.
|
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 |
|
| 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 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`. |
|
| `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.
|
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.
|
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
|
### Editing your Feature Branch
|
||||||
|
|
||||||
Until your feature branch is merged you can edit it as you see fit by using the rebase tool.
|
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)
|
- [Naming Times and Durations](#naming-times-and-durations)
|
||||||
- [Strings](#strings)
|
- [Strings](#strings)
|
||||||
3. Code Style (TS / JS / Web)
|
3. Code Style (TS / JS / Web)
|
||||||
- [ESLint](#eslint)
|
- [Oxlint](#oxlint)
|
||||||
- [Imports Within `lib` Package](#imports-within-lib-package)
|
- [Imports Within `lib` Package](#imports-within-lib-package)
|
||||||
- [Name Style](#name-style-ts)
|
- [Name Style](#name-style-ts)
|
||||||
4. Code Style (Lua)
|
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)
|
- [React](#react)
|
||||||
- [Separation of Host and Controller](#separation-of-host-and-controller)
|
- [Separation of Host and Controller](#separation-of-host-and-controller)
|
||||||
- [Typescript](#typescript)
|
- [Typescript](#typescript)
|
||||||
- [Webpack](#webpack)
|
- [Vite](#vite)
|
||||||
8. Supportability
|
8. Supportability
|
||||||
- [Breaking Changes](#breaking-changes)
|
- [Breaking Changes](#breaking-changes)
|
||||||
- [Distribution](#distribution)
|
- [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.
|
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.
|
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)
|
## Code Style (General)
|
||||||
|
|
||||||
@@ -157,14 +162,15 @@ Therefore, we selected double quotes as our standard.
|
|||||||
|
|
||||||
## Code Style (TS / JS / Web)
|
## 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.
|
We needed to maintain a consistent style across all our files and used ESLint for it from the start.
|
||||||
Therefore, we utilise the industry-standard ESLint, as it is familiar and highly configurable.
|
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
|
### 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.
|
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.
|
We used Webpack with Module Federation for loading plugins from the start, as it was the standard at the time.
|
||||||
While we have considered alternatives since, transitioning to another solution would be a significant undertaking.
|
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
|
## 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.
|
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.
|
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.
|
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
|
### 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.
|
- `src: Address` - Responding endpoint.
|
||||||
- `dst: Address` - Requester's address, including the original request ID.
|
- `dst: Address` - Requester's address, including the original request ID.
|
||||||
- `data.message: string` - Human-readable error description.
|
- `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.
|
- `data.stack: string` (optional) - Error stack.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -283,10 +283,15 @@ This command lists all plugins currently known to the remote. Newly installed pl
|
|||||||
|
|
||||||
### Update
|
### Update
|
||||||
|
|
||||||
ctl> controller plugin update <name> [--restart]
|
ctl> controller plugin update <name>[@<version>] [--restart]
|
||||||
ctl> host plugin update <host> <name> [--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
|
### Update All
|
||||||
|
|
||||||
@@ -297,10 +302,30 @@ Updates the Clusterio package and all plugins installed from npm in a single `np
|
|||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
ctl> controller plugin install <name> [--restart]
|
ctl> controller plugin install <name>[@<version>] [--restart]
|
||||||
ctl> host plugin install <host> <name> [--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
|
### 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.
|
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
|
## Exercise the boundaries
|
||||||
|
|
||||||
- Write a small matrix of relevant states and transitions before probing.
|
- 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.
|
Object mapping ctl configuration fields to `FieldDefinition` objects.
|
||||||
|
|
||||||
**webEntrypoint**:
|
**webEntrypoint**:
|
||||||
Optional Module Federation exposure key for the web entrypoint, such as `"./web"`.
|
Optional key of the web entrypoint in the plugin's web bundle, this should be `"./web"`.
|
||||||
It must match the key in the plugin's webpack `exposes` configuration; it is not a Node.js file path.
|
The `clusterioPlugin` Vite plugin exposes `web/index.tsx` (or `.ts`, `.jsx`, `.js`) under this key; it is not a Node.js file path.
|
||||||
|
|
||||||
**migrationEntrypoint**:
|
**migrationEntrypoint**:
|
||||||
Path to a Node.js module relative to the plugin directory which exports the migration entrypoint function for this plugin.
|
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.
|
Array of request and event classes for this plugin.
|
||||||
See guide for [defining link messages](#defining-link-messages) below.
|
See guide for [defining link messages](#defining-link-messages) below.
|
||||||
|
|
||||||
|
**errors**:
|
||||||
|
Array of error classes for this plugin.
|
||||||
|
See [Request errors](#request-errors).
|
||||||
|
|
||||||
**permissions**:
|
**permissions**:
|
||||||
Array of permission definitions for this plugin.
|
Array of permission definitions for this plugin.
|
||||||
See [Plugin Permissions](#plugin-permissions)
|
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 `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.
|
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
|
### 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.
|
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.
|
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.
|
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:
|
For example, this checks the payload shape needed to choose a permission; the destination schema still validates the full payload:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { PermissionError } from "@clusterio/lib";
|
import { ValidationError } from "@clusterio/lib";
|
||||||
|
|
||||||
function checkFrobnicatePermission(user, message) {
|
function checkFrobnicatePermission(user, message) {
|
||||||
const data = message.data;
|
const data = message.data;
|
||||||
if (!data || typeof data !== "object" || typeof data.special !== "boolean") {
|
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");
|
user.checkPermission(data.special ? "foo_frobber.special" : "foo_frobber.frobnicate");
|
||||||
}
|
}
|
||||||
@@ -740,6 +763,54 @@ connection.sendTo({ instanceId: 123 }, message);
|
|||||||
connection.sendTo({ controlId: 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
|
### 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.
|
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",
|
"cover": "c8 pnpm test",
|
||||||
"fast-cover": "FAST_TEST=y c8 pnpm test",
|
"fast-cover": "FAST_TEST=y c8 pnpm test",
|
||||||
"ci-cover": "c8 --reporter=lcovonly pnpm test",
|
"ci-cover": "c8 --reporter=lcovonly pnpm test",
|
||||||
"lint": "eslint packages plugins test",
|
"lint": "oxlint packages plugins test",
|
||||||
"lint-fix": "eslint --fix packages plugins test",
|
"lint-fix": "oxlint --fix packages plugins test",
|
||||||
"clean": "node ./clean.js",
|
"clean": "node ./clean.js",
|
||||||
"docs": "typedoc",
|
|
||||||
"bundle-dependencies": "bundle-dependencies"
|
"bundle-dependencies": "bundle-dependencies"
|
||||||
},
|
},
|
||||||
"c8": {
|
"c8": {
|
||||||
@@ -43,22 +42,14 @@
|
|||||||
"@clusterio/host": "workspace:^",
|
"@clusterio/host": "workspace:^",
|
||||||
"@clusterio/lib": "workspace:^",
|
"@clusterio/lib": "workspace:^",
|
||||||
"@clusterio/web_ui": "workspace:^",
|
"@clusterio/web_ui": "workspace:^",
|
||||||
"@eslint/eslintrc": "^3.3.7",
|
|
||||||
"@eslint/js": "^10.0.1",
|
|
||||||
"@stylistic/eslint-plugin": "^5.10.0",
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"@swc/core": "catalog:",
|
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"c8": "^12.0.0",
|
"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:",
|
"express": "catalog:",
|
||||||
"form-data": "^4.0.6",
|
"form-data": "^4.0.6",
|
||||||
"globals": "^17.13.0",
|
|
||||||
"jsonwebtoken": "catalog:",
|
"jsonwebtoken": "catalog:",
|
||||||
"mocha": "^11.8.0",
|
"mocha": "^11.8.0",
|
||||||
"typedoc": "^0.28.20",
|
"oxlint": "^1.88.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"yargs": "catalog:",
|
"yargs": "catalog:",
|
||||||
"yauzl": "catalog:",
|
"yauzl": "catalog:",
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
webpack.config.js
|
vite.config.js
|
||||||
dist/browser
|
dist/browser
|
||||||
@@ -147,15 +147,15 @@ async function handleBootstrapCommand(
|
|||||||
path.join(databaseDirectory, "roles.json"),
|
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"),
|
path.join(databaseDirectory, "roles.json"),
|
||||||
lib.Role.fromJSON.bind(lib.Role),
|
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"),
|
path.join(databaseDirectory, "users.json"),
|
||||||
UserRecord.fromJSON.bind(UserRecord),
|
UserRecord.fromJSON.bind(UserRecord),
|
||||||
).bootstrap());
|
));
|
||||||
|
|
||||||
const userManager = new UserManager(users, roles, controllerConfig);
|
const userManager = new UserManager(users, roles, controllerConfig);
|
||||||
|
|
||||||
@@ -400,7 +400,8 @@ async function initialize(): Promise<InitializeParameters> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Loading available plugins from ${args.pluginList}`);
|
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 the command is plugin management we don't try to load plugins
|
||||||
if (command === "plugin") {
|
if (command === "plugin") {
|
||||||
@@ -415,7 +416,7 @@ async function initialize(): Promise<InitializeParameters> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Loading Plugin info");
|
logger.info("Loading Plugin info");
|
||||||
const pluginInfos = await lib.loadPluginInfos(pluginList);
|
const pluginInfos = await lib.loadPluginInfos(pluginList, skippedNpmPlugins);
|
||||||
lib.registerPluginMessages(pluginInfos);
|
lib.registerPluginMessages(pluginInfos);
|
||||||
lib.registerPluginPermissions(pluginInfos);
|
lib.registerPluginPermissions(pluginInfos);
|
||||||
lib.addPluginConfigFields(pluginInfos);
|
lib.addPluginConfigFields(pluginInfos);
|
||||||
@@ -474,13 +475,12 @@ async function startup() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
controller = new Controller(
|
controller = await Controller.fromConfig(
|
||||||
clusterLogger,
|
|
||||||
pluginInfos,
|
|
||||||
controllerConfig,
|
controllerConfig,
|
||||||
|
pluginInfos,
|
||||||
|
clusterLogger,
|
||||||
Boolean(args.canRestart),
|
Boolean(args.canRestart),
|
||||||
Boolean(args.recovery),
|
Boolean(args.recovery),
|
||||||
...await Controller.bootstrap(controllerConfig)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refuse to start if there are no users loaded
|
// 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 UserRecord } from "./src/UserRecord.js";
|
||||||
export { default as User } from "./src/User.js";
|
export { default as User } from "./src/User.js";
|
||||||
export { default as WsServer } from "./src/WsServer.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) {
|
if (import.meta.main) {
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/node/index.js",
|
"main": "dist/node/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "tsc --build && webpack-cli --mode production",
|
"prepare": "tsc --build && vite build",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -51,7 +51,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/web_ui": "workspace:^",
|
"@clusterio/web_ui": "workspace:^",
|
||||||
"@swc/core": "catalog:",
|
|
||||||
"@types/busboy": "^1.5.5",
|
"@types/busboy": "^1.5.5",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/express": "catalog:",
|
"@types/express": "catalog:",
|
||||||
@@ -61,13 +60,12 @@
|
|||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
"@types/ws": "catalog:",
|
"@types/ws": "catalog:",
|
||||||
"@types/yargs": "catalog:",
|
"@types/yargs": "catalog:",
|
||||||
|
"antd": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
|
"react-router": "catalog:",
|
||||||
"react-router-dom": "catalog:",
|
"react-router-dom": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"webpack": "catalog:",
|
"vite": "catalog:"
|
||||||
"webpack-cli": "catalog:",
|
|
||||||
"webpack-dev-middleware": "^8.3.0",
|
|
||||||
"webpack-merge": "catalog:"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ export default class BaseConnection extends lib.Link {
|
|||||||
let { id } = request;
|
let { id } = request;
|
||||||
let modPack = this._controller.modPacks.get(id);
|
let modPack = this._controller.modPacks.get(id);
|
||||||
if (!modPack) {
|
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;
|
return modPack;
|
||||||
}
|
}
|
||||||
@@ -71,11 +71,11 @@ export default class BaseConnection extends lib.Link {
|
|||||||
async handleModPackGetDefaultRequest(): Promise<lib.ModPack> {
|
async handleModPackGetDefaultRequest(): Promise<lib.ModPack> {
|
||||||
let id = this._controller.config.get("controller.default_mod_pack_id");
|
let id = this._controller.config.get("controller.default_mod_pack_id");
|
||||||
if (id === null) {
|
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);
|
let modPack = this._controller.modPacks.get(id);
|
||||||
if (!modPack) {
|
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;
|
return modPack;
|
||||||
}
|
}
|
||||||
@@ -84,10 +84,10 @@ export default class BaseConnection extends lib.Link {
|
|||||||
let filename = lib.ModInfo.filename(mod.name, mod.version);
|
let filename = lib.ModInfo.filename(mod.name, mod.version);
|
||||||
let modInfo = this._controller.modStore.files.get(filename);
|
let modInfo = this._controller.modStore.files.get(filename);
|
||||||
if (!modInfo) {
|
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) {
|
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;
|
return modInfo;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type Controller from "./Controller.js";
|
|||||||
import type InstanceRecord from "./InstanceRecord.js";
|
import type InstanceRecord from "./InstanceRecord.js";
|
||||||
import type ControlConnection from "./ControlConnection.js";
|
import type ControlConnection from "./ControlConnection.js";
|
||||||
import type HostConnection from "./HostConnection.js";
|
import type HostConnection from "./HostConnection.js";
|
||||||
|
import type { PlayerProxyRouteRequest } from "./PlayerProxyServer.js";
|
||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
export type ControllerPluginContext = lib.PluginLoadContext<{
|
export type ControllerPluginContext = lib.PluginLoadContext<{
|
||||||
@@ -28,6 +29,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
|||||||
this.modsUpdated = this.newHook();
|
this.modsUpdated = this.newHook();
|
||||||
this.rolesUpdated = this.newHook();
|
this.rolesUpdated = this.newHook();
|
||||||
this.playerEvent = 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 curr - The current value of the field.
|
||||||
* @param prev - The previous 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]>;
|
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.
|
* The connection the event occured on.
|
||||||
* @param event - one of connect, drop, resume and close
|
* @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"]>;
|
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.
|
* The connection the event occured on.
|
||||||
* @param event - one of connect, drop, resume, and close.
|
* @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"]>;
|
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.
|
* @param event - Information about the event.
|
||||||
*/
|
*/
|
||||||
readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>;
|
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
|
// 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.PluginListRequest, this.handlePluginListRequest.bind(this));
|
||||||
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
|
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
|
||||||
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
|
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
|
||||||
|
this.handle(lib.PluginSearchRequest, this.handlePluginSearchRequest.bind(this));
|
||||||
|
this.handle(lib.PluginLatestVersionsRequest, this.handlePluginLatestVersionsRequest.bind(this));
|
||||||
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
|
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
|
||||||
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
|
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
|
||||||
}
|
}
|
||||||
@@ -153,7 +155,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
try {
|
try {
|
||||||
this.checkPermission(message, entry);
|
this.checkPermission(message, entry);
|
||||||
} catch (err: any) {
|
} 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}`);
|
logger.audit(`Permission denied for ${message.name} by ${this.user.id} from ${this.connector.dst}`);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -195,12 +197,15 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
|
|
||||||
async handleControllerRestartRequest() {
|
async handleControllerRestartRequest() {
|
||||||
if (!this._controller.canRestart) {
|
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();
|
const downgrade = await this._controller.checkRestartDowngrade();
|
||||||
if (downgrade) {
|
if (downgrade) {
|
||||||
const { installedVersion, runningVersion } = downgrade;
|
const { installedVersion, runningVersion } = downgrade;
|
||||||
throw new lib.RequestError(
|
throw new lib.ConflictError(
|
||||||
`Cannot restart controller with installed Clusterio version ${installedVersion} because it ` +
|
`Cannot restart controller with installed Clusterio version ${installedVersion} because it ` +
|
||||||
`is older than running version ${runningVersion}. ` +
|
`is older than running version ${runningVersion}. ` +
|
||||||
"Stop the controller before starting the older version manually."
|
"Stop the controller before starting the older version manually."
|
||||||
@@ -298,13 +303,13 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
|
|
||||||
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
|
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
|
||||||
if ((request.config["instance.assigned_host"] ?? null) !== null) {
|
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");
|
const instanceConfig = new lib.InstanceConfig("controller");
|
||||||
if (request.cloneFromId) {
|
if (request.cloneFromId) {
|
||||||
const baseInstance = this._controller.instances.get(request.cloneFromId);
|
const baseInstance = this._controller.instances.get(request.cloneFromId);
|
||||||
if (!baseInstance) {
|
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.update(baseInstance.config.toJSON(), false);
|
||||||
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
|
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) {
|
private static validateInstanceConfigSetField(fieldName: string) {
|
||||||
if (fieldName === "instance.assigned_host") {
|
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") {
|
if (fieldName === "instance.id") {
|
||||||
// XXX is this worth implementing? It's race condition galore.
|
// 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) => {
|
let ready = new Promise<void>((resolve, reject) => {
|
||||||
stream.events.on("source", resolve);
|
stream.events.on("source", resolve);
|
||||||
stream.events.on("timeout", () => reject(
|
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(() => {});
|
ready.catch(() => {});
|
||||||
@@ -405,17 +410,17 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
|
|
||||||
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
|
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
|
||||||
if (request.sourceInstanceId === request.targetInstanceId) {
|
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 sourceInstance = this._controller.instances.getForRequest(request.sourceInstanceId);
|
||||||
let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId);
|
let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId);
|
||||||
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
|
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
|
||||||
let targetHostId = targetInstance.config.get("instance.assigned_host");
|
let targetHostId = targetInstance.config.get("instance.assigned_host");
|
||||||
if (sourceHostId === null) {
|
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) {
|
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.
|
// 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
|
// Check connectivity
|
||||||
let sourceHostConnection = this._controller.wsServer.hostConnections.get(sourceHostId);
|
let sourceHostConnection = this._controller.wsServer.hostConnections.get(sourceHostId);
|
||||||
if (!sourceHostConnection || sourceHostConnection.connector.closing) {
|
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);
|
let targetHostConnection = this._controller.wsServer.hostConnections.get(targetHostId);
|
||||||
if (!targetHostConnection || targetHostConnection.connector.closing) {
|
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
|
// Create stream to proxy from target to source
|
||||||
@@ -440,7 +445,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
if (stream.source) {
|
if (stream.source) {
|
||||||
stream.source.destroy();
|
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.
|
// 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) {
|
async handleModPackCreateRequest(request: lib.ModPackCreateRequest) {
|
||||||
let modPack = request.modPack;
|
let modPack = request.modPack;
|
||||||
if (modPack.id === undefined) {
|
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)) {
|
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);
|
this._controller.modPacks.set(modPack);
|
||||||
}
|
}
|
||||||
@@ -491,7 +496,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
async handleModPackUpdateRequest(request: lib.ModPackUpdateRequest) {
|
async handleModPackUpdateRequest(request: lib.ModPackUpdateRequest) {
|
||||||
let modPack = request.modPack;
|
let modPack = request.modPack;
|
||||||
if (modPack.id === undefined || !this._controller.modPacks.has(modPack.id)) {
|
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);
|
this._controller.modPacks.set(modPack);
|
||||||
}
|
}
|
||||||
@@ -500,7 +505,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
let { id } = request;
|
let { id } = request;
|
||||||
let modPack = this._controller.modPacks.getMutable(id);
|
let modPack = this._controller.modPacks.getMutable(id);
|
||||||
if (!modPack) {
|
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.modPacks.delete(modPack);
|
||||||
this._controller.deleteNote("mod_pack", id);
|
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),
|
author: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].author, b.versions[0].author),
|
||||||
};
|
};
|
||||||
if (!Object.prototype.hasOwnProperty.call(sorters, sort)) {
|
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]);
|
resultList.sort(sorters[sort as keyof typeof sorters]);
|
||||||
let order = request.sortOrder;
|
let order = request.sortOrder;
|
||||||
@@ -629,7 +634,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error}`);
|
logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error}`);
|
||||||
// Propagate a user-friendly error back to the client
|
// 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");
|
const token = this._controller.config.get("controller.factorio_token");
|
||||||
|
|
||||||
if (!username || username === "" || !token || 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 {
|
try {
|
||||||
@@ -656,18 +661,16 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Error downloading mods from portal: ${error.message}`);
|
logger.error(`Error downloading mods from portal: ${error.message}`);
|
||||||
// Improve error message clarity
|
const errorMessage = "Mod portal download failed";
|
||||||
let errorMessage = "Mod portal download failed";
|
if (error.message.includes("401") || error.message.includes("Unauthorized")) {
|
||||||
if (error instanceof lib.RequestError) {
|
throw new lib.ConflictError(
|
||||||
errorMessage += `: ${error.message}`;
|
`${errorMessage}: Authentication failed. Check Factorio username/token in controller config.`
|
||||||
} 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}`;
|
|
||||||
}
|
}
|
||||||
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) {
|
for (const mod of request.mods) {
|
||||||
const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version));
|
const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version));
|
||||||
if (!modInfo) {
|
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);
|
modInfos.push(modInfo);
|
||||||
}
|
}
|
||||||
@@ -711,7 +714,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (!error.message.includes("404") && !error.message.includes("Not Found")) {
|
if (!error.message.includes("404") && !error.message.includes("Not Found")) {
|
||||||
logger.error(`Error fetching mod from portal: ${error.message}`);
|
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 {
|
} else {
|
||||||
releases = null;
|
releases = null;
|
||||||
}
|
}
|
||||||
@@ -951,7 +954,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
let { id, name, description, permissions } = request;
|
let { id, name, description, permissions } = request;
|
||||||
let role = this._controller.roles.getMutable(id);
|
let role = this._controller.roles.getMutable(id);
|
||||||
if (!role) {
|
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;
|
role.name = name;
|
||||||
@@ -963,7 +966,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
async handleRoleGrantDefaultPermissionsRequest(request: lib.RoleGrantDefaultPermissionsRequest) {
|
async handleRoleGrantDefaultPermissionsRequest(request: lib.RoleGrantDefaultPermissionsRequest) {
|
||||||
let role = this._controller.roles.get(request.id);
|
let role = this._controller.roles.get(request.id);
|
||||||
if (!role) {
|
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();
|
role.grantDefaultPermissions();
|
||||||
@@ -975,7 +978,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
|
|
||||||
const role = this._controller.roles.get(id);
|
const role = this._controller.roles.get(id);
|
||||||
if (!role) {
|
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.roles.delete(role);
|
||||||
this._controller.deleteNote("role", id);
|
this._controller.deleteNote("role", id);
|
||||||
@@ -994,7 +997,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
async handleNoteSetRequest(request: lib.NoteSetRequest) {
|
async handleNoteSetRequest(request: lib.NoteSetRequest) {
|
||||||
const { resourceType, resourceId, content } = request;
|
const { resourceType, resourceId, content } = request;
|
||||||
if (!resourceType || !resourceId) {
|
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);
|
this._controller.setNote(resourceType, resourceId, content, this.user.name);
|
||||||
}
|
}
|
||||||
@@ -1003,7 +1006,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
let name = request.name;
|
let name = request.name;
|
||||||
let user = this._controller.users.getByName(name);
|
let user = this._controller.users.getByName(name);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new lib.RequestError(`User ${name} does not exist`);
|
throw new lib.NotFoundError(`User ${name} does not exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return user.toUserDetails();
|
return user.toUserDetails();
|
||||||
@@ -1020,7 +1023,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
|
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
|
||||||
let user = this._controller.users.getByName(request.name);
|
let user = this._controller.users.getByName(request.name);
|
||||||
if (!user) {
|
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) {
|
if (user.id !== this.user.id) {
|
||||||
this.user.checkPermission("core.user.revoke_other_token");
|
this.user.checkPermission("core.user.revoke_other_token");
|
||||||
@@ -1037,12 +1040,12 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
|
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
|
||||||
let user = this._controller.users.getByName(request.name);
|
let user = this._controller.users.getByName(request.name);
|
||||||
if (!user) {
|
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) {
|
for (let roleId of request.roles) {
|
||||||
if (!this._controller.roles.has(roleId)) {
|
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");
|
this.user.checkPermission("core.user.create");
|
||||||
user = this._controller.users.createUser(name);
|
user = this._controller.users.createUser(name);
|
||||||
} else {
|
} 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");
|
this.user.checkPermission("core.user.create");
|
||||||
user = this._controller.users.createUser(name);
|
user = this._controller.users.createUser(name);
|
||||||
} else {
|
} 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");
|
this.user.checkPermission("core.user.create");
|
||||||
user = this._controller.users.createUser(name);
|
user = this._controller.users.createUser(name);
|
||||||
} else {
|
} 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 name = request.name;
|
||||||
let user = this._controller.users.getByName(name);
|
let user = this._controller.users.getByName(name);
|
||||||
if (!user) {
|
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);
|
this._controller.users.deleteUser(user);
|
||||||
@@ -1335,47 +1338,54 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
|
|
||||||
async handleControllerUpdateRequest(request: lib.ControllerUpdateRequest) {
|
async handleControllerUpdateRequest(request: lib.ControllerUpdateRequest) {
|
||||||
if (!this._controller.config.get("controller.allow_remote_updates")) {
|
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");
|
return await lib.updatePackage("@clusterio/controller");
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
|
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
|
||||||
if (!this._controller.config.get("controller.allow_plugin_updates")) {
|
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);
|
return await lib.handlePluginUpdate(request.pluginPackage, this._controller.pluginInfos);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
|
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
|
||||||
if (!this._controller.config.get("controller.allow_plugin_install")) {
|
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);
|
const npmPackage = await lib.handlePluginInstall(request.pluginPackage);
|
||||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
|
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it. Version
|
||||||
if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
|
// changes to loaded plugins are noticed there.
|
||||||
|
if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === npmPackage)) {
|
||||||
this._controller.config.restartRequired = true;
|
this._controller.config.restartRequired = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
|
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
|
||||||
if (!this._controller.config.get("controller.allow_remote_updates")) {
|
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")) {
|
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);
|
return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePluginListRequest(request: lib.PluginListRequest) {
|
async handlePluginSearchRequest(request: lib.PluginSearchRequest) {
|
||||||
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
|
return await lib.searchPlugins(request.query, request.page, request.pageSize);
|
||||||
pluginInfo,
|
}
|
||||||
this._controller.loadedPlugins.has(pluginInfo),
|
|
||||||
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
|
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) {
|
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
|
||||||
if (this.ws_dumper) {
|
if (this.ws_dumper) {
|
||||||
return; // Already dumping
|
return; // Already dumping
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import HostRecord from "./HostRecord.js";
|
|||||||
import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js";
|
import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js";
|
||||||
import ControllerRouter from "./ControllerRouter.js";
|
import ControllerRouter from "./ControllerRouter.js";
|
||||||
import InstanceManager from "./InstanceManager.js";
|
import InstanceManager from "./InstanceManager.js";
|
||||||
|
import PlayerProxyServer, { type PlayerProxyRouteRequest } from "./PlayerProxyServer.js";
|
||||||
|
|
||||||
const endpointDurationSummary = new Summary(
|
const endpointDurationSummary = new Summary(
|
||||||
"clusterio_controller_http_endpoint_duration_seconds",
|
"clusterio_controller_http_endpoint_duration_seconds",
|
||||||
@@ -52,7 +53,7 @@ type ControllerDebugEvents = {
|
|||||||
|
|
||||||
type ControllerEvents = {
|
type ControllerEvents = {
|
||||||
"stop": [],
|
"stop": [],
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages all controller related operations
|
* Manages all controller related operations
|
||||||
@@ -75,6 +76,8 @@ export default class Controller {
|
|||||||
hooks = new ControllerHooks(logger);
|
hooks = new ControllerHooks(logger);
|
||||||
/** Plugins which are currently loaded */
|
/** Plugins which are currently loaded */
|
||||||
loadedPlugins: Set<lib.PluginNodeEnvInfo> = new Set();
|
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 */
|
/** WebSocket server */
|
||||||
wsServer: WsServer;
|
wsServer: WsServer;
|
||||||
@@ -82,6 +85,7 @@ export default class Controller {
|
|||||||
trustedProxies: BlockList;
|
trustedProxies: BlockList;
|
||||||
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
||||||
private _events = new events.EventEmitter<ControllerEvents>();
|
private _events = new events.EventEmitter<ControllerEvents>();
|
||||||
|
playerProxyServer: PlayerProxyServer | null = null;
|
||||||
|
|
||||||
/** Event subscription controller */
|
/** Event subscription controller */
|
||||||
subscriptions: lib.SubscriptionController;
|
subscriptions: lib.SubscriptionController;
|
||||||
@@ -104,7 +108,7 @@ export default class Controller {
|
|||||||
_registeredEvents = new Map<lib.EventClass<unknown>, lib.EventHandler<unknown>>();
|
_registeredEvents = new Map<lib.EventClass<unknown>, lib.EventHandler<unknown>>();
|
||||||
_snoopedEvents = new Map();
|
_snoopedEvents = new Map();
|
||||||
|
|
||||||
devMiddleware: any | null = null;
|
devServer: import("vite").ViteDevServer | null = null;
|
||||||
|
|
||||||
startedAtMs = Date.now();
|
startedAtMs = Date.now();
|
||||||
autosaveInterval?: ReturnType<typeof setInterval>;
|
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");
|
let databaseDirectory = config.get("controller.database_directory");
|
||||||
await fs.mkdir(databaseDirectory, { recursive: true });
|
await fs.mkdir(databaseDirectory, { recursive: true });
|
||||||
|
|
||||||
@@ -158,23 +169,23 @@ export default class Controller {
|
|||||||
path.join(databaseDirectory, "roles.json"),
|
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"),
|
path.join(databaseDirectory, "systems.json"),
|
||||||
lib.SystemInfo.fromJSON.bind(lib.SystemInfo),
|
lib.SystemInfo.fromJSON.bind(lib.SystemInfo),
|
||||||
this.migrateSystems,
|
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"),
|
path.join(databaseDirectory, "hosts.json"),
|
||||||
HostRecord.fromJSON.bind(HostRecord),
|
HostRecord.fromJSON.bind(HostRecord),
|
||||||
this.migrateHosts, this.finaliseHosts,
|
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"),
|
path.join(databaseDirectory, "instances.json"),
|
||||||
InstanceRecord.fromJSON.bind(InstanceRecord),
|
InstanceRecord.fromJSON.bind(InstanceRecord),
|
||||||
this.migrateInstances, this.finaliseInstances,
|
this.migrateInstances, this.finaliseInstances,
|
||||||
).bootstrap());
|
));
|
||||||
// Save plugin versions written while loading
|
// Save plugin versions written while loading
|
||||||
for (const instance of instances.values()) {
|
for (const instance of instances.values()) {
|
||||||
if (instance.config.dirty) {
|
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"),
|
path.join(databaseDirectory, "saves.json"),
|
||||||
lib.SaveDetails.fromJSON.bind(lib.SaveDetails)
|
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"),
|
path.join(databaseDirectory, "mod-packs.json"),
|
||||||
lib.ModPack.fromJSON.bind(lib.ModPack),
|
lib.ModPack.fromJSON.bind(lib.ModPack),
|
||||||
this.migrateModPacks,
|
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"),
|
path.join(databaseDirectory, "roles.json"),
|
||||||
lib.Role.fromJSON.bind(lib.Role),
|
lib.Role.fromJSON.bind(lib.Role),
|
||||||
this.migrateRoles,
|
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"),
|
path.join(databaseDirectory, "users.json"),
|
||||||
UserRecord.fromJSON.bind(UserRecord),
|
UserRecord.fromJSON.bind(UserRecord),
|
||||||
undefined, this.finaliseUsers,
|
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"),
|
path.join(databaseDirectory, "notes.json"),
|
||||||
lib.Note.fromJSON.bind(lib.Note),
|
lib.Note.fromJSON.bind(lib.Note),
|
||||||
).bootstrap());
|
));
|
||||||
|
|
||||||
let modsDirectory = config.get("controller.mods_directory");
|
let modsDirectory = config.get("controller.mods_directory");
|
||||||
await fs.mkdir(modsDirectory, { recursive: true });
|
await fs.mkdir(modsDirectory, { recursive: true });
|
||||||
@@ -223,7 +234,12 @@ export default class Controller {
|
|||||||
lib.Role.ensureDefaultAdminRole(roles);
|
lib.Role.ensureDefaultAdminRole(roles);
|
||||||
lib.Role.ensureDefaultPlayerRole(roles);
|
lib.Role.ensureDefaultPlayerRole(roles);
|
||||||
|
|
||||||
return [
|
return new this(
|
||||||
|
clusterLogger,
|
||||||
|
pluginInfos,
|
||||||
|
config,
|
||||||
|
canRestart,
|
||||||
|
recoveryMode,
|
||||||
systems,
|
systems,
|
||||||
hosts,
|
hosts,
|
||||||
instances,
|
instances,
|
||||||
@@ -233,7 +249,7 @@ export default class Controller {
|
|||||||
roles,
|
roles,
|
||||||
users,
|
users,
|
||||||
notes,
|
notes,
|
||||||
] as const;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -272,7 +288,6 @@ export default class Controller {
|
|||||||
this.clusterLogger = clusterLogger;
|
this.clusterLogger = clusterLogger;
|
||||||
this.pluginInfos = pluginInfos;
|
this.pluginInfos = pluginInfos;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
|
||||||
this.app = express();
|
this.app = express();
|
||||||
this.app.locals.controller = this;
|
this.app.locals.controller = this;
|
||||||
this.app.locals.streams = new Map();
|
this.app.locals.streams = new Map();
|
||||||
@@ -364,7 +379,7 @@ export default class Controller {
|
|||||||
}, 600e3);
|
}, 600e3);
|
||||||
logSizeGauge.callback = async () => { logSizeGauge.set(await this.logSize()); };
|
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) {
|
if (args.dev || args.devPlugin) {
|
||||||
await this._startDevServer(args);
|
await this._startDevServer(args);
|
||||||
}
|
}
|
||||||
@@ -402,20 +417,37 @@ export default class Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Controller.addAppRoutes(this.app, this.pluginInfos);
|
Controller.addAppRoutes(this.app, this.pluginInfos);
|
||||||
|
if (this.devServer) {
|
||||||
|
this.app.use(this.devServer.middlewares);
|
||||||
|
}
|
||||||
|
|
||||||
if (!args.dev) {
|
if (!args.dev) {
|
||||||
let manifestPath = path.join(import.meta.dirname, "..", "..", "web", "manifest.json");
|
let manifestPath = path.join(import.meta.dirname, "..", "..", "web", "manifest.json");
|
||||||
|
|
||||||
let manifest = await Controller.loadJsonObject(manifestPath);
|
let manifest = await Controller.loadJsonObject(manifestPath);
|
||||||
if (!manifest["main.js"]) {
|
if (!manifest.main) {
|
||||||
logger.error("Missing main.js entry in dist/web/manifest.json");
|
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");
|
this.app.locals.staticRoot = this.config.get("controller.static_url");
|
||||||
|
|
||||||
// Load plugins
|
// 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.loadPlugins();
|
||||||
|
await this.playerProxyServer?.start();
|
||||||
|
|
||||||
// Log all express errors from middleware or routes (app.use is missing the type overload)
|
// Log all express errors from middleware or routes (app.use is missing the type overload)
|
||||||
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
||||||
@@ -478,37 +510,44 @@ export default class Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _startDevServer(args: ControllerArgs) {
|
async _startDevServer(args: ControllerArgs) {
|
||||||
logger.warn("Webpack development mode enabled");
|
logger.warn("Vite development mode enabled");
|
||||||
|
|
||||||
const webpack = (await import("webpack")).default;
|
const vite = await import("vite");
|
||||||
const webpackDevMiddleware = (await import("webpack-dev-middleware")).default;
|
const webUiVite = await import("@clusterio/web_ui/vite.mjs");
|
||||||
const webpackConfigs = [];
|
|
||||||
|
|
||||||
if (args.dev) {
|
const devPlugins = new Map<string, string>();
|
||||||
// @ts-expect-error this file is run from a different folder
|
const pluginDirs = new Map<string, string>();
|
||||||
webpackConfigs.push((await import("../../../webpack.config.cjs")).default({})); // Path outside of build
|
for (let name of (args.devPlugin ?? []).map(String)) {
|
||||||
}
|
let info = this.pluginInfos.find(i => i.name === name);
|
||||||
if (args.devPlugin) {
|
if (!info) {
|
||||||
let devPlugins = new Map();
|
throw new lib.StartupError(`No plugin named ${name}`);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
this.app.locals.devPlugins = devPlugins;
|
pluginDirs.set(name, path.dirname(info.packagePath));
|
||||||
|
devPlugins.set(name, webUiVite.devPluginUrl(name));
|
||||||
}
|
}
|
||||||
|
this.app.locals.devPlugins = devPlugins;
|
||||||
|
|
||||||
|
const controllerDir = path.join(import.meta.dirname, "..", "..", "..");
|
||||||
const compiler = webpack(webpackConfigs);
|
this.devServer = await vite.createServer({
|
||||||
this.devMiddleware = webpackDevMiddleware(compiler, { serverSideRender: true });
|
configFile: path.join(controllerDir, "vite.config.js"),
|
||||||
this.app.use(this.devMiddleware);
|
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();
|
await this.clusterLogIndex.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.devMiddleware) {
|
if (this.devServer) {
|
||||||
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
|
await this.devServer.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.hooks.shutdown.invoke();
|
await this.hooks.shutdown.invoke();
|
||||||
|
|
||||||
|
if (this.playerProxyServer) {
|
||||||
|
await this.playerProxyServer.stop();
|
||||||
|
this.playerProxyServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
await this.wsServer.stop();
|
await this.wsServer.stop();
|
||||||
|
|
||||||
let stopTasks = [];
|
let stopTasks = [];
|
||||||
@@ -634,7 +678,9 @@ export default class Controller {
|
|||||||
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
|
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
|
||||||
}
|
}
|
||||||
const restartRequired = await this.checkRestartRequired();
|
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);
|
const newMetrics = await Promise.all(requests);
|
||||||
for (const metric of newMetrics) {
|
for (const metric of newMetrics) {
|
||||||
this.systems.set(metric);
|
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() {
|
async checkRestartRequired() {
|
||||||
if (this.config.restartRequired) {
|
if (this.config.restartRequired) {
|
||||||
return true;
|
return true;
|
||||||
@@ -989,6 +1044,94 @@ export default class Controller {
|
|||||||
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
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
|
* Removes all saves currently stored for the given instance, if any
|
||||||
* @param instanceId - Id of Instance to clear saves for.
|
* @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 depth = routeDepth + Number(req.path.slice(-1) === "/");
|
||||||
let webRoot = "../".repeat(depth) || "./";
|
let webRoot = "../".repeat(depth) || "./";
|
||||||
let staticRoot = res.app.locals.staticRoot ?? `${webRoot}static/`;
|
let staticRoot = res.app.locals.staticRoot ?? `${webRoot}static/`;
|
||||||
let mainBundle: string = "";
|
const { main, imports } = res.app.locals.webBundle;
|
||||||
if (res.app.locals.mainBundle) {
|
const importMap = JSON.stringify({
|
||||||
mainBundle = res.app.locals.mainBundle;
|
imports: Object.fromEntries(Object.entries(imports).map(
|
||||||
} else {
|
([name, file]) => [name, `${staticRoot}${routes.stripStaticPrefix(file)}`]
|
||||||
let stats = res.locals.webpack.devMiddleware.stats.stats[0];
|
)),
|
||||||
mainBundle = stats.toJson().assetsByChunkName["main"][0];
|
}).replace(/</g, "\\u003c");
|
||||||
}
|
|
||||||
mainBundle = routes.stripStaticPrefix(mainBundle);
|
|
||||||
|
|
||||||
fs.readFile(
|
fs.readFile(
|
||||||
path.join(import.meta.dirname, "..", "..", "..", "web", "index.html"),
|
path.join(import.meta.dirname, "..", "..", "..", "web", "index.html"),
|
||||||
"utf8",
|
"utf8",
|
||||||
).then((content) => {
|
).then(async (content) => {
|
||||||
res.type("text/html");
|
content = content
|
||||||
res.send(content
|
|
||||||
.replace(/__CLUSTER_NAME__/g, res.app.locals.controller.config.get("controller.name"))
|
.replace(/__CLUSTER_NAME__/g, res.app.locals.controller.config.get("controller.name"))
|
||||||
.replace(/__WEB_ROOT__/g, webRoot)
|
.replace(/__WEB_ROOT__/g, webRoot)
|
||||||
.replace(/__STATIC_ROOT__/g, staticRoot)
|
.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 => {
|
}).catch(err => {
|
||||||
next(err);
|
next(err);
|
||||||
});
|
});
|
||||||
@@ -1440,24 +1586,24 @@ export default class Controller {
|
|||||||
} else if (dst.type === lib.Address.control) {
|
} else if (dst.type === lib.Address.control) {
|
||||||
connection = this.wsServer.controlConnections.get(dst.id);
|
connection = this.wsServer.controlConnections.get(dst.id);
|
||||||
if (!connection) {
|
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) {
|
} else if (dst.type === lib.Address.instance) {
|
||||||
const instance = this.instances.getForRequest(dst.id);
|
const instance = this.instances.getForRequest(dst.id);
|
||||||
let hostId = instance.config.get("instance.assigned_host");
|
let hostId = instance.config.get("instance.assigned_host");
|
||||||
if (hostId === null) {
|
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);
|
connection = this.wsServer.hostConnections.get(hostId);
|
||||||
if (!connection) {
|
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) {
|
} else if (dst.type === lib.Address.host) {
|
||||||
connection = this.wsServer.hostConnections.get(dst.id);
|
connection = this.wsServer.hostConnections.get(dst.id);
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
throw new lib.RequestError("Host is not connected");
|
throw new lib.UnavailableError("Host is not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
@@ -1541,11 +1687,11 @@ export default class Controller {
|
|||||||
let instance = this.instances.getForRequest(request.instanceId);
|
let instance = this.instances.getForRequest(request.instanceId);
|
||||||
let hostId = instance.config.get("instance.assigned_host");
|
let hostId = instance.config.get("instance.assigned_host");
|
||||||
if (hostId === null) {
|
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);
|
let connection = this.wsServer.hostConnections.get(hostId);
|
||||||
if (!connection) {
|
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);
|
return await connection.send(request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default class ControllerRouter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
origin.connector.sendResponseError(
|
origin.connector.sendResponseError(
|
||||||
new lib.ResponseError(msg || "Unroutable destination"), message.src
|
new lib.ResponseError(msg || "Unroutable destination", "UnavailableError"), message.src
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,12 +65,12 @@ export default class InstanceManager {
|
|||||||
*
|
*
|
||||||
* @param instanceId - ID of instance to get.
|
* @param instanceId - ID of instance to get.
|
||||||
* @returns The instance record.
|
* @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 {
|
getForRequest(instanceId: number): InstanceRecord {
|
||||||
const instance = this.records.get(instanceId);
|
const instance = this.records.get(instanceId);
|
||||||
if (!instance) {
|
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;
|
return instance;
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ export default class InstanceManager {
|
|||||||
* @param instanceConfig - Config to base the newly created instance on.
|
* @param instanceConfig - Config to base the newly created instance on.
|
||||||
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
|
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
|
||||||
* @returns The created instance record.
|
* @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(
|
async createInstance(
|
||||||
instanceConfig: lib.InstanceConfig,
|
instanceConfig: lib.InstanceConfig,
|
||||||
@@ -133,7 +133,7 @@ export default class InstanceManager {
|
|||||||
): Promise<InstanceRecord> {
|
): Promise<InstanceRecord> {
|
||||||
const instanceId = instanceConfig.get("instance.id");
|
const instanceId = instanceConfig.get("instance.id");
|
||||||
if (this.records.has(instanceId)) {
|
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");
|
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,
|
* @param hostId - ID of host to assign the instance to. If undefined,
|
||||||
* the instance will be unassigned.
|
* the instance will be unassigned.
|
||||||
*
|
*
|
||||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
* @throws {module:lib.NotFoundError} if the instance does not exist.
|
||||||
* @throws {module:lib.RequestError} if the target host is not connected.
|
* @throws {module:lib.UnavailableError} if the target host is not connected.
|
||||||
*/
|
*/
|
||||||
async assignInstance(
|
async assignInstance(
|
||||||
instanceId: number,
|
instanceId: number,
|
||||||
@@ -191,7 +191,7 @@ export default class InstanceManager {
|
|||||||
if (hostId !== null) {
|
if (hostId !== null) {
|
||||||
newHostConnection = hostConnections.get(hostId);
|
newHostConnection = hostConnections.get(hostId);
|
||||||
if (!newHostConnection) {
|
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.
|
* @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> {
|
async deleteInstance(instanceId: number): Promise<void> {
|
||||||
const instance = this.getForRequest(instanceId);
|
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 {
|
namespace Express {
|
||||||
export interface Locals {
|
export interface Locals {
|
||||||
controller: Controller,
|
controller: Controller,
|
||||||
mainBundle: string,
|
webBundle: { main: string, imports: Record<string, string> },
|
||||||
|
devServer?: import("vite").ViteDevServer,
|
||||||
staticRoot: string | null,
|
staticRoot: string | null,
|
||||||
devPlugins: Map<string, number>,
|
devPlugins?: Map<string, string>,
|
||||||
streams: Map<string, ProxyStream>,
|
streams: Map<string, ProxyStream>,
|
||||||
pendingMetrics?: Promise<string>,
|
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.
|
* resolved relative to staticRoot.
|
||||||
*/
|
*/
|
||||||
export function stripStaticPrefix(bundlePath: string) {
|
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
|
// Note: Cast through unknown is needed because load_plugin is
|
||||||
// defined at runtime and no other fields in the controller config
|
// defined at runtime and no other fields in the controller config
|
||||||
// currently have the boolean type.
|
// 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;
|
let devPlugins = req.app.locals.devPlugins;
|
||||||
if (devPlugins && devPlugins.has(name)) {
|
if (devPlugins && devPlugins.has(name)) {
|
||||||
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
|
web.main = devPlugins.get(name);
|
||||||
web.main = stats.toJson().assetsByChunkName[name][0];
|
|
||||||
} else if (pluginInfo.manifest) {
|
} else if (pluginInfo.manifest) {
|
||||||
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
|
web.main = pluginInfo.manifest.main;
|
||||||
if (!web.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)) {
|
} else if (lib.pluginNeedsWebBuild(pluginInfo)) {
|
||||||
web.error = "Missing dist/web/manifest.json";
|
web.error = "Missing dist/web/manifest.json";
|
||||||
}
|
}
|
||||||
if (web.main === "remoteEntry.js") {
|
|
||||||
web.error = "Incompatible old remoteEntry.js entrypoint.";
|
|
||||||
}
|
|
||||||
if (web.main) {
|
if (web.main) {
|
||||||
web.main = stripStaticPrefix(web.main);
|
web.main = stripStaticPrefix(web.main);
|
||||||
}
|
}
|
||||||
@@ -308,7 +305,7 @@ type ProxyStreamEvents = {
|
|||||||
"close": [],
|
"close": [],
|
||||||
"timeout": [],
|
"timeout": [],
|
||||||
"source": [],
|
"source": [],
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface ProxyStream {
|
export interface ProxyStream {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -548,7 +545,7 @@ function checkModName(name: string) {
|
|||||||
try {
|
try {
|
||||||
lib.checkFilename(name);
|
lib.checkFilename(name);
|
||||||
} catch (err: any) {
|
} 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="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#2a2a2a" />
|
<meta name="theme-color" content="#2a2a2a" />
|
||||||
<title>__CLUSTER_NAME__</title>
|
<title>__CLUSTER_NAME__</title>
|
||||||
|
<script type="importmap">__IMPORT_MAP__</script>
|
||||||
<style>
|
<style>
|
||||||
html {
|
html {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -26,7 +27,6 @@
|
|||||||
<div id="root">
|
<div id="root">
|
||||||
<div id="bootstrap-msg">Loading web interface...</div>
|
<div id="bootstrap-msg">Loading web interface...</div>
|
||||||
</div>
|
</div>
|
||||||
<script id="script-bundle" src="__STATIC_ROOT____MAIN_BUNDLE__" defer></script>
|
|
||||||
<script>
|
<script>
|
||||||
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
|
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
|
||||||
staticRoot = new URL("__STATIC_ROOT__", document.location).href;
|
staticRoot = new URL("__STATIC_ROOT__", document.location).href;
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
bootstrapMsg.textContent = `${errorMsg}\nSee web console for details.`;
|
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));
|
window.addEventListener("error", event => scriptError(event.error));
|
||||||
</script>
|
</script>
|
||||||
|
<script id="script-bundle" type="module" src="__STATIC_ROOT____MAIN_BUNDLE__"
|
||||||
|
onerror="scriptError('Main bundle failed to load.')"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// eslint-disable-next-line no-implicit-globals
|
||||||
declare function scriptError(err: any): void;
|
declare function scriptError(err: any): void;
|
||||||
import("@clusterio/web_ui")
|
import("@clusterio/web_ui")
|
||||||
.then(webUi => webUi.bootstrap())
|
.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 = [
|
const availablePlugins = [
|
||||||
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
||||||
|
{ name: "Lobby", value: "@clusterio/plugin-lobby" },
|
||||||
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
|
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
|
||||||
{ name: "Player Sync", value: "@clusterio/plugin-player_sync" },
|
{ name: "Player Sync", value: "@clusterio/plugin-player_sync" },
|
||||||
{ name: "Research Sync", value: "@clusterio/plugin-research_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 => (
|
const config = !templates.includes("no_config") && templates.some(value => (
|
||||||
["controller", "host", "instance", "ctl"].includes(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
|
// A count of the number of isolated contexts the plugin runs under
|
||||||
let pluginContexts = 0;
|
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.json", path.join(templatePath, "tsconfig.json"));
|
||||||
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
|
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
|
||||||
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.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.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files and dependences to support webpack
|
// Files and dependences to support the web build
|
||||||
if (webpack) {
|
if (webBuild) {
|
||||||
prepare.push("webpack-cli --mode production");
|
prepare.push("vite build");
|
||||||
files.set("webpack.config.cjs", path.join(commonPath, "webpack.config.cjs"));
|
files.set("vite.config.js", path.join(commonPath, "vite.config.js"));
|
||||||
if (templates.includes("web")) {
|
if (templates.includes("web")) {
|
||||||
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
|
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
|
||||||
pluginContexts += 1;
|
pluginContexts += 1;
|
||||||
@@ -185,7 +185,7 @@ export async function copyPluginTemplates(pluginName, templates) {
|
|||||||
ctl: templates.includes("ctl"),
|
ctl: templates.includes("ctl"),
|
||||||
web: templates.includes("web"),
|
web: templates.includes("web"),
|
||||||
// Macro flags for context requirements
|
// Macro flags for context requirements
|
||||||
webpack: webpack,
|
web_build: webBuild,
|
||||||
config: config,
|
config: config,
|
||||||
// String values for package json
|
// String values for package json
|
||||||
clusterio_version: _package.version,
|
clusterio_version: _package.version,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
//%// Typescript and type declarations
|
//%// Typescript and type declarations
|
||||||
//%if typescript
|
//%if typescript
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^7.0.2",
|
||||||
"@types/node": "^24.13.4",
|
"@types/node": "^24.13.4",
|
||||||
//%endif
|
//%endif
|
||||||
//%if typescript & web
|
//%if typescript & web
|
||||||
@@ -67,15 +67,13 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
//%endif
|
//%endif
|
||||||
//%if webpack
|
//%if web_build
|
||||||
"webpack": "^5.110.3",
|
"vite": "^8.3.4",
|
||||||
"webpack-cli": "^7.2.3",
|
|
||||||
"webpack-merge": "^5.9.0",
|
|
||||||
//%endif
|
//%endif
|
||||||
//%if controller
|
//%if controller
|
||||||
"@clusterio/controller": "^__clusterio_version__",
|
"@clusterio/controller": "^__clusterio_version__",
|
||||||
//%endif
|
//%endif
|
||||||
//%if webpack
|
//%if web_build
|
||||||
"@clusterio/web_ui": "^__clusterio_version__",
|
"@clusterio/web_ui": "^__clusterio_version__",
|
||||||
//%endif
|
//%endif
|
||||||
//%if host
|
//%if host
|
||||||
@@ -87,7 +85,7 @@
|
|||||||
"@clusterio/lib": "^__clusterio_version__"
|
"@clusterio/lib": "^__clusterio_version__"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "^0.30.6"
|
"@sinclair/typebox": "^0.34.52"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
webpack.config.js
|
vite.config.js
|
||||||
dist/browser
|
dist/browser
|
||||||
dist/web
|
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",
|
"extends": "./tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["es2023"],
|
"lib": ["es2023"],
|
||||||
|
"types": ["node"],
|
||||||
"module": "node16",
|
"module": "node16",
|
||||||
"moduleResolution": "node16",
|
"moduleResolution": "node16",
|
||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
|
|||||||
+6
-6
@@ -307,12 +307,12 @@ async function startCtl() {
|
|||||||
logger.error(`Error running command: ${err.message}`);
|
logger.error(`Error running command: ${err.message}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|
||||||
} else if (err instanceof lib.RequestError) {
|
} else if (err instanceof lib.ClientError || err instanceof lib.UnavailableError) {
|
||||||
if (err.stack) {
|
logger.error(`Error sending request: ${err.message}`);
|
||||||
logger.error(`Error sending request:\n${err.stack}`);
|
process.exitCode = 1;
|
||||||
} else {
|
|
||||||
logger.error(`Error sending request: ${err.message}`);
|
} else if (err instanceof lib.ServerError) {
|
||||||
}
|
logger.error(`Error sending request:\n${err.stack ?? err.message}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ controllerPluginCommands.add(new lib.Command({
|
|||||||
}));
|
}));
|
||||||
controllerPluginCommands.add(new lib.Command({
|
controllerPluginCommands.add(new lib.Command({
|
||||||
definition: ["update <plugin>", "Update a plugin on the controller", (yargs) => {
|
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" });
|
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||||
}],
|
}],
|
||||||
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
|
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({
|
controllerPluginCommands.add(new lib.Command({
|
||||||
definition: ["install <plugin>", "Install a plugin on the controller", (yargs) => {
|
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" });
|
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||||
}],
|
}],
|
||||||
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
|
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ hostPluginCommands.add(new lib.Command({
|
|||||||
hostPluginCommands.add(new lib.Command({
|
hostPluginCommands.add(new lib.Command({
|
||||||
definition: ["update <host> <plugin>", "Update a plugin on a host", (yargs) => {
|
definition: ["update <host> <plugin>", "Update a plugin on a host", (yargs) => {
|
||||||
yargs.positional("host", { describe: "Host to update on", type: "string" });
|
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" });
|
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||||
}],
|
}],
|
||||||
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
|
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({
|
hostPluginCommands.add(new lib.Command({
|
||||||
definition: ["install <host> <plugin>", "Install a plugin on a host", (yargs) => {
|
definition: ["install <host> <plugin>", "Install a plugin on a host", (yargs) => {
|
||||||
yargs.positional("host", { describe: "Host to install on", type: "string" });
|
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" });
|
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||||
}],
|
}],
|
||||||
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
|
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) {
|
export async function getEditor(argsEditor: string) {
|
||||||
// eslint-disable-next-line
|
// 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
|
// needed for the process.env statements to not be flagged by eslint
|
||||||
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
|
// 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}`);
|
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 the command is plugin management we don't try to load plugins
|
||||||
if (command === "plugin") {
|
if (command === "plugin") {
|
||||||
@@ -173,7 +174,7 @@ async function startHost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Loading Plugin info");
|
logger.info("Loading Plugin info");
|
||||||
let pluginInfos = await lib.loadPluginInfos(pluginList);
|
let pluginInfos = await lib.loadPluginInfos(pluginList, skippedNpmPlugins);
|
||||||
lib.registerPluginMessages(pluginInfos);
|
lib.registerPluginMessages(pluginInfos);
|
||||||
lib.registerPluginPermissions(pluginInfos);
|
lib.registerPluginPermissions(pluginInfos);
|
||||||
lib.addPluginConfigFields(pluginInfos);
|
lib.addPluginConfigFields(pluginInfos);
|
||||||
@@ -233,13 +234,12 @@ async function startHost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let hostConnector = new HostConnector(hostConfig, pluginInfos);
|
let hostConnector = new HostConnector(hostConfig, pluginInfos);
|
||||||
host = new Host(
|
host = await Host.fromConfig(
|
||||||
hostConnector,
|
|
||||||
hostConfig,
|
hostConfig,
|
||||||
pluginInfos,
|
pluginInfos,
|
||||||
|
hostConnector,
|
||||||
Boolean(args.canRestart),
|
Boolean(args.canRestart),
|
||||||
Boolean(args.recovery),
|
Boolean(args.recovery),
|
||||||
...await Host.bootstrap(hostConfig)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle interrupts
|
// Handle interrupts
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { BaseInstancePlugin, InstanceHooks, type InstancePluginContext } from ".
|
|||||||
export { default as Host, HostRouter } from "./src/Host.js";
|
export { default as Host, HostRouter } from "./src/Host.js";
|
||||||
export { default as Instance } from "./src/Instance.js";
|
export { default as Instance } from "./src/Instance.js";
|
||||||
export { default as InstanceConnection } from "./src/InstanceConnection.js";
|
export { default as InstanceConnection } from "./src/InstanceConnection.js";
|
||||||
|
export { setSaveScenario } from "./src/patch.js";
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class InstanceHooks extends lib.AsyncHookCollection {
|
|||||||
this.controllerConnectionEvent = this.newHook();
|
this.controllerConnectionEvent = this.newHook();
|
||||||
this.prepareControllerDisconnect = this.newHook();
|
this.prepareControllerDisconnect = this.newHook();
|
||||||
this.playerEvent = 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>;
|
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
|
* Called after the Factorio server is started
|
||||||
*/
|
*/
|
||||||
@@ -147,6 +163,19 @@ export class InstanceHooks extends lib.AsyncHookCollection {
|
|||||||
* @param event - Information about the event.
|
* @param event - Information about the event.
|
||||||
*/
|
*/
|
||||||
readonly playerEvent: lib.AsyncHook<[event: lib.PlayerEvent]>;
|
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
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||||
@@ -160,6 +189,7 @@ export interface BaseInstancePlugin {
|
|||||||
onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void;
|
onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void;
|
||||||
onPrepareControllerDisconnect?(connection: Instance): Promise<void>;
|
onPrepareControllerDisconnect?(connection: Instance): Promise<void>;
|
||||||
onPlayerEvent?(event: lib.PlayerEvent): 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.controllerConnectionEvent, this.onControllerConnectionEvent);
|
||||||
attach(instance.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect);
|
attach(instance.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect);
|
||||||
attach(instance.hooks.playerEvent, this.onPlayerEvent);
|
attach(instance.hooks.playerEvent, this.onPlayerEvent);
|
||||||
|
attach(instance.hooks.autoPauseChanged, this.onAutoPauseChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromContext(context: InstancePluginContext) {
|
static fromContext(context: InstancePluginContext) {
|
||||||
|
|||||||
+162
-75
@@ -15,6 +15,7 @@ import { logger } from "@clusterio/lib";
|
|||||||
import type { HostConnector } from "../host.js";
|
import type { HostConnector } from "../host.js";
|
||||||
import Instance from "./Instance.js";
|
import Instance from "./Instance.js";
|
||||||
import InstanceConnection from "./InstanceConnection.js";
|
import InstanceConnection from "./InstanceConnection.js";
|
||||||
|
import { listFactorioVersions } from "./server.js";
|
||||||
import { BaseHostPlugin, HostHooks } from "./BaseHostPlugin.js";
|
import { BaseHostPlugin, HostHooks } from "./BaseHostPlugin.js";
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +23,38 @@ function checkRequestSaveName(name: string) {
|
|||||||
try {
|
try {
|
||||||
lib.checkFilename(name);
|
lib.checkFilename(name);
|
||||||
} catch (err: any) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
origin.connector.sendResponseError(
|
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;
|
return true;
|
||||||
@@ -234,13 +266,13 @@ export class HostRouter {
|
|||||||
let dst = message.dst;
|
let dst = message.dst;
|
||||||
if (nextHop) {
|
if (nextHop) {
|
||||||
origin.connector.sendResponseError(
|
origin.connector.sendResponseError(
|
||||||
new lib.ResponseError("Instance is already running."), message.src
|
new lib.ResponseError("Instance is already running.", "ConflictError"), message.src
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.host._shuttingDown) {
|
if (this.host._shuttingDown) {
|
||||||
origin.connector.sendResponseError(
|
origin.connector.sendResponseError(
|
||||||
new lib.ResponseError("Host is shutting down."), message.src
|
new lib.ResponseError("Host is shutting down.", "UnavailableError"), message.src
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -249,9 +281,7 @@ export class HostRouter {
|
|||||||
instanceConnection.connector.forward(message);
|
instanceConnection.connector.forward(message);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
logger.error(`Error starting instance:\n${err.stack ?? err.message}`, this.host.instanceLogMeta(dst.id));
|
logger.error(`Error starting instance:\n${err.stack ?? err.message}`, this.host.instanceLogMeta(dst.id));
|
||||||
origin.connector.sendResponseError(
|
origin.connector.sendResponseError(lib.Link.errorToResponse(err), message.src);
|
||||||
new lib.ResponseError(err.message, err.code, err.stack), message.src
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,9 +294,7 @@ export class HostRouter {
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (message.type === "request") {
|
if (message.type === "request") {
|
||||||
origin.connector.sendResponseError(
|
origin.connector.sendResponseError(lib.Link.errorToResponse(err), message.src);
|
||||||
new lib.ResponseError(err.message, err.code, err.stack), message.src
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
logger.warn(`Failed to deliver ${(message as any).name || "message"} (${message.type}): ${err.message}`);
|
logger.warn(`Failed to deliver ${(message as any).name || "message"} (${message.type}): ${err.message}`);
|
||||||
if (err instanceof lib.InvalidMessage && err.errors) {
|
if (err instanceof lib.InvalidMessage && err.errors) {
|
||||||
@@ -322,6 +350,8 @@ export default class Host extends lib.Link {
|
|||||||
_startup = true;
|
_startup = true;
|
||||||
_disconnecting = false;
|
_disconnecting = false;
|
||||||
_shuttingDown = false;
|
_shuttingDown = false;
|
||||||
|
/** Instances waiting on {@link Instance.fromConfig} */
|
||||||
|
_startingInstances = new Set<number>();
|
||||||
|
|
||||||
startedAtMs = Date.now();
|
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.",
|
"_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");
|
const modsDirectory = hostConfig.get("host.mods_directory");
|
||||||
await fs.mkdir(modsDirectory, { recursive: true });
|
await fs.mkdir(modsDirectory, { recursive: true });
|
||||||
return [
|
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
|
||||||
await lib.ModStore.fromDirectory(modsDirectory),
|
return new this(connector, hostConfig, pluginInfos, canRestart, recoveryMode, modStore);
|
||||||
] as const;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -427,6 +463,7 @@ export default class Host extends lib.Link {
|
|||||||
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
|
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
|
||||||
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
|
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
|
||||||
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.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.SyncUserListsEvent, this.handleSyncUserListsEvent.bind(this));
|
||||||
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
|
this.handle(lib.SystemInfoRequest, this.handleSystemInfoRequest.bind(this));
|
||||||
this.handle(lib.PluginListRequest, this.handlePluginListRequest.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");
|
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
|
for (let i = 1; ; i++) {
|
||||||
let candidateDir = path.join(instancesDir, await lib.findUnusedName(instancesDir, name));
|
let candidateDir = path.join(instancesDir, i === 1 ? name : `${name}-${i}`);
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(candidateDir);
|
await fs.mkdir(candidateDir);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -510,7 +547,6 @@ export default class Host extends lib.Link {
|
|||||||
}
|
}
|
||||||
return candidateDir;
|
return candidateDir;
|
||||||
}
|
}
|
||||||
throw Error("Unable to create instance dir, retry threshold reached");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async broadcastEventToInstance<T>(event: lib.Event<T>) {
|
async broadcastEventToInstance<T>(event: lib.Event<T>) {
|
||||||
@@ -528,12 +564,15 @@ export default class Host extends lib.Link {
|
|||||||
|
|
||||||
async handleHostRestartRequest() {
|
async handleHostRestartRequest() {
|
||||||
if (!this.canRestart) {
|
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();
|
const downgrade = await this.checkRestartDowngrade();
|
||||||
if (downgrade) {
|
if (downgrade) {
|
||||||
const { installedVersion, runningVersion } = 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 ` +
|
`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.`
|
`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") {
|
if (fieldName === "host.id") {
|
||||||
// Changing host.id at runtime is not worth the effort to
|
// Changing host.id at runtime is not worth the effort to
|
||||||
// support and will break a lot of things if it is allowed.
|
// 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(
|
this.send(
|
||||||
new lib.InstanceStatusChangedEvent(
|
new lib.InstanceStatusChangedEvent(
|
||||||
instanceId,
|
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.gamePort : this.gamePort(instanceId),
|
||||||
instanceConnection
|
instanceConnection
|
||||||
? instanceConnection.instance.server.version
|
? instanceConnection.instance.server.version
|
||||||
@@ -799,7 +838,7 @@ export default class Host extends lib.Link {
|
|||||||
getRequestInstance(instanceId: number) {
|
getRequestInstance(instanceId: number) {
|
||||||
let instance = this.assignedInstances.get(instanceId);
|
let instance = this.assignedInstances.get(instanceId);
|
||||||
if (!instance) {
|
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;
|
return instance;
|
||||||
}
|
}
|
||||||
@@ -845,7 +884,7 @@ export default class Host extends lib.Link {
|
|||||||
}
|
}
|
||||||
if (!availablePorts.size) {
|
if (!availablePorts.size) {
|
||||||
const others = inUse.size ? ` (${inUse.size} in use by other processes)` : "";
|
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;
|
const [newPort] = availablePorts;
|
||||||
instanceToAssign.config.set("factorio.host_assigned_game_port", newPort);
|
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
|
* Initialize and connect an unloaded instance
|
||||||
*
|
*
|
||||||
@@ -882,29 +926,36 @@ export default class Host extends lib.Link {
|
|||||||
*/
|
*/
|
||||||
async _connectInstance(instanceId: number) {
|
async _connectInstance(instanceId: number) {
|
||||||
let unloadedInstance = this.getRequestInstance(instanceId);
|
let unloadedInstance = this.getRequestInstance(instanceId);
|
||||||
if (this.instanceConnections.has(instanceId)) {
|
if (this.instanceConnections.has(instanceId) || this._startingInstances.has(instanceId)) {
|
||||||
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
|
throw new lib.ConflictError(`Instance with ID ${instanceId} is running`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let hostAddress = new lib.Address(lib.Address.host, this.config.get("host.id"));
|
let hostAddress = new lib.Address(lib.Address.host, this.config.get("host.id"));
|
||||||
let instanceAddress = new lib.Address(lib.Address.instance, instanceId);
|
let instanceAddress = new lib.Address(lib.Address.instance, instanceId);
|
||||||
let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
|
let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
|
||||||
let instance = new Instance(
|
let instance: Instance;
|
||||||
this, connectionClient,
|
this._startingInstances.add(instanceId);
|
||||||
unloadedInstance.path, this.config.get("host.factorio_directory"),
|
try {
|
||||||
unloadedInstance.config
|
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);
|
let instanceConnection = new InstanceConnection(connectionServer, this, instance);
|
||||||
|
|
||||||
this.instanceConnections.set(instanceId, instanceConnection);
|
this.instanceConnections.set(instanceId, instanceConnection);
|
||||||
await instance.init(this.pluginInfos);
|
await instance.loadPlugins(this.pluginInfos);
|
||||||
|
|
||||||
return instanceConnection;
|
return instanceConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSystemInfoRequest() {
|
async handleSystemInfoRequest() {
|
||||||
const restartRequired = await this.checkRestartRequired();
|
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() {
|
async checkRestartRequired() {
|
||||||
@@ -971,6 +1022,23 @@ export default class Host extends lib.Link {
|
|||||||
return null;
|
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() {
|
async handleHostMetricsRequest() {
|
||||||
let requests: Promise<InstanceType<typeof lib.InstanceMetricsRequest["Response"]>>[] = [];
|
let requests: Promise<InstanceType<typeof lib.InstanceMetricsRequest["Response"]>>[] = [];
|
||||||
for (let instanceConnection of this.instanceConnections.values()) {
|
for (let instanceConnection of this.instanceConnections.values()) {
|
||||||
@@ -1025,19 +1093,16 @@ export default class Host extends lib.Link {
|
|||||||
checkRequestSaveName(newName);
|
checkRequestSaveName(newName);
|
||||||
let instance = this.getRequestInstance(instanceId);
|
let instance = this.getRequestInstance(instanceId);
|
||||||
try {
|
try {
|
||||||
// Link save in the new location.
|
await moveSaveExclusive(
|
||||||
await fs.link(
|
|
||||||
path.join(instance.path, "saves", oldName),
|
path.join(instance.path, "saves", oldName),
|
||||||
path.join(instance.path, "saves", newName),
|
path.join(instance.path, "saves", newName),
|
||||||
);
|
);
|
||||||
// Unlink save in old location.
|
|
||||||
await fs.unlink(path.join(instance.path, "saves", oldName));
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === "EEXIST") {
|
if (err.code === "EEXIST") {
|
||||||
throw new lib.RequestError(`${newName} already exists`);
|
throw new lib.ConflictError(`${newName} already exists`);
|
||||||
}
|
}
|
||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
throw new lib.RequestError(`${oldName} does not exist`);
|
throw new lib.NotFoundError(`${oldName} does not exist`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -1057,13 +1122,32 @@ export default class Host extends lib.Link {
|
|||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
throw new lib.RequestError(`${source} does not exist`);
|
throw new lib.NotFoundError(`${source} does not exist`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
|
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) {
|
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
|
||||||
let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request;
|
let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request;
|
||||||
checkRequestSaveName(sourceName);
|
checkRequestSaveName(sourceName);
|
||||||
@@ -1071,31 +1155,27 @@ export default class Host extends lib.Link {
|
|||||||
let sourceInstance = this.getRequestInstance(sourceInstanceId);
|
let sourceInstance = this.getRequestInstance(sourceInstanceId);
|
||||||
let targetInstance = this.getRequestInstance(targetInstanceId);
|
let targetInstance = this.getRequestInstance(targetInstanceId);
|
||||||
|
|
||||||
|
if (!targetName.endsWith(".zip")) {
|
||||||
|
targetName += ".zip";
|
||||||
|
}
|
||||||
|
|
||||||
// For consistency with remote transfer initiated through pullSave the
|
// For consistency with remote transfer initiated through pullSave the
|
||||||
// target is renamed if it already exists.
|
// target is renamed if it already exists.
|
||||||
targetName = await lib.findUnusedName(
|
const sourcePath = path.join(sourceInstance.path, "saves", sourceName);
|
||||||
path.join(targetInstance.path, "saves"), targetName, ".zip"
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (copy) {
|
targetName = await this._moveOrCopySave(
|
||||||
await fs.copyFile(
|
sourcePath,
|
||||||
path.join(sourceInstance.path, "saves", sourceName),
|
path.join(targetInstance.path, "saves", targetName),
|
||||||
path.join(targetInstance.path, "saves", targetName),
|
copy,
|
||||||
fs.constants.COPYFILE_FICLONE
|
);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await fs.rename(
|
|
||||||
path.join(sourceInstance.path, "saves", sourceName),
|
|
||||||
path.join(targetInstance.path, "saves", targetName),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === "ENOENT") {
|
// ENOENT may also come from a missing target saves folder.
|
||||||
throw new lib.RequestError(`${sourceName} does not exist`);
|
if (err.code === "ENOENT" && !await fs.access(sourcePath).then(() => true, () => false)) {
|
||||||
|
throw new lib.NotFoundError(`${sourceName} does not exist`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstance.path, "saves"));
|
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstance.path, "saves"));
|
||||||
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstance.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));
|
await fs.unlink(path.join(instance.path, "saves", name));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
throw new lib.RequestError(`${name} does not exist`);
|
throw new lib.NotFoundError(`${name} does not exist`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -1154,7 +1234,7 @@ export default class Host extends lib.Link {
|
|||||||
content = (await fs.open(path.join(instance.path, "saves", name))).createReadStream();
|
content = (await fs.open(path.join(instance.path, "saves", name))).createReadStream();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
throw new lib.RequestError(`${name} does not exist`);
|
throw new lib.NotFoundError(`${name} does not exist`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -1173,12 +1253,12 @@ export default class Host extends lib.Link {
|
|||||||
async handleInstanceDeleteInternalRequest(request: lib.InstanceDeleteInternalRequest) {
|
async handleInstanceDeleteInternalRequest(request: lib.InstanceDeleteInternalRequest) {
|
||||||
let instanceId = request.instanceId;
|
let instanceId = request.instanceId;
|
||||||
if (this.instanceConnections.has(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);
|
let instance = this.discoveredInstances.get(instanceId);
|
||||||
if (!instance) {
|
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);
|
this.discoveredInstances.delete(instanceId);
|
||||||
@@ -1188,45 +1268,52 @@ export default class Host extends lib.Link {
|
|||||||
|
|
||||||
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
|
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
|
||||||
if (!this.config.get("host.allow_remote_updates")) {
|
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");
|
return await lib.updatePackage("@clusterio/host");
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
|
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
|
||||||
if (!this.config.get("host.allow_plugin_updates")) {
|
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);
|
return await lib.handlePluginUpdate(request.pluginPackage, this.pluginInfos);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
|
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
|
||||||
if (!this.config.get("host.allow_plugin_install")) {
|
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);
|
const npmPackage = await lib.handlePluginInstall(request.pluginPackage);
|
||||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
|
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it. Version
|
||||||
if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
|
// changes to loaded plugins are noticed there.
|
||||||
|
if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === npmPackage)) {
|
||||||
this.config.restartRequired = true;
|
this.config.restartRequired = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
|
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
|
||||||
if (!this.config.get("host.allow_remote_updates")) {
|
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")) {
|
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);
|
return await lib.handleUpdateAll("@clusterio/host", this.pluginInfos);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePluginListRequest(request: lib.PluginListRequest) {
|
async handlePluginListRequest(request: lib.PluginListRequest) {
|
||||||
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
|
return await this.listPlugins();
|
||||||
pluginInfo,
|
}
|
||||||
this.loadedPlugins.has(pluginInfo),
|
|
||||||
this.config.get(`${pluginInfo.name}.load_plugin`),
|
/** 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() {
|
sendHostUpdate() {
|
||||||
@@ -1253,7 +1340,7 @@ export default class Host extends lib.Link {
|
|||||||
let instanceConnection = this.instanceConnections.get(instanceId);
|
let instanceConnection = this.instanceConnections.get(instanceId);
|
||||||
list.push(new lib.HostInstanceUpdate(
|
list.push(new lib.HostInstanceUpdate(
|
||||||
instance.config.toRemote("controller"),
|
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.gamePort : this.gamePort(instanceId),
|
||||||
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
||||||
));
|
));
|
||||||
|
|||||||
+117
-59
@@ -10,7 +10,7 @@ const execAsync = util.promisify(exec);
|
|||||||
// internal libraries
|
// internal libraries
|
||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
import { FactorioServer } from "./server.js";
|
import { FactorioServer, checkForUpdates } from "./server.js";
|
||||||
import { loadModules, patch } from "./patch.js";
|
import { loadModules, patch } from "./patch.js";
|
||||||
import { exportData } from "./export.js";
|
import { exportData } from "./export.js";
|
||||||
import type Host from "./Host.js";
|
import type Host from "./Host.js";
|
||||||
@@ -21,7 +21,7 @@ function checkRequestSaveName(name: string) {
|
|||||||
try {
|
try {
|
||||||
lib.checkFilename(name);
|
lib.checkFilename(name);
|
||||||
} catch (err: any) {
|
} 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",
|
"/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.
|
// Statuses the instance passes through on its way to running or stopped.
|
||||||
const transitoryStatuses: ReadonlySet<Instance["_status"]> = new Set([
|
const transitoryStatuses: ReadonlySet<Instance["_status"]> = new Set([
|
||||||
"starting", "stopping", "creating_save", "exporting_data",
|
"starting", "stopping", "creating_save", "exporting_data",
|
||||||
@@ -183,6 +190,7 @@ export default class Instance extends lib.Link {
|
|||||||
_loadedSave: string | null = null;
|
_loadedSave: string | null = null;
|
||||||
_playerCheckInterval: ReturnType<typeof setInterval> | undefined;
|
_playerCheckInterval: ReturnType<typeof setInterval> | undefined;
|
||||||
_hadPlayersOnline = false;
|
_hadPlayersOnline = false;
|
||||||
|
_autoPause = false;
|
||||||
_playerAutosaveSlot = 1;
|
_playerAutosaveSlot = 1;
|
||||||
_expectedUserUpdates: { action: string, name: string, reason: string }[] = [];
|
_expectedUserUpdates: { action: string, name: string, reason: string }[] = [];
|
||||||
_settleWaiters: (() => void)[] = [];
|
_settleWaiters: (() => void)[] = [];
|
||||||
@@ -191,8 +199,8 @@ export default class Instance extends lib.Link {
|
|||||||
host: Host,
|
host: Host,
|
||||||
connector: lib.VirtualConnector,
|
connector: lib.VirtualConnector,
|
||||||
dir: string,
|
dir: string,
|
||||||
factorioDir: string,
|
instanceConfig: lib.InstanceConfig,
|
||||||
instanceConfig: lib.InstanceConfig
|
server: FactorioServer,
|
||||||
) {
|
) {
|
||||||
super(connector);
|
super(connector);
|
||||||
this._host = host;
|
this._host = host;
|
||||||
@@ -202,10 +210,7 @@ export default class Instance extends lib.Link {
|
|||||||
|
|
||||||
this.id = this.config.get("instance.id");
|
this.id = this.config.get("instance.id");
|
||||||
|
|
||||||
this.logger = lib.logger.child({
|
this.logger = instanceLogger(instanceConfig);
|
||||||
instance_id: this.id,
|
|
||||||
instance_name: this.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.hooks = new InstanceHooks(this.logger);
|
this.hooks = new InstanceHooks(this.logger);
|
||||||
|
|
||||||
@@ -225,28 +230,7 @@ export default class Instance extends lib.Link {
|
|||||||
};
|
};
|
||||||
this.config.on("fieldChanged", this._configFieldChanged);
|
this.config.on("fieldChanged", this._configFieldChanged);
|
||||||
|
|
||||||
let serverOptions = {
|
this.server = server;
|
||||||
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.on("output", (parsed, line) => {
|
this.server.on("output", (parsed, line) => {
|
||||||
this.logger.log("server", { message: line, instance_id: this.id, parsed });
|
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) {
|
_recordPlayerJoin(name: string) {
|
||||||
if (this.playersOnline.has(name)) {
|
if (this.playersOnline.has(name)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const wasPaused = this.autoPaused;
|
||||||
this.playersOnline.add(name);
|
this.playersOnline.add(name);
|
||||||
|
|
||||||
let stats = this.playerStats.get(name);
|
let stats = this.playerStats.get(name);
|
||||||
@@ -428,6 +421,9 @@ export default class Instance extends lib.Link {
|
|||||||
stats,
|
stats,
|
||||||
};
|
};
|
||||||
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats));
|
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats));
|
||||||
|
if (wasPaused) {
|
||||||
|
this.hooks.autoPauseChanged.invoke(false);
|
||||||
|
}
|
||||||
this.hooks.playerEvent.invoke(event);
|
this.hooks.playerEvent.invoke(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +445,9 @@ export default class Instance extends lib.Link {
|
|||||||
stats,
|
stats,
|
||||||
};
|
};
|
||||||
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("leave", name, stats, reason));
|
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("leave", name, stats, reason));
|
||||||
|
if (this.autoPaused) {
|
||||||
|
this.hooks.autoPauseChanged.invoke(true);
|
||||||
|
}
|
||||||
this.hooks.playerEvent.invoke(event);
|
this.hooks.playerEvent.invoke(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,6 +644,7 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
|
|
||||||
notifyExit() {
|
notifyExit() {
|
||||||
this._loadedSave = null;
|
this._loadedSave = null;
|
||||||
|
this._autoPause = false;
|
||||||
this.notifyStatus("stopped");
|
this.notifyStatus("stopped");
|
||||||
this.connector.emit("close");
|
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}`));
|
this._saveStats().catch(err => this.logger.error(`Error saving stats:\n${err.stack}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async _loadStats() {
|
static async _readStats(statsPath: string) {
|
||||||
let instanceStats;
|
let instanceStats;
|
||||||
try {
|
try {
|
||||||
instanceStats = JSON.parse(await fs.readFile(this.path("instance-stats.json"), "utf8"));
|
instanceStats = JSON.parse(await fs.readFile(statsPath, "utf8"));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
return;
|
return { playerStats: new Map<string, lib.PlayerStats>(), playerAutosaveSlot: 1 };
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
this.playerStats = new Map(instanceStats["players"].map(
|
return {
|
||||||
([id, stats]: [number, Static<typeof lib.PlayerStats.jsonSchema>]) => [id, new lib.PlayerStats(stats)])
|
playerStats: new Map<string, lib.PlayerStats>(instanceStats["players"].map(
|
||||||
);
|
([id, stats]: [string, Static<typeof lib.PlayerStats.jsonSchema>]) => [id, new lib.PlayerStats(stats)]
|
||||||
this._playerAutosaveSlot = instanceStats["player_autosave_slot"] || 1;
|
)),
|
||||||
|
playerAutosaveSlot: (instanceStats["player_autosave_slot"] as number) || 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async _saveStats() {
|
async _saveStats() {
|
||||||
@@ -694,21 +696,35 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
await lib.safeOutputFile(this.path("instance-stats.json"), content);
|
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.
|
* Create an instance with a Factorio server, downloading Factorio if needed
|
||||||
this.notifyStatus("starting");
|
*
|
||||||
|
* 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 {
|
try {
|
||||||
if (this.config.get("factorio.game_port") === null) {
|
const factorioVersions = await host.sendTo("controller", new lib.FactorioVersionsRequest());
|
||||||
this.server.gamePort = await this._host.assignFreeGamePort(this.id);
|
stats = await Instance._readStats(path.join(dir, "instance-stats.json"));
|
||||||
}
|
|
||||||
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
|
|
||||||
await this._loadStats();
|
|
||||||
// Resolve a release channel target (e.g. "stable") to a concrete
|
// Resolve a release channel target (e.g. "stable") to a concrete
|
||||||
// version using the controller's cached latest-releases data, so the
|
// version using the controller's cached latest-releases data, so the
|
||||||
// existing download/lookup logic can fetch and run it.
|
// 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)) {
|
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);
|
const resolved = lib.resolveReleaseChannel(releases, targetVersion);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -716,16 +732,46 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
"latest-releases unavailable and no cached copy on the controller."
|
"latest-releases unavailable and no cached copy on the controller."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.server.setTargetVersion(resolved);
|
version = resolved;
|
||||||
}
|
}
|
||||||
await this.server.checkForUpdates(factorioVersions);
|
const logger = instanceLogger(config);
|
||||||
await this.server.init();
|
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) {
|
} catch (err) {
|
||||||
this.notifyExit();
|
host.send(new lib.InstanceStatusChangedEvent(id, "stopped", gamePort, targetVersion));
|
||||||
await this.sendSaveListUpdate();
|
await host.sendSaveListUpdate(id, path.join(dir, "saves"));
|
||||||
throw err;
|
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
|
// load plugins
|
||||||
const plugins: Record<string, string> = {};
|
const plugins: Record<string, string> = {};
|
||||||
for (let pluginInfo of pluginInfos) {
|
for (let pluginInfo of pluginInfos) {
|
||||||
@@ -828,6 +874,7 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
this.server.writePath("server-settings.json"),
|
this.server.writePath("server-settings.json"),
|
||||||
JSON.stringify(serverSettings, null, "\t")
|
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
|
* Factorio refuses to read it if it is newer than the game. It then
|
||||||
* loads default settings instead, which can corrupt the save.
|
* 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) {
|
checkModPackVersion(modPack: lib.ModPack) {
|
||||||
const serverVersion = this.server.version;
|
const serverVersion = this.server.version;
|
||||||
if (serverVersion && modPack.integerFactorioVersion > lib.integerFullVersion(serverVersion)) {
|
if (serverVersion && modPack.integerFactorioVersion > lib.integerFullVersion(serverVersion)) {
|
||||||
throw new lib.RequestError(
|
throw new lib.ConflictError(
|
||||||
`Mod pack ${modPack.name} is for Factorio ${modPack.factorioVersion} ` +
|
`Mod pack ${modPack.name} is for Factorio ${modPack.factorioVersion} ` +
|
||||||
`which is newer than the Factorio ${serverVersion} this instance runs`
|
`which is newer than the Factorio ${serverVersion} this instance runs`
|
||||||
);
|
);
|
||||||
@@ -942,7 +989,9 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
*/
|
*/
|
||||||
async prepare() {
|
async prepare() {
|
||||||
this.logger.verbose("Writing server-settings.json");
|
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 = [];
|
this._expectedUserUpdates = [];
|
||||||
|
|
||||||
if (this.config.get("factorio.sync_adminlist") !== "disabled") {
|
if (this.config.get("factorio.sync_adminlist") !== "disabled") {
|
||||||
@@ -996,7 +1045,16 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
* @returns Name of the save prepared.
|
* @returns Name of the save prepared.
|
||||||
*/
|
*/
|
||||||
async prepareSave(saveName?: string) {
|
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) {
|
if (saveName === undefined) {
|
||||||
saveName = await lib.getNewestFile(
|
saveName = await lib.getNewestFile(
|
||||||
this.path("saves"), (name) => !name.endsWith(".tmp.zip")
|
this.path("saves"), (name) => !name.endsWith(".tmp.zip")
|
||||||
@@ -1331,7 +1389,7 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
async handleInstanceLoadScenarioRequest(request: lib.InstanceLoadScenarioRequest) {
|
async handleInstanceLoadScenarioRequest(request: lib.InstanceLoadScenarioRequest) {
|
||||||
if (this.config.get("factorio.enable_save_patching")) {
|
if (this.config.get("factorio.enable_save_patching")) {
|
||||||
this.notifyExit();
|
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 {
|
try {
|
||||||
@@ -1393,7 +1451,7 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
|
|
||||||
async handleInstanceSaveGameRequest() {
|
async handleInstanceSaveGameRequest() {
|
||||||
if (this._status !== "running") {
|
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) => {
|
const saved = new Promise<void>((resolve, reject) => {
|
||||||
@@ -1403,7 +1461,7 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
};
|
};
|
||||||
const onExit = () => {
|
const onExit = () => {
|
||||||
this.server.off("save-finished", onSaved);
|
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("save-finished", onSaved);
|
||||||
this.server.once("exit", onExit);
|
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
|
// 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 added = new Map([...files].map(([relativePath, contents]) => [rootPath(relativePath), contents]));
|
||||||
const output = new lib.ZipWriter();
|
tempSavePath = await writeSaveCopy(zip, savePath, added, removed);
|
||||||
let writeStream;
|
} finally {
|
||||||
[tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
|
zip.close();
|
||||||
const writing = pipeline(output.outputStream, writeStream);
|
}
|
||||||
// Awaited below, but a write error can reject it before then.
|
await fs.rename(tempSavePath, savePath);
|
||||||
writing.catch(() => {});
|
}
|
||||||
try {
|
|
||||||
for (const entry of zip.entries.values()) {
|
/**
|
||||||
const name = entry.fileName;
|
* Write a copy of a save with entries replaced, added and removed
|
||||||
if (removed.has(name)) {
|
*
|
||||||
continue;
|
* The copy is written next to the save, and must be renamed over it after
|
||||||
}
|
* the save has been closed.
|
||||||
const contents = added.get(name);
|
*
|
||||||
if (contents !== undefined) {
|
* @param zip - Opened save to copy.
|
||||||
await output.addBuffer(contents, name);
|
* @param savePath - Path the save was opened from.
|
||||||
added.delete(name);
|
* @param added - Entries by full path in the zip to add or replace.
|
||||||
} else {
|
* @param removed - Full paths of entries to leave out.
|
||||||
await output.addEntry(zip, entry);
|
* @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);
|
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 {
|
} finally {
|
||||||
zip.close();
|
zip.close();
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-95
@@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) {
|
|||||||
* @param factorioDir - Path to Factorio installation dirs.
|
* @param factorioDir - Path to Factorio installation dirs.
|
||||||
* @returns Set of installed factorio versions
|
* @returns Set of installed factorio versions
|
||||||
*/
|
*/
|
||||||
async function listFactorioVersions(factorioDir: string) {
|
export async function listFactorioVersions(factorioDir: string) {
|
||||||
const versions = new Set<lib.FullVersion>();
|
const versions = new Set<lib.FullVersion>();
|
||||||
|
|
||||||
const directVersion = await getFactorioVersion(factorioDir);
|
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
|
// Factorio downloads in progress by target directory, shared by every server in the process
|
||||||
const downloadsInProgress = new Map<string, Promise<void>>();
|
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
|
* Find factorio data directory of the given version
|
||||||
*
|
*
|
||||||
@@ -670,9 +737,8 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
_factorioDir: string;
|
_factorioDir: string;
|
||||||
_writeDir: string;
|
_writeDir: string;
|
||||||
|
|
||||||
// Resolved in init
|
_version: lib.FullVersion;
|
||||||
_version: lib.FullVersion | null = null;
|
_dataDir: string;
|
||||||
_dataDir: string | null = null;
|
|
||||||
|
|
||||||
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
|
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
|
||||||
// https://forums.factorio.com/viewtopic.php?t=123673
|
// https://forums.factorio.com/viewtopic.php?t=123673
|
||||||
@@ -680,8 +746,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
_whitelist = new Set<string>();
|
_whitelist = new Set<string>();
|
||||||
|
|
||||||
_logger: lib.Logger;
|
_logger: lib.Logger;
|
||||||
_targetVersion: lib.TargetVersion;
|
_state: "init" | "create" | "running" | "stopping" = "init";
|
||||||
_state: "new" | "init" | "create" | "running" | "stopping" = "new";
|
|
||||||
_server: child_process.ChildProcessWithoutNullStreams | null = null;
|
_server: child_process.ChildProcessWithoutNullStreams | null = null;
|
||||||
_rconClient: Rcon | null= null;
|
_rconClient: Rcon | null= null;
|
||||||
_rconReady = false;
|
_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 factorioDir - Directory of the Factorio install(s).
|
||||||
* @param writeDir - Directory to write runtime data to.
|
* @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();
|
super();
|
||||||
|
|
||||||
this._factorioDir = factorioDir;
|
this._factorioDir = factorioDir;
|
||||||
this._writeDir = writeDir;
|
this._writeDir = writeDir;
|
||||||
|
this._dataDir = dataDir;
|
||||||
|
this._version = version;
|
||||||
|
|
||||||
this._logger = options.logger || lib.logger;
|
this._logger = options.logger || lib.logger;
|
||||||
this._targetVersion = options.version || "latest";
|
|
||||||
this.executablePath = options.executablePath;
|
this.executablePath = options.executablePath;
|
||||||
/** UDP port used for hosting the Factorio game server on */
|
/** UDP port used for hosting the Factorio game server on */
|
||||||
this.gamePort = options.gamePort || randomDynamicPort();
|
this.gamePort = options.gamePort || randomDynamicPort();
|
||||||
/** TCP port used for RCON on the Factorio game server */
|
/** TCP port used for RCON on the Factorio game server */
|
||||||
this.rconPort = options.rconPort || randomDynamicPort();
|
this.rconPort = options.rconPort || randomDynamicPort();
|
||||||
/** Password used for RCON on the Factorio game server */
|
/** 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 */
|
/** Enable UDP messaging with the Factorio game server */
|
||||||
this.enableLuaUdp = options.enableLuaUdp || false;
|
this.enableLuaUdp = options.enableLuaUdp || false;
|
||||||
/** UDP port the Factorio game server receives Lua UDP messages on */
|
/** 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
|
// Factorio before 2.1.10 crashes when a packet is received on a headless server
|
||||||
_checkLuaUdpVersion() {
|
_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");
|
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
|
* @param factorioDir - Directory of the Factorio install(s).
|
||||||
* (e.g. "stable" or "experimental") before {@link checkForUpdates} and
|
* @param writeDir - Directory to write runtime data to.
|
||||||
* {@link init} run. Must be called before the server is initialized.
|
* @param options - Optional parameters.
|
||||||
*/
|
|
||||||
setTargetVersion(version: lib.TargetVersion) {
|
|
||||||
this._check(["new"]);
|
|
||||||
this._targetVersion = version;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize class instance
|
|
||||||
*
|
|
||||||
* Must be called before instances of this class can be used.
|
|
||||||
* @throws {Error} if the requested version of Factorio was not found.
|
* @throws {Error} if the requested version of Factorio was not found.
|
||||||
*/
|
*/
|
||||||
async init() {
|
static async fromOptions(factorioDir: string, writeDir: string, options: FactorioServerOptions) {
|
||||||
this._check(["new"]);
|
const [dataDir, version] = await findVersion(factorioDir, options.version || "latest");
|
||||||
[this._dataDir, this._version] = await findVersion(this._factorioDir, this._targetVersion);
|
const rconPassword = options.rconPassword ?? await generatePassword(10);
|
||||||
this.rconPassword = this.rconPassword as string | undefined ?? await generatePassword(10);
|
return new this(factorioDir, writeDir, dataDir, version, { ...options, rconPassword });
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The version of Factorio in use. This will be the actual version if
|
* The version of Factorio in use. This will be the actual version if
|
||||||
* "latest" (the default) was specified as `factorioVersion` to the constructor.
|
* "latest" (the default) was passed to {@link fromOptions}.
|
||||||
* This will be undefined before the server is initialized, or if init failed.
|
|
||||||
*/
|
*/
|
||||||
get version(): lib.FullVersion | undefined {
|
get version(): lib.FullVersion {
|
||||||
return this._version ?? undefined;
|
return this._version;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1681,14 +1686,13 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
* Return path in data directory
|
* Return path in data directory
|
||||||
*
|
*
|
||||||
* Creates a path using path.join with the given parts that's relative to
|
* 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
|
* the data directory of the Factorio server.
|
||||||
* been called.
|
|
||||||
*
|
*
|
||||||
* @param parts - Extra parts to add to the data path.
|
* @param parts - Extra parts to add to the data path.
|
||||||
* @returns Data directory path.
|
* @returns Data directory path.
|
||||||
*/
|
*/
|
||||||
dataPath(...parts: string[]) {
|
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/api";
|
||||||
export * from "./src/AsyncHook";
|
export * from "./src/AsyncHook";
|
||||||
export * from "./src/SyncHook";
|
export * from "./src/SyncHook";
|
||||||
|
|||||||
Vendored
+1
-1
@@ -6,5 +6,5 @@ export interface BuildArgs {
|
|||||||
build?: boolean;
|
build?: boolean;
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
sourceDir: string;
|
sourceDir: string;
|
||||||
};
|
}
|
||||||
export function build(args: BuildArgs): Promise<void>;
|
export function build(args: BuildArgs): Promise<void>;
|
||||||
@@ -35,6 +35,7 @@ export * from "./src/subscriptions.js";
|
|||||||
export * from "./src/datastore.js";
|
export * from "./src/datastore.js";
|
||||||
export * from "./src/load_plugin_list.js";
|
export * from "./src/load_plugin_list.js";
|
||||||
export * from "./src/rce_ops.js";
|
export * from "./src/rce_ops.js";
|
||||||
|
export * from "./src/npm_registry.js";
|
||||||
export * from "./src/LockFile.js";
|
export * from "./src/LockFile.js";
|
||||||
export * from "./src/AsyncHook.js";
|
export * from "./src/AsyncHook.js";
|
||||||
export * from "./src/SyncHook.js";
|
export * from "./src/SyncHook.js";
|
||||||
|
|||||||
@@ -153,9 +153,10 @@ export class AsyncHookCollection {
|
|||||||
Args extends unknown[],
|
Args extends unknown[],
|
||||||
Result = void,
|
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);
|
this.hooks.push(hook);
|
||||||
return hook;
|
return hook;
|
||||||
}
|
}
|
||||||
|
|||||||
+144
-31
@@ -1,5 +1,5 @@
|
|||||||
import { FileHandle, open, unlink, readFile } from "node:fs/promises";
|
import fs, { type FileHandle } from "node:fs/promises";
|
||||||
import { unlinkSync } from "node:fs";
|
import { readFileSync, unlinkSync } from "node:fs";
|
||||||
import assert from "assert";
|
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 LockState = "idle" | "acquiring" | "acquired" | "releasing";
|
||||||
|
type LockSnapshot = { content: string, ino: bigint, mtimeNs: bigint };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-platform lock file utility for Node.js.
|
* Cross-platform lock file utility for Node.js.
|
||||||
*/
|
*/
|
||||||
export class LockFile {
|
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 handle: FileHandle | null = null;
|
||||||
private state: LockState = "idle";
|
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(
|
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.
|
* Acquire the lock file.
|
||||||
* Throws LockFileExistsError if already locked.
|
* Throws LockFileExistsError if already locked.
|
||||||
* Throws LockFileBusyError if not in idle state.
|
* Throws LockFileBusyError if not in idle state.
|
||||||
|
* Throws LockFileRecoveryError if clearing a stale lock was interrupted.
|
||||||
*/
|
*/
|
||||||
async acquire(): Promise<void> {
|
async acquire(): Promise<void> {
|
||||||
if (this.state !== "idle") {
|
if (this.state !== "idle") {
|
||||||
@@ -49,13 +125,10 @@ export class LockFile {
|
|||||||
|
|
||||||
this.state = "acquiring";
|
this.state = "acquiring";
|
||||||
|
|
||||||
if (await this.isStale()) {
|
|
||||||
await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.handle = await open(this.filePath, "wx");
|
await this.removeStale();
|
||||||
await this.handle.write(`${process.pid}\n`);
|
this.handle = await fs.open(this.filePath, "wx");
|
||||||
|
await this.handle.write(LockFile.content);
|
||||||
this.attachExitHandlers();
|
this.attachExitHandlers();
|
||||||
this.state = "acquired";
|
this.state = "acquired";
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -81,7 +154,9 @@ export class LockFile {
|
|||||||
|
|
||||||
// Ignored: file may have already been removed by another process
|
// Ignored: file may have already been removed by another process
|
||||||
try { await this.handle?.close(); } catch {}
|
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.handle = null;
|
||||||
this.state = "idle";
|
this.state = "idle";
|
||||||
@@ -89,19 +164,20 @@ export class LockFile {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether the lock is currently held by this process.
|
* Check whether the lock is currently held by this process.
|
||||||
* True only if the lock is acquired and the PID in the file
|
* True only if the lock is acquired and the lock file was written by this process.
|
||||||
* matches the current process.
|
|
||||||
*/
|
*/
|
||||||
async isHeld(): Promise<boolean> {
|
async isHeld(): Promise<boolean> {
|
||||||
if (this.state !== "acquired") {
|
if (this.state !== "acquired") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return await this.isHeldFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isHeldFile() {
|
||||||
try {
|
try {
|
||||||
const pidInLock = Number(await readFile(this.filePath, "utf8"));
|
return (await LockFile.readLock(this.filePath))?.content === LockFile.content;
|
||||||
return pidInLock === process.pid;
|
|
||||||
} catch {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,14 +186,45 @@ export class LockFile {
|
|||||||
* Determine whether an existing lock is stale.
|
* Determine whether an existing lock is stale.
|
||||||
*/
|
*/
|
||||||
async isStale(): Promise<boolean> {
|
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 {
|
try {
|
||||||
const pidInLock = Number(await readFile(this.filePath, "utf8"));
|
await fs.writeFile(recoveryPath, LockFile.content, { flag: "wx" });
|
||||||
if (!Number.isFinite(pidInLock)) { return true; }
|
} catch (err: any) {
|
||||||
// signal 0 is a special case to test if a process exists
|
if (err.code !== "EEXIST") {
|
||||||
process.kill(pidInLock, 0);
|
throw err;
|
||||||
return false;
|
}
|
||||||
} catch {
|
const holder = await LockFile.readLock(recoveryPath);
|
||||||
return true;
|
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 {
|
private attachExitHandlers(): void {
|
||||||
assert(this.state === "acquiring");
|
assert(this.state === "acquiring");
|
||||||
process.on("SIGINT", this.handleExit);
|
if (this.handleSignals) {
|
||||||
process.on("SIGTERM", this.handleExit);
|
process.on("SIGINT", this.handleExit);
|
||||||
process.on("SIGHUP", this.handleExit);
|
process.on("SIGTERM", this.handleExit);
|
||||||
process.on("uncaughtException", this.handleExit);
|
process.on("SIGHUP", this.handleExit);
|
||||||
|
process.on("uncaughtException", this.handleExit);
|
||||||
|
}
|
||||||
// eslint-disable-next-line node/no-sync
|
// eslint-disable-next-line node/no-sync
|
||||||
process.on("exit", this.handleExitSync);
|
process.on("exit", this.handleExitSync);
|
||||||
}
|
}
|
||||||
@@ -137,7 +246,7 @@ export class LockFile {
|
|||||||
/**
|
/**
|
||||||
* Detach process handlers, called when transitioning to "idle"
|
* Detach process handlers, called when transitioning to "idle"
|
||||||
*/
|
*/
|
||||||
private async detachExitHandlers(): Promise<void> {
|
private detachExitHandlers(): void {
|
||||||
assert(this.state === "releasing");
|
assert(this.state === "releasing");
|
||||||
process.off("SIGINT", this.handleExit);
|
process.off("SIGINT", this.handleExit);
|
||||||
process.off("SIGTERM", this.handleExit);
|
process.off("SIGTERM", this.handleExit);
|
||||||
@@ -147,17 +256,21 @@ export class LockFile {
|
|||||||
process.off("exit", this.handleExitSync);
|
process.off("exit", this.handleExitSync);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event handler for async signals / exceptions
|
// Event handler for async signals / exceptions
|
||||||
private handleExit = async (): Promise<void> => {
|
private handleExit = async (): Promise<void> => {
|
||||||
await this.release();
|
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 => {
|
private handleExitSync = (): void => {
|
||||||
try {
|
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) {
|
} 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.
|
* package.
|
||||||
*/
|
*/
|
||||||
npmPackage?: string;
|
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 { StringEnum } from "../data/composites.js";
|
||||||
import { safeOutputFile } from "../file_ops.js";
|
import { safeOutputFile } from "../file_ops.js";
|
||||||
import * as validators from "./validators.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";
|
import type { SyncHook } from "../SyncHook.js";
|
||||||
|
|
||||||
const ConfigLocation = StringEnum(["controller", "host", "control"]);
|
const ConfigLocation = StringEnum(["controller", "host", "control"]);
|
||||||
export type ConfigLocation = Static<typeof ConfigLocation>;
|
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({
|
const OldConfigGroupSchema = Type.Object({
|
||||||
"name": Type.String(),
|
"name": Type.String(),
|
||||||
"fields": Type.Record(Type.String(), Type.Unknown()),
|
"fields": Type.Record(Type.String(), Type.Unknown()),
|
||||||
@@ -55,7 +24,7 @@ type OldConfigGroupSchema = Static<typeof OldConfigGroupSchema>;
|
|||||||
const OldConfigSchema = Type.Object({
|
const OldConfigSchema = Type.Object({
|
||||||
"groups": Type.Array(OldConfigGroupSchema),
|
"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([
|
export const ConfigSchema = Type.Record(Type.String(), Type.Union([
|
||||||
Type.Null(),
|
Type.Null(),
|
||||||
@@ -69,11 +38,11 @@ export type ConfigSchema = Static<typeof ConfigSchema>;
|
|||||||
type FieldValue = null | boolean | string | number | object;
|
type FieldValue = null | boolean | string | number | object;
|
||||||
|
|
||||||
type FieldType<T extends FieldValue> =
|
type FieldType<T extends FieldValue> =
|
||||||
T extends boolean ? "boolean" :
|
T extends boolean ? "boolean"
|
||||||
T extends string ? "string" :
|
: T extends string ? "string"
|
||||||
T extends number ? "number" :
|
: T extends number ? "number"
|
||||||
T extends object ? "object" :
|
: T extends object ? "object"
|
||||||
never;
|
: never;
|
||||||
|
|
||||||
export interface FieldDefinition<
|
export interface FieldDefinition<
|
||||||
Type extends FieldValue = any,
|
Type extends FieldValue = any,
|
||||||
@@ -165,7 +134,7 @@ export type ConfigDefs<
|
|||||||
Fields extends Record<keyof Fields, FieldValue>
|
Fields extends Record<keyof Fields, FieldValue>
|
||||||
> = {
|
> = {
|
||||||
[Prop in keyof Fields]: FieldDefinition<Fields[Prop], Fields>;
|
[Prop in keyof Fields]: FieldDefinition<Fields[Prop], Fields>;
|
||||||
}
|
};
|
||||||
|
|
||||||
function defaultValue(def: FieldDefinition) {
|
function defaultValue(def: FieldDefinition) {
|
||||||
if (def.initialValue === undefined) {
|
if (def.initialValue === undefined) {
|
||||||
@@ -179,7 +148,7 @@ function defaultValue(def: FieldDefinition) {
|
|||||||
|
|
||||||
export type ConfigEvents = {
|
export type ConfigEvents = {
|
||||||
fieldChanged: [ name: string, curr: FieldValue, prev: FieldValue ],
|
fieldChanged: [ name: string, curr: FieldValue, prev: FieldValue ],
|
||||||
}
|
};
|
||||||
|
|
||||||
export enum ConfigAccess {
|
export enum ConfigAccess {
|
||||||
read = 0x1,
|
read = 0x1,
|
||||||
@@ -368,7 +337,7 @@ export class Config<
|
|||||||
* @param name - Name of the field being validated.
|
* @param name - Name of the field being validated.
|
||||||
* @param def - Definition describing the field type and constraints.
|
* @param def - Definition describing the field type and constraints.
|
||||||
* @param value - Value to validate and coerce.
|
* @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.
|
* @returns The validated and coerced value.
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
@@ -380,7 +349,7 @@ export class Config<
|
|||||||
|
|
||||||
// If def is an enum then it must be a value within it
|
// If def is an enum then it must be a value within it
|
||||||
if (def.enum && !def.enum.includes(value)) {
|
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
|
// If it is a string, then attempt to coerce it
|
||||||
@@ -402,7 +371,7 @@ export class Config<
|
|||||||
try {
|
try {
|
||||||
value = JSON.parse(value);
|
value = JSON.parse(value);
|
||||||
} catch (err: any) {
|
} 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
|
// Check if it is allowed to be null
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
if (!def.optional) {
|
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
|
// Check if is the correct type
|
||||||
} else if (basicType(value) !== def.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"]>;
|
return value as FieldType<T["type"]>;
|
||||||
@@ -429,7 +398,7 @@ export class Config<
|
|||||||
* @param location - location to check access for.
|
* @param location - location to check access for.
|
||||||
* @param mode - type of access to check for.
|
* @param mode - type of access to check for.
|
||||||
* @param error - throw on failure to pass check.
|
* @param error - throw on failure to pass check.
|
||||||
* @throws {InvalidAccess}
|
* @throws {InvalidAccessError}
|
||||||
* if access is not granted and error is true.
|
* if access is not granted and error is true.
|
||||||
* @returns true if access is granted
|
* @returns true if access is granted
|
||||||
* @private
|
* @private
|
||||||
@@ -448,7 +417,7 @@ export class Config<
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new InvalidAccess(`Field '${name}' is not accessible from ${location}`);
|
throw new InvalidAccessError(`Field '${name}' is not accessible from ${location}`);
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -648,7 +617,7 @@ export class Config<
|
|||||||
}
|
}
|
||||||
let def = this.constructor.fieldDefinitions[name];
|
let def = this.constructor.fieldDefinitions[name];
|
||||||
if (!def) {
|
if (!def) {
|
||||||
throw new InvalidField(`No field named '${name}'`);
|
throw new InvalidFieldError(`No field named '${name}'`);
|
||||||
}
|
}
|
||||||
return this._checkAccess(name, def, location, mode, false);
|
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) {
|
get<Field extends keyof Fields & string>(name: Field, remote = this.location) {
|
||||||
let def = this.constructor.fieldDefinitions[name];
|
let def = this.constructor.fieldDefinitions[name];
|
||||||
if (!def) {
|
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 access is requested require it to be able to read the field
|
||||||
if (remote !== this.location) {
|
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.write, false)
|
||||||
&& !this._checkAccess(name, def, this.location, ConfigAccess.read, 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];
|
return this.fields[name];
|
||||||
@@ -690,13 +659,13 @@ export class Config<
|
|||||||
* @param name - Name of field to set.
|
* @param name - Name of field to set.
|
||||||
* @param newValue - Value to set for field.
|
* @param newValue - Value to set for field.
|
||||||
* @param remote - Location this field is being set from.
|
* @param remote - Location this field is being set from.
|
||||||
* @throws {InvalidField} if field is not defined.
|
* @throws {InvalidFieldError} if field is not defined.
|
||||||
* @throws {InvalidValue} if value is not allowed for the field.
|
* @throws {InvalidValueError} if value is not allowed for the field.
|
||||||
*/
|
*/
|
||||||
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
||||||
const def = this.constructor.fieldDefinitions[name];
|
const def = this.constructor.fieldDefinitions[name];
|
||||||
if (!def) {
|
if (!def) {
|
||||||
throw new InvalidField(`No field named '${name}'`);
|
throw new InvalidFieldError(`No field named '${name}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||||
@@ -714,7 +683,7 @@ export class Config<
|
|||||||
try {
|
try {
|
||||||
def.validator(value, this);
|
def.validator(value, this);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
|
throw new InvalidValueError(`Failed validation of ${name}: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -732,7 +701,7 @@ export class Config<
|
|||||||
try {
|
try {
|
||||||
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
||||||
} catch (err: any) {
|
} 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 {
|
} finally {
|
||||||
@@ -753,8 +722,8 @@ export class Config<
|
|||||||
* @param prop - Name of property to set on field.
|
* @param prop - Name of property to set on field.
|
||||||
* @param value - the value to set the property to.
|
* @param value - the value to set the property to.
|
||||||
* @param remote - Location this property is being set from
|
* @param remote - Location this property is being set from
|
||||||
* @throws {InvalidField} if field is not defined.
|
* @throws {InvalidFieldError} if field is not defined.
|
||||||
* @throws {InvalidValue} if field is not an object.
|
* @throws {InvalidValueError} if field is not an object.
|
||||||
*/
|
*/
|
||||||
setProp<Field extends keyof Fields & string>(
|
setProp<Field extends keyof Fields & string>(
|
||||||
name: Field,
|
name: Field,
|
||||||
@@ -764,7 +733,7 @@ export class Config<
|
|||||||
) {
|
) {
|
||||||
let def = this.constructor.fieldDefinitions[name];
|
let def = this.constructor.fieldDefinitions[name];
|
||||||
if (!def) {
|
if (!def) {
|
||||||
throw new InvalidField(`No field named '${name}'`);
|
throw new InvalidFieldError(`No field named '${name}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||||
@@ -773,7 +742,7 @@ export class Config<
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (def.type !== "object") {
|
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>;
|
let prev = this.fields[name] as Record<string, unknown>;
|
||||||
@@ -793,7 +762,7 @@ export class Config<
|
|||||||
try {
|
try {
|
||||||
def.validator(updated, this);
|
def.validator(updated, this);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
|
throw new InvalidValueError(`Failed validation of ${name}: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -811,7 +780,7 @@ export class Config<
|
|||||||
try {
|
try {
|
||||||
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
||||||
} catch (err: any) {
|
} 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 {
|
} finally {
|
||||||
@@ -829,8 +798,8 @@ export class Config<
|
|||||||
* @param name - Name of field to set.
|
* @param name - Name of field to set.
|
||||||
* @param newValue - Value to set for field.
|
* @param newValue - Value to set for field.
|
||||||
* @param remote - Location this field is being set from.
|
* @param remote - Location this field is being set from.
|
||||||
* @throws {InvalidField} if field is not defined.
|
* @throws {InvalidFieldError} if field is not defined.
|
||||||
* @throws {InvalidValue} if value is not allowed for the field.
|
* @throws {InvalidValueError} if value is not allowed for the field.
|
||||||
*/
|
*/
|
||||||
stage<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
stage<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
||||||
if (this._readonly) {
|
if (this._readonly) {
|
||||||
@@ -839,7 +808,7 @@ export class Config<
|
|||||||
|
|
||||||
const def = this.constructor.fieldDefinitions[name];
|
const def = this.constructor.fieldDefinitions[name];
|
||||||
if (!def) {
|
if (!def) {
|
||||||
throw new InvalidField(`No field named '${name}'`);
|
throw new InvalidFieldError(`No field named '${name}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
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 prop - Name of property to set on field.
|
||||||
* @param value - the value to set the property to.
|
* @param value - the value to set the property to.
|
||||||
* @param remote - Location this property is being set from
|
* @param remote - Location this property is being set from
|
||||||
* @throws {InvalidField} if field is not defined.
|
* @throws {InvalidFieldError} if field is not defined.
|
||||||
* @throws {InvalidValue} if field is not an object.
|
* @throws {InvalidValueError} if field is not an object.
|
||||||
*/
|
*/
|
||||||
stageProp<Field extends keyof Fields & string>(
|
stageProp<Field extends keyof Fields & string>(
|
||||||
name: Field,
|
name: Field,
|
||||||
@@ -874,7 +843,7 @@ export class Config<
|
|||||||
|
|
||||||
const def = this.constructor.fieldDefinitions[name];
|
const def = this.constructor.fieldDefinitions[name];
|
||||||
if (!def) {
|
if (!def) {
|
||||||
throw new InvalidField(`No field named '${name}'`);
|
throw new InvalidFieldError(`No field named '${name}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||||
@@ -883,7 +852,7 @@ export class Config<
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (def.type !== "object") {
|
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>;
|
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.
|
* 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() {
|
commitStaging() {
|
||||||
if (this._readonly) {
|
if (this._readonly) {
|
||||||
@@ -923,7 +892,7 @@ export class Config<
|
|||||||
try {
|
try {
|
||||||
fieldDef.validator(config.fields[fieldName as keyof Fields], config);
|
fieldDef.validator(config.fields[fieldName as keyof Fields], config);
|
||||||
} catch (err: any) {
|
} 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
|
// Core definitions for the configuration system
|
||||||
import * as classes from "./classes.js";
|
import * as classes from "./classes.js";
|
||||||
import * as validators from "./validators.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 { Static } from "@sinclair/typebox";
|
||||||
import { isTargetVersion } from "../data/version.js";
|
import { isTargetVersion } from "../data/version.js";
|
||||||
import { SyncHook } from "../SyncHook.js";
|
import { SyncHook } from "../SyncHook.js";
|
||||||
@@ -31,6 +31,9 @@ export interface ControllerConfigFields {
|
|||||||
"controller.max_subscription_filters": number;
|
"controller.max_subscription_filters": number;
|
||||||
"controller.system_metrics_interval": number;
|
"controller.system_metrics_interval": number;
|
||||||
"controller.proxy_stream_timeout": number;
|
"controller.proxy_stream_timeout": number;
|
||||||
|
"controller.player_proxy_port": number | null;
|
||||||
|
"controller.player_proxy_default_instance_id": number | null;
|
||||||
|
"controller.player_proxy_public_address": string | null;
|
||||||
"controller.factorio_username": string | null;
|
"controller.factorio_username": string | null;
|
||||||
"controller.factorio_token": string | null;
|
"controller.factorio_token": string | null;
|
||||||
"controller.share_factorio_credentials_with_hosts": boolean;
|
"controller.share_factorio_credentials_with_hosts": boolean;
|
||||||
@@ -151,7 +154,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
|||||||
optional: true,
|
optional: true,
|
||||||
validator: (value) => {
|
validator: (value) => {
|
||||||
if (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;
|
const pathname = new URL(value, "http://localhost/").pathname;
|
||||||
if (pathname !== "/static/") {
|
if (pathname !== "/static/") {
|
||||||
throw new Error(`Path component of static_url must be /static/ not ${pathname}.`);
|
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,
|
initialValue: 15,
|
||||||
validator: validators.greaterThanZero,
|
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": {
|
"controller.factorio_username": {
|
||||||
title: "Factorio Username",
|
title: "Factorio Username",
|
||||||
description: "Username to authenticate with Factorio API with.",
|
description: "Username to authenticate with Factorio API with.",
|
||||||
@@ -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 " +
|
"Range of UDP ports to use for game connections. Supports both comma separated values and " +
|
||||||
"ranges separated with a dash.",
|
"ranges separated with a dash.",
|
||||||
type: "string",
|
type: "string",
|
||||||
initialValue: "34100-34199",
|
initialValue: "34200-34299",
|
||||||
// TODO extendedValidation, port range
|
// TODO extendedValidation, port range
|
||||||
},
|
},
|
||||||
"host.factorio_username": {
|
"host.factorio_username": {
|
||||||
@@ -471,6 +502,7 @@ export interface InstanceConfigFields {
|
|||||||
"instance.id": number;
|
"instance.id": number;
|
||||||
"instance.assigned_host": number | null;
|
"instance.assigned_host": number | null;
|
||||||
"instance.auto_start": boolean;
|
"instance.auto_start": boolean;
|
||||||
|
"instance.player_proxy_port": number | null;
|
||||||
"instance.exclude_from_start_all": boolean;
|
"instance.exclude_from_start_all": boolean;
|
||||||
[key: `${string}.load_plugin`]: boolean;
|
[key: `${string}.load_plugin`]: boolean;
|
||||||
[key: `${string}.version`]: string;
|
[key: `${string}.version`]: string;
|
||||||
@@ -545,6 +577,14 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
initialValue: false,
|
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": {
|
"instance.exclude_from_start_all": {
|
||||||
description: "Exclude this instance from the 'Start all' button operation",
|
description: "Exclude this instance from the 'Start all' button operation",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
@@ -786,7 +826,7 @@ function validateFieldNames(
|
|||||||
* Add the config fields defined by the by a specific plugin infos
|
* Add the config fields defined by the by a specific plugin infos
|
||||||
*/
|
*/
|
||||||
export function addPluginFieldDefinitions(
|
export function addPluginFieldDefinitions(
|
||||||
pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo,
|
pluginInfo: PluginNodeEnvInfo | PluginWebEnvInfo,
|
||||||
kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields",
|
kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields",
|
||||||
Config: typeof classes.Config<any>
|
Config: typeof classes.Config<any>
|
||||||
) {
|
) {
|
||||||
@@ -858,7 +898,7 @@ export function addPluginFieldDefinitions(
|
|||||||
*
|
*
|
||||||
* @param {Array<Object>} pluginInfos - Array of plugin info objects.
|
* @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) {
|
for (let pluginInfo of pluginInfos) {
|
||||||
addPluginFieldDefinitions(pluginInfo, "controllerConfigFields", ControllerConfig);
|
addPluginFieldDefinitions(pluginInfo, "controllerConfigFields", ControllerConfig);
|
||||||
if (pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint) {
|
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 { type LogEntry, levels } from "../logging.js";
|
||||||
import { ControllerConfig, HostConfig } from "../config/index.js";
|
import { ControllerConfig, HostConfig } from "../config/index.js";
|
||||||
import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external/index.js";
|
import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external/index.js";
|
||||||
|
import { PluginDetails } from "./messages_plugin.js";
|
||||||
|
|
||||||
const LogEntrySchema = Type.Object({
|
const LogEntrySchema = Type.Object({
|
||||||
"level": StringKey(levels),
|
"level": StringKey(levels),
|
||||||
@@ -298,6 +299,8 @@ export class SystemInfo {
|
|||||||
/** Millisecond Unix timestamp this entry was last updated at */
|
/** Millisecond Unix timestamp this entry was last updated at */
|
||||||
public updatedAtMs: number,
|
public updatedAtMs: number,
|
||||||
public isDeleted: boolean,
|
public isDeleted: boolean,
|
||||||
|
/** Plugins on the machine when last collected, kept while it is offline */
|
||||||
|
public plugins?: PluginDetails[],
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
static jsonSchema = Type.Object({
|
static jsonSchema = Type.Object({
|
||||||
@@ -318,6 +321,7 @@ export class SystemInfo {
|
|||||||
"processStartedAtMs": Type.Number(),
|
"processStartedAtMs": Type.Number(),
|
||||||
"updatedAtMs": Type.Number(),
|
"updatedAtMs": Type.Number(),
|
||||||
"isDeleted": Type.Boolean(),
|
"isDeleted": Type.Boolean(),
|
||||||
|
"plugins": Type.Optional(Type.Array(PluginDetails.jsonSchema)),
|
||||||
});
|
});
|
||||||
|
|
||||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
@@ -339,6 +343,7 @@ export class SystemInfo {
|
|||||||
json.processStartedAtMs,
|
json.processStartedAtMs,
|
||||||
json.updatedAtMs,
|
json.updatedAtMs,
|
||||||
json.isDeleted,
|
json.isDeleted,
|
||||||
|
json.plugins?.map(plugin => PluginDetails.fromJSON(plugin)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +440,7 @@ export class DebugWsMessageEvent {
|
|||||||
export class FactorioVersionsRequest {
|
export class FactorioVersionsRequest {
|
||||||
declare ["constructor"]: typeof FactorioVersionsRequest;
|
declare ["constructor"]: typeof FactorioVersionsRequest;
|
||||||
static type = "request" as const;
|
static type = "request" as const;
|
||||||
static src = ["control", "instance"] as const;
|
static src = ["control", "host", "instance"] as const;
|
||||||
static dst = "controller" as const;
|
static dst = "controller" as const;
|
||||||
static permission = "core.external.get_factorio_versions" as const;
|
static permission = "core.external.get_factorio_versions" as const;
|
||||||
|
|
||||||
@@ -457,7 +462,7 @@ export class FactorioVersionsRequest {
|
|||||||
export class LatestReleasesRequest {
|
export class LatestReleasesRequest {
|
||||||
declare ["constructor"]: typeof LatestReleasesRequest;
|
declare ["constructor"]: typeof LatestReleasesRequest;
|
||||||
static type = "request" as const;
|
static type = "request" as const;
|
||||||
static src = ["control", "instance"] as const;
|
static src = ["control", "host", "instance"] as const;
|
||||||
static dst = "controller" as const;
|
static dst = "controller" as const;
|
||||||
static permission = "core.external.get_latest_releases" 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;
|
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 {
|
export class HostConfigGetRequest {
|
||||||
declare ["constructor"]: typeof HostConfigGetRequest;
|
declare ["constructor"]: typeof HostConfigGetRequest;
|
||||||
static type = "request" as const;
|
static type = "request" as const;
|
||||||
|
|||||||
@@ -251,7 +251,6 @@ export class ModPortalGetAllRequest {
|
|||||||
|
|
||||||
// Define the Response class inline
|
// Define the Response class inline
|
||||||
static Response = class ModPortalGetAllResponse {
|
static Response = class ModPortalGetAllResponse {
|
||||||
declare ["constructor"]: typeof ModPortalGetAllResponse;
|
|
||||||
constructor(
|
constructor(
|
||||||
public mods: Static<typeof ModPortalDetailsSchema>[],
|
public mods: Static<typeof ModPortalDetailsSchema>[],
|
||||||
) { }
|
) { }
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ export class PluginDetails {
|
|||||||
public enabled: boolean,
|
public enabled: boolean,
|
||||||
public description?: string,
|
public description?: string,
|
||||||
public npmPackage?: 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({
|
static jsonSchema = Type.Object({
|
||||||
@@ -24,12 +36,19 @@ export class PluginDetails {
|
|||||||
enabled: Type.Boolean(),
|
enabled: Type.Boolean(),
|
||||||
description: Type.Optional(Type.String()),
|
description: Type.Optional(Type.String()),
|
||||||
npmPackage: 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>) {
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
return new this(
|
return new this(
|
||||||
json.name, json.title, json.version, json.loaded, json.enabled,
|
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,
|
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 {
|
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 {
|
export class UpdateAllRequest {
|
||||||
declare ["constructor"]: typeof UpdateAllRequest;
|
declare ["constructor"]: typeof UpdateAllRequest;
|
||||||
static type = "request" as const;
|
static type = "request" as const;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
|
|||||||
import UserDetails, { IUser } from "./UserDetails.js";
|
import UserDetails, { IUser } from "./UserDetails.js";
|
||||||
import { StringEnum, jsonArray, plainJson } from "./composites.js";
|
import { StringEnum, jsonArray, plainJson } from "./composites.js";
|
||||||
import { MessageRequest } from "./messages_core.js";
|
import { MessageRequest } from "./messages_core.js";
|
||||||
import { PermissionError } from "../errors.js";
|
import { ValidationError } from "../errors.js";
|
||||||
|
|
||||||
export class UserGetRequest {
|
export class UserGetRequest {
|
||||||
declare ["constructor"]: typeof UserGetRequest;
|
declare ["constructor"]: typeof UserGetRequest;
|
||||||
@@ -277,9 +277,9 @@ export class UserBulkImportRequest {
|
|||||||
user.checkPermission("core.user.set_whitelisted");
|
user.checkPermission("core.user.set_whitelisted");
|
||||||
break;
|
break;
|
||||||
default:
|
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
|
// @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,
|
K extends DatastoreKey,
|
||||||
V extends DatastoreValue,
|
V extends DatastoreValue,
|
||||||
> {
|
> {
|
||||||
abstract save(data: Map<K, V>): Promise<void>
|
abstract save(data: Map<K, V>): Promise<void>;
|
||||||
abstract load(): Promise<Map<K, V>>
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store all data in memory, used as a default and should be avoided
|
// Store all data in memory, used as a default and should be avoided
|
||||||
@@ -251,6 +246,11 @@ export class KeyValueDatastore<
|
|||||||
K extends DatastoreKey,
|
K extends DatastoreKey,
|
||||||
V extends DatastoreValue,
|
V extends DatastoreValue,
|
||||||
> extends Datastore<K, V, [key: K, value: V, isDeleted?: boolean]> {
|
> 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 the value in the datastore, be careful of race conditions if you await any functions before calling set
|
||||||
set(key: K, value: V) {
|
set(key: K, value: V) {
|
||||||
this.data.set(key, value);
|
this.data.set(key, value);
|
||||||
@@ -316,6 +316,11 @@ export class SubscribableDatastore<
|
|||||||
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
|
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() {
|
async load() {
|
||||||
await super.load();
|
await super.load();
|
||||||
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
|
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 {
|
export class ClientError extends Error { }
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public code = "RequestError",
|
|
||||||
public stack?: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown when a permission check fails.
|
* Thrown when the request contains invalid data (HTTP 400)
|
||||||
*
|
|
||||||
* Is a subclass of RequestError to prevent logging stack traces when
|
|
||||||
* requests fail due to permission denied.
|
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
export class PermissionError extends RequestError {
|
export class ValidationError extends ClientError { }
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
code = "PermissionError",
|
|
||||||
stack?: string,
|
|
||||||
) {
|
|
||||||
super(message, code, stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
/**
|
||||||
|
* 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
|
* Signal for messages that fail validation
|
||||||
*/
|
*/
|
||||||
export class InvalidMessage extends Error {
|
export class InvalidMessage extends ValidationError {
|
||||||
code = "InvalidMessage";
|
code = "InvalidMessage";
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export type FactorioModSettingType = "startup" | "runtime-global" | "runtime-per
|
|||||||
* Boolean mod setting prototype.
|
* Boolean mod setting prototype.
|
||||||
* @see [bool-setting](https://wiki.factorio.com/Tutorial:Mod_settings#bool-setting)
|
* @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",
|
type: "bool-setting",
|
||||||
setting_type?: FactorioModSettingType,
|
setting_type?: FactorioModSettingType,
|
||||||
default_value: boolean,
|
default_value: boolean,
|
||||||
@@ -46,7 +46,7 @@ export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
|
|||||||
* Integer mod setting prototype.
|
* Integer mod setting prototype.
|
||||||
* @see [int-setting](https://wiki.factorio.com/Tutorial:Mod_settings#int-setting)
|
* @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",
|
type: "int-setting",
|
||||||
setting_type?: FactorioModSettingType,
|
setting_type?: FactorioModSettingType,
|
||||||
default_value: number,
|
default_value: number,
|
||||||
@@ -59,7 +59,7 @@ export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
|
|||||||
* Real number mod setting prototype.
|
* Real number mod setting prototype.
|
||||||
* @see [double-setting](https://wiki.factorio.com/Tutorial:Mod_settings#double-setting)
|
* @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",
|
type: "double-setting",
|
||||||
setting_type?: FactorioModSettingType,
|
setting_type?: FactorioModSettingType,
|
||||||
default_value: number,
|
default_value: number,
|
||||||
@@ -72,7 +72,7 @@ export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
|
|||||||
* String mod setting prototype.
|
* String mod setting prototype.
|
||||||
* @see [string-setting](https://wiki.factorio.com/Tutorial:Mod_settings#string-setting)
|
* @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",
|
type: "string-setting",
|
||||||
setting_type?: FactorioModSettingType,
|
setting_type?: FactorioModSettingType,
|
||||||
default_value: string,
|
default_value: string,
|
||||||
@@ -85,7 +85,7 @@ export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
|
|||||||
* Color mod setting prototype.
|
* Color mod setting prototype.
|
||||||
* @see [color-setting](https://wiki.factorio.com/Tutorial:Mod_settings#color-setting)
|
* @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",
|
type: "color-setting",
|
||||||
setting_type?: FactorioModSettingType,
|
setting_type?: FactorioModSettingType,
|
||||||
default_value: FactorioColor,
|
default_value: FactorioColor,
|
||||||
@@ -104,7 +104,7 @@ export type FactorioModSettingPrototype =
|
|||||||
* A list of prototypes as it appears in Factorio's Lua API.
|
* 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)
|
* @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 FactorioDefinesValue = number | string | { [index: string]: FactorioDefinesValue };
|
||||||
export type FactorioDefines = {
|
export type FactorioDefines = {
|
||||||
[index: string]: FactorioDefinesValue,
|
[index: string]: FactorioDefinesValue,
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@ export const ExternalFactorioVersionSchema = Type.Object({
|
|||||||
headlessUrl: Type.String(),
|
headlessUrl: Type.String(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
|
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all factorio versions that support headless
|
* Fetch all factorio versions that support headless
|
||||||
@@ -47,7 +47,7 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
|
|||||||
const versions = new Map<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>
|
// 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;
|
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+.\d+)"[^>]*>/g;
|
||||||
|
|
||||||
let match: RegExpExecArray | null;
|
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 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
|
* Fetch the latest stable and experimental factorio releases
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ function readMapPosition(state: MapReaderState) {
|
|||||||
y = state.last_position.y + y_diff;
|
y = state.last_position.y + y_diff;
|
||||||
}
|
}
|
||||||
state.last_position.x = x;
|
state.last_position.x = x;
|
||||||
state.last_position.x = y;
|
state.last_position.y = y;
|
||||||
return { x, 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
|
* Generate collision resistant temporary file name
|
||||||
*
|
|
||||||
* 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
|
|
||||||
*
|
*
|
||||||
* Warning: this function should not be relied upon to be accurate for
|
* Warning: this function should not be relied upon to be accurate for
|
||||||
* security sensitive applications. The selection process is inherently
|
* 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 = "./") {
|
export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
|
||||||
let fileName = path.join(prefix + crypto.randomBytes(16).toString("hex") + suffix);
|
let fileName = path.join(prefix + crypto.randomBytes(16).toString("hex") + suffix);
|
||||||
let freeFile = await findUnusedName(tmpdir, fileName);
|
let fullPath = path.join(tmpdir, fileName);
|
||||||
let fullPath = path.join(tmpdir, freeFile);
|
|
||||||
return fullPath;
|
return fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,57 @@ export function redactUrl(url: URL | string) {
|
|||||||
return isPath ? parsed.pathname + parsed.search + parsed.hash : parsed.toString();
|
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`
|
* 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 * as libErrors from "../errors.js";
|
||||||
import { logger } from "../logging.js";
|
import { logger } from "../logging.js";
|
||||||
import * as libSchema from "../schema.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 { BaseConnector, WebSocketBaseConnector } from "./connectors.js";
|
||||||
import { strict as assert } from "assert";
|
import { strict as assert } from "assert";
|
||||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin.js";
|
import type { PluginNodeEnvInfo, PluginWebEnvInfo } from "../plugin.js";
|
||||||
import type {
|
import type {
|
||||||
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IUser,
|
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IUser,
|
||||||
} from "../data/index.js";
|
} from "../data/index.js";
|
||||||
@@ -38,6 +38,12 @@ export interface Event<T> {
|
|||||||
}
|
}
|
||||||
export type EventClass<T> = Event<T>["constructor"];
|
export type EventClass<T> = Event<T>["constructor"];
|
||||||
|
|
||||||
|
export interface ErrorClass {
|
||||||
|
new (message: string): Error;
|
||||||
|
name: string;
|
||||||
|
plugin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RequestEntry {
|
export interface RequestEntry {
|
||||||
Request: RequestClass<unknown, unknown>;
|
Request: RequestClass<unknown, unknown>;
|
||||||
name: string,
|
name: string,
|
||||||
@@ -156,7 +162,7 @@ export class Link {
|
|||||||
return this._pendingRequests.size + this._forwardedRequests.size;
|
return this._pendingRequests.size + this._forwardedRequests.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
_clearPendingRequests(err: Error & { code?: string }) {
|
_clearPendingRequests(err: Error) {
|
||||||
for (let pending of this._pendingRequests.values()) {
|
for (let pending of this._pendingRequests.values()) {
|
||||||
pending.reject(err);
|
pending.reject(err);
|
||||||
}
|
}
|
||||||
@@ -170,9 +176,7 @@ export class Link {
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
pending.origin.connector.sendResponseError(
|
pending.origin.connector.sendResponseError(Link.errorToResponse(err, false), pending.src, pending.dst);
|
||||||
new libData.ResponseError(err.message, err.code), pending.src, pending.dst
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
this._pendingRequests.clear();
|
this._pendingRequests.clear();
|
||||||
this._forwardedRequests.clear();
|
this._forwardedRequests.clear();
|
||||||
@@ -200,10 +204,10 @@ export class Link {
|
|||||||
try {
|
try {
|
||||||
this.validatePermission(permissionMessage, entry); // Somewhat hacky, defined in ControlConnection
|
this.validatePermission(permissionMessage, entry); // Somewhat hacky, defined in ControlConnection
|
||||||
} catch (err) {
|
} 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
|
// broken. validatePermission (control connections only) already
|
||||||
// sent a response error, so log and drop instead of crashing.
|
// sent a response error, so log and drop instead of crashing.
|
||||||
if (!(err instanceof libErrors.PermissionError)) {
|
if (!(err instanceof libErrors.ClientError)) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Unexpected error checking permission for ${permissionMessage.name}:\n` +
|
`Unexpected error checking permission for ${permissionMessage.name}:\n` +
|
||||||
`${(err as Error).stack ?? (err as Error).message}`
|
`${(err as Error).stack ?? (err as Error).message}`
|
||||||
@@ -280,7 +284,7 @@ export class Link {
|
|||||||
this.validateIngress(message);
|
this.validateIngress(message);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (message.type === "request") {
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -290,17 +294,17 @@ export class Link {
|
|||||||
let entry = Link._requestsByName.get(message.name);
|
let entry = Link._requestsByName.get(message.name);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
let err = new libErrors.InvalidMessage(`Unrecognized request ${message.name}`);
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
if (!entry.allowedSrcTypes.has(message.src.type)) {
|
if (!entry.allowedSrcTypes.has(message.src.type)) {
|
||||||
let err = new libErrors.InvalidMessage(`Source ${message.src} is not allowed for ${message.name}`);
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
if (!entry.allowedDstTypes.has(message.dst.type)) {
|
if (!entry.allowedDstTypes.has(message.dst.type)) {
|
||||||
let err = new libErrors.InvalidMessage(`Destination ${message.dst} is not allowed for ${message.name}`);
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
@@ -356,7 +360,7 @@ export class Link {
|
|||||||
);
|
);
|
||||||
if (message.type === "request") {
|
if (message.type === "request") {
|
||||||
this.connector.sendResponseError(
|
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;
|
throw err;
|
||||||
@@ -399,13 +403,7 @@ export class Link {
|
|||||||
try {
|
try {
|
||||||
response = handler(entry.requestFromJSON(message.data), message.src, message.dst);
|
response = handler(entry.requestFromJSON(message.data), message.src, message.dst);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.errors) {
|
response = Promise.reject(err);
|
||||||
logger.error(JSON.stringify(err.errors, null, "\t"));
|
|
||||||
}
|
|
||||||
this.connector.sendResponseError(
|
|
||||||
new libData.ResponseError(err.message, err.code, err.stack), message.src
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(response instanceof Promise)) {
|
if (!(response instanceof Promise)) {
|
||||||
@@ -424,19 +422,17 @@ export class Link {
|
|||||||
this.connector.sendResponse(result, message.src, spoofedSrc);
|
this.connector.sendResponse(result, message.src, spoofedSrc);
|
||||||
}
|
}
|
||||||
).catch(
|
).catch(
|
||||||
(err: Error & { code?: string }) => {
|
(err: Error) => {
|
||||||
if (err instanceof libErrors.InvalidMessage) {
|
if (err instanceof libErrors.InvalidMessage) {
|
||||||
logger.error(err.message);
|
logger.error(err.message);
|
||||||
if (err.errors) {
|
if (err.errors) {
|
||||||
logger.error(JSON.stringify(err.errors, null, "\t"));
|
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}`);
|
logger.error(`Unexpected error responding to ${message.name}:\n${err.stack}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.connector.sendResponseError(
|
this.connector.sendResponseError(Link.errorToResponse(err), message.src, spoofedSrc);
|
||||||
new libData.ResponseError(err.message, err.code, err.stack), message.src, spoofedSrc
|
|
||||||
);
|
|
||||||
} catch (subErr: any) {
|
} catch (subErr: any) {
|
||||||
logger.error(`Unexpected error sending error response for ${message.name}:\n${subErr.stack}`);
|
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!);
|
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) {
|
_processEvent(message: libData.MessageEvent, entry: EventEntry) {
|
||||||
@@ -823,13 +819,83 @@ export class Link {
|
|||||||
this._eventsByName.set(name, entry);
|
this._eventsByName.set(name, entry);
|
||||||
this._eventsByClass.set(Event as EventClass<unknown>, 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) {
|
for (let Class of dataClasses) {
|
||||||
Link.register(Class as RequestClass<unknown, unknown>);
|
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 pluginInfo of pluginInfos) {
|
||||||
for (let Class of pluginInfo.messages || []) {
|
for (let Class of pluginInfo.messages || []) {
|
||||||
if (Class.plugin !== pluginInfo.name) {
|
if (Class.plugin !== pluginInfo.name) {
|
||||||
@@ -840,5 +906,14 @@ export function registerPluginMessages(pluginInfos: PluginNodeEnvInfo[]|PluginWe
|
|||||||
}
|
}
|
||||||
Link.register(Class);
|
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 note from "../data/messages_note.js";
|
||||||
import * as plugin from "../data/messages_plugin.js";
|
import * as plugin from "../data/messages_plugin.js";
|
||||||
import * as subscriptions from "../subscriptions.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
|
* 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.HostInfoUpdateEvent,
|
||||||
host.HostUpdatesEvent,
|
host.HostUpdatesEvent,
|
||||||
host.HostMetricsRequest,
|
host.HostMetricsRequest,
|
||||||
|
host.HostFactorioVersionsRequest,
|
||||||
host.ControllerConnectionEvent,
|
host.ControllerConnectionEvent,
|
||||||
host.PrepareControllerDisconnectRequest,
|
host.PrepareControllerDisconnectRequest,
|
||||||
host.SyncUserListsEvent,
|
host.SyncUserListsEvent,
|
||||||
@@ -147,5 +149,30 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
|||||||
plugin.PluginListRequest,
|
plugin.PluginListRequest,
|
||||||
plugin.PluginUpdateRequest,
|
plugin.PluginUpdateRequest,
|
||||||
plugin.PluginInstallRequest,
|
plugin.PluginInstallRequest,
|
||||||
|
plugin.PluginSearchRequest,
|
||||||
|
plugin.PluginLatestVersionsRequest,
|
||||||
plugin.UpdateAllRequest,
|
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 * as libFileOps from "./file_ops.js";
|
||||||
import vm from "vm";
|
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
|
* 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.
|
* 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);
|
const stats = await fs.stat(pluginPath);
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
// Check for package.json and plugin keyword
|
// Check for package.json and plugin keyword
|
||||||
if (await checkPackageJson(pluginPath)) {
|
if (isPluginPackageJson(await readPackageJson(pluginPath, false))) {
|
||||||
pluginList.set(pluginName, path.resolve(pluginPath));
|
pluginList.set(pluginName, path.resolve(pluginPath));
|
||||||
logger.info(`Added ${pluginName} from ${pluginFolder}`);
|
logger.info(`Added ${pluginName} from ${pluginFolder}`);
|
||||||
hasChanged += 1;
|
hasChanged += 1;
|
||||||
@@ -67,24 +157,85 @@ async function findLocalPlugins(pluginList: Map<string, string>): Promise<boolea
|
|||||||
return hasChanged > 0;
|
return hasChanged > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
type NpmPluginPackage = {
|
||||||
function getPluginName(requireSpec: string) {
|
packageName: string,
|
||||||
const context = vm.createContext({ require: require, pluginInfo: null });
|
packageVersion: unknown,
|
||||||
const code = `pluginInfo = require(${JSON.stringify(requireSpec)}).plugin;`;
|
packagePath: string,
|
||||||
vm.runInContext(code, context);
|
packageJson: any,
|
||||||
return context.pluginInfo.name;
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
/** Real paths of plugin packages some dependent installs from a path, git, url, tarball or alias */
|
||||||
const packageRequire = createRequire(path.join(fromPath, "package.json"));
|
export async function findNonRegistryDirs() {
|
||||||
try {
|
const dependencies = installedDependencies(await readPackageJson(process.cwd(), true));
|
||||||
return path.dirname(packageRequire.resolve(`${packageName}/package.json`));
|
return (await discoverNpmPluginPackages(dependencies, true)).nonRegistryDirs;
|
||||||
} catch (err: any) {
|
}
|
||||||
if (err.code === "MODULE_NOT_FOUND" || err.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
|
|
||||||
return undefined;
|
/** Whether npm install name would replace a plugin package not installed from the registry */
|
||||||
}
|
export async function isNonRegistryPackage(name: string) {
|
||||||
throw err;
|
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.
|
* Adds discovered plugins to the plugin list.
|
||||||
*
|
*
|
||||||
* @param pluginList - Map of plugin names to their file paths
|
* @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
|
* @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> {
|
async function findNpmPlugins(
|
||||||
let dependencies;
|
pluginList: Map<string, string>,
|
||||||
try {
|
skippedNpmPlugins?: Map<string, string>,
|
||||||
const rootPackageJson = JSON.parse(await fs.readFile(path.resolve("package.json"), { encoding: "utf8" }));
|
): Promise<boolean> {
|
||||||
dependencies = rootPackageJson.dependencies;
|
const rootPackageJson = await readPackageJson(process.cwd(), false);
|
||||||
} catch (err: any) {
|
if (!rootPackageJson) {
|
||||||
if (err.code === "ENOENT") {
|
|
||||||
// Skip if package.json doesn't exist
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dependencies) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const dependencies = installedDependencies(rootPackageJson);
|
||||||
|
|
||||||
let hasChanged = 0;
|
let hasChanged = 0;
|
||||||
const pendingPackages = Object.entries(dependencies).map(([packageName, packageVersion]) => ({
|
const { plugins, nonPluginRoots } = await discoverNpmPluginPackages(dependencies);
|
||||||
packageName,
|
for (const { packageName, packageVersion, packagePath } of plugins) {
|
||||||
packageVersion,
|
const pluginName = getPluginName(packagePath);
|
||||||
parentPath: process.cwd(),
|
const listedPath = pluginList.get(pluginName);
|
||||||
isRootDependency: true,
|
if (listedPath === packageName) {
|
||||||
}));
|
|
||||||
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)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (listedPath !== undefined && !await pluginPathExists(listedPath)) {
|
||||||
const npmPackagePath = resolvePackagePath(packageName, parentPath);
|
logger.warn(`${pluginName} at ${listedPath} no longer exists, using ${packageName}`);
|
||||||
if (!npmPackagePath) {
|
pluginList.set(pluginName, packageName);
|
||||||
continue;
|
hasChanged += 1;
|
||||||
}
|
} else if (pluginList.has(pluginName)) {
|
||||||
|
logger.warn(
|
||||||
visitedPackages.add(packageName);
|
`${pluginName} provided by ${packageName}@${packageVersion} is already in the plugin list, ` +
|
||||||
|
`but with a different path - using ${pluginList.get(pluginName)}`
|
||||||
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" })
|
|
||||||
);
|
);
|
||||||
for (const [dependencyName, dependencyVersion] of Object.entries(packageJson.dependencies ?? {})) {
|
skippedNpmPlugins?.set(pluginName, await realDir(packagePath));
|
||||||
pendingPackages.push({
|
} else {
|
||||||
packageName: dependencyName,
|
pluginList.set(pluginName, packageName);
|
||||||
packageVersion: dependencyVersion,
|
logger.info(`Added ${pluginName} from node modules`);
|
||||||
parentPath: npmPackagePath,
|
hasChanged += 1;
|
||||||
isRootDependency: false,
|
}
|
||||||
});
|
}
|
||||||
}
|
for (const { packageName, packageVersion } of nonPluginRoots) {
|
||||||
} else if (isRootDependency && !corePackages.has(packageName)) {
|
if (!corePackages.has(packageName)) {
|
||||||
logger.warn(`${packageName}@${packageVersion} is not a clusterio-plugin`);
|
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 pluginListPath - Path to the plugin list JSON file
|
||||||
* @param findLocal - Whether to look for local plugins in plugins/ and external_plugins/ directories
|
* @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
|
* @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>();
|
let pluginList = new Map<string, string>();
|
||||||
|
|
||||||
// Try to load existing plugin list
|
// Try to load existing plugin list
|
||||||
@@ -201,7 +325,7 @@ export async function loadPluginList(pluginListPath: string, findLocal = true):
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Discover NPM plugins
|
// Discover NPM plugins
|
||||||
hasChanges = await findNpmPlugins(pluginList) || hasChanges;
|
hasChanges = await findNpmPlugins(pluginList, skippedNpmPlugins) || hasChanges;
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
|
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
|
||||||
@@ -209,24 +333,3 @@ export async function loadPluginList(pluginListPath: string, findLocal = true):
|
|||||||
|
|
||||||
return pluginList;
|
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
|
// 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?
|
// into the type when you can define custom levels you want to support?
|
||||||
export type Logger = Omit<winston.Logger,
|
export type Logger = Omit<winston.Logger,
|
||||||
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "verbose" | "input" | "silly" |
|
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "verbose" | "input" | "silly"
|
||||||
"emerg" | "alert" | "crit" | "warning" | "notice" |
|
| "emerg" | "alert" | "crit" | "warning" | "notice"
|
||||||
"child"
|
| "child"
|
||||||
> & {
|
> & {
|
||||||
fatal: winston.LeveledLogMethod,
|
fatal: winston.LeveledLogMethod,
|
||||||
error: 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
|
* @author Hornwitser
|
||||||
* @module lib/users
|
* @module lib/users
|
||||||
*/
|
*/
|
||||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "./plugin.js";
|
import type { PluginNodeEnvInfo, PluginWebEnvInfo } from "./plugin.js";
|
||||||
import { Permission } from "./data/index.js";
|
import { Permission } from "./data/index.js";
|
||||||
|
|
||||||
export const permissions = new Map<string, Permission>();
|
export const permissions = new Map<string, Permission>();
|
||||||
@@ -88,7 +88,7 @@ export function definePermission({
|
|||||||
*
|
*
|
||||||
* @param pluginInfos - Array of plugin info objects.
|
* @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 pluginInfo of pluginInfos) {
|
||||||
for (const permission of pluginInfo.permissions ?? []) {
|
for (const permission of pluginInfo.permissions ?? []) {
|
||||||
if (typeof permission.name !== "string" || !permission.name.startsWith(`${pluginInfo.name}.`)) {
|
if (typeof permission.name !== "string" || !permission.name.startsWith(`${pluginInfo.name}.`)) {
|
||||||
@@ -583,6 +583,11 @@ export const corePermissions = [
|
|||||||
title: "Install plugin",
|
title: "Install plugin",
|
||||||
description: "Remotely install a plugin if the target allows remote installs of plugins.",
|
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",
|
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 { PermissionDefinition } from "./permissions.js";
|
||||||
import type { PlayerStats, Role } from "./data/index.js";
|
import type { PlayerStats, Role } from "./data/index.js";
|
||||||
import type { SyncHook } from "./SyncHook.js";
|
import type { SyncHook } from "./SyncHook.js";
|
||||||
|
import type { ErrorClass } from "./link/index.js";
|
||||||
|
|
||||||
export const PluginFeatureFlags = [
|
export const PluginFeatureFlags = [
|
||||||
/** The plugin requires module code to be patched into the save */
|
/** The plugin requires module code to be patched into the save */
|
||||||
@@ -42,9 +43,10 @@ export type PluginDeclaration = {
|
|||||||
features?: (typeof PluginFeatureFlags)[number][];
|
features?: (typeof PluginFeatureFlags)[number][];
|
||||||
|
|
||||||
messages?: any[];
|
messages?: any[];
|
||||||
|
errors?: ErrorClass[];
|
||||||
permissions?: PermissionDefinition[];
|
permissions?: PermissionDefinition[];
|
||||||
routes?: string[];
|
routes?: string[];
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a plugin is expected to ship a web build.
|
* Check if a plugin is expected to ship a web build.
|
||||||
@@ -82,9 +84,14 @@ export type PluginNodeEnvInfo = PluginDeclaration & {
|
|||||||
* package.
|
* package.
|
||||||
*/
|
*/
|
||||||
npmPackage?: string;
|
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;
|
container?: any;
|
||||||
package?: any;
|
package?: any;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { loadPluginEntrypoint, loadPluginClass } from "./loadPlugin.js";
|
|||||||
import { Config } from "./config/classes.js";
|
import { Config } from "./config/classes.js";
|
||||||
import { ControllerConfig, HostConfig, InstanceConfig } from "./config/definitions.js";
|
import { ControllerConfig, HostConfig, InstanceConfig } from "./config/definitions.js";
|
||||||
import Role from "./data/Role.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 -
|
* @param pluginList -
|
||||||
* Mapping of plugin name to require path for the plugins to load.
|
* 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.
|
* @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[] = [];
|
let plugins: libPlugin.PluginNodeEnvInfo[] = [];
|
||||||
|
const nonRegistryDirs = await findNonRegistryDirs();
|
||||||
for (let [pluginName, pluginPath] of pluginList) {
|
for (let [pluginName, pluginPath] of pluginList) {
|
||||||
let pluginInfo: libPlugin.PluginNodeEnvInfo;
|
let pluginInfo: libPlugin.PluginNodeEnvInfo;
|
||||||
let pluginPackage: { name?: string, version: string, main?: string, private?: boolean };
|
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.packagePath = packagePath;
|
||||||
pluginInfo.requirePath = pluginPath;
|
pluginInfo.requirePath = pluginPath;
|
||||||
pluginInfo.version = pluginPackage.version;
|
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);
|
plugins.push(pluginInfo);
|
||||||
}
|
}
|
||||||
return plugins;
|
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
|
* Load the migration entrypoints of plugins
|
||||||
*
|
*
|
||||||
|
|||||||
+80
-25
@@ -2,8 +2,11 @@ import util from "util";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { logger } from "./logging.js";
|
import { logger } from "./logging.js";
|
||||||
import { RequestError } from "./errors.js";
|
import { ConflictError, NotFoundError, UnavailableError } from "./errors.js";
|
||||||
import type { PluginNodeEnvInfo } from "./plugin.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);
|
const execAsync = util.promisify(exec);
|
||||||
|
|
||||||
function isDev() {
|
function isDev() {
|
||||||
@@ -12,10 +15,55 @@ function isDev() {
|
|||||||
return import.meta.dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio"
|
return import.meta.dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio"
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logExec(cmd: string) {
|
const operationInProgressMessage = "Another package operation is already in progress on this machine";
|
||||||
logger.audit(`RCE | ${cmd}`);
|
|
||||||
if (!isDev()) {
|
let operationInProgress = false;
|
||||||
await execAsync(cmd);
|
|
||||||
|
/**
|
||||||
|
* 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(" ")}`);
|
return logExec(`npm update --save ${names.join(" ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installPackage(name: string) {
|
export async function installPackage(name: string, check?: () => Promise<void>) {
|
||||||
return logExec(`npm install --save ${name}`);
|
return logExec(`npm install --save ${name}`, check);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) {
|
/** Whether an npm operation is running in this installation, from this or another process */
|
||||||
if (!pluginInfos.some(plugin => plugin.npmPackage === pluginName)) {
|
export async function packageOperationRunning() {
|
||||||
throw new RequestError(`Plugin ${pluginName} is not installed on this machine`);
|
return operationInProgress || !await new LockFile(path.resolve("npm-operation.lock")).isStale();
|
||||||
}
|
|
||||||
|
|
||||||
return await updatePackage(pluginName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNodeEnvInfo[]) {
|
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);
|
return await updatePackage(corePackage, ...pluginPackages);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePluginInstall(pluginName: string) {
|
/** Throws if the dependency by this name is a path, git, url or alias that installing by name would replace */
|
||||||
if (pluginName.length > 214 || /[^a-zA-Z0-9\-_.+@\/]/.test(pluginName)) {
|
async function checkNotLocalDependency(name: string) {
|
||||||
// https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name
|
if (await isNonRegistryPackage(name)) {
|
||||||
// https://www.npmjs.com/package/validate-npm-package-name
|
throw new ConflictError(`${name} is not installed from npm on this machine`);
|
||||||
throw new RequestError(`Invalid plugin name: ${pluginName}`);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const packageName = encodeURI(pluginName);
|
export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) {
|
||||||
const npmRequest = await fetch(`https://registry.npmjs.com/${packageName}`, {
|
// Optionally pinned to a version as name@version to update past the range in package.json
|
||||||
method: "HEAD",
|
const { name, version } = parsePackageSpec(pluginName);
|
||||||
});
|
if (!pluginInfos.some(plugin => plugin.npmPackage === name)) {
|
||||||
if (!npmRequest.ok) {
|
throw new NotFoundError(`Plugin ${name} is not installed on this machine`);
|
||||||
throw new RequestError(`Unknown plugin: ${packageName}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { loadPluginList } from "./load_plugin_list.js";
|
||||||
import * as libConfig from "./config/index.js";
|
import * as libConfig from "./config/index.js";
|
||||||
import * as libFileOps from "./file_ops.js";
|
import * as libFileOps from "./file_ops.js";
|
||||||
|
import { InvalidFieldError, InvalidValueError } from "./errors.js";
|
||||||
import { logger } from "./logging.js";
|
import { logger } from "./logging.js";
|
||||||
import * as libHelpers from "./helpers.js";
|
import * as libHelpers from "./helpers.js";
|
||||||
import { LockFile } from "./LockFile.js";
|
import { LockFile } from "./LockFile.js";
|
||||||
@@ -174,7 +175,7 @@ export async function handlePluginCommand(
|
|||||||
try {
|
try {
|
||||||
const pluginPackage = (await import(
|
const pluginPackage = (await import(
|
||||||
pathToFileURL(path.posix.join(pluginPath, "package.json")).href,
|
pathToFileURL(path.posix.join(pluginPath, "package.json")).href,
|
||||||
{ with: { type: "json" }},
|
{ with: { type: "json" }}
|
||||||
)).default;
|
)).default;
|
||||||
pluginInfo = (await import(
|
pluginInfo = (await import(
|
||||||
pathToFileURL(path.posix.join(pluginPath, pluginPackage.main ?? "index.js")).href
|
pathToFileURL(path.posix.join(pluginPath, pluginPackage.main ?? "index.js")).href
|
||||||
@@ -271,7 +272,7 @@ export async function handleConfigCommand(
|
|||||||
try {
|
try {
|
||||||
print(instance.get(args.field as string));
|
print(instance.get(args.field as string));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof libConfig.InvalidField) {
|
if (err instanceof InvalidFieldError) {
|
||||||
logger.error(err.message);
|
logger.error(err.message);
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -292,7 +293,7 @@ export async function handleConfigCommand(
|
|||||||
instance.set(args.field as string, args.value);
|
instance.set(args.field as string, args.value);
|
||||||
await instance.save();
|
await instance.save();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) {
|
if (err instanceof InvalidFieldError || err instanceof InvalidValueError) {
|
||||||
logger.error(err.message);
|
logger.error(err.message);
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from
|
|||||||
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum } from "./data/index.js";
|
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum } from "./data/index.js";
|
||||||
import isDeepStrictEqual from "#is_deep_strict_equal";
|
import isDeepStrictEqual from "#is_deep_strict_equal";
|
||||||
import type { PermissionName } from "./permissions.js";
|
import type { PermissionName } from "./permissions.js";
|
||||||
import { RequestError } from "./errors.js";
|
import { SessionLost, ValidationError } from "./errors.js";
|
||||||
import { logger } from "./logging.js";
|
import { logger } from "./logging.js";
|
||||||
|
|
||||||
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
|
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.
|
* Represents a set of string subscription filters.
|
||||||
@@ -241,7 +241,7 @@ type Subscriber = {
|
|||||||
link: Link,
|
link: Link,
|
||||||
address: Address,
|
address: Address,
|
||||||
filters: SubscriptionFilters,
|
filters: SubscriptionFilters,
|
||||||
}
|
};
|
||||||
|
|
||||||
type EventData = {
|
type EventData = {
|
||||||
subscriptionUpdate?: SubscriptionRequestHandler<unknown>,
|
subscriptionUpdate?: SubscriptionRequestHandler<unknown>,
|
||||||
@@ -351,7 +351,7 @@ export class SubscriptionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (count > this._maxFilters) {
|
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);
|
this._notify(null);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} 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}`);
|
logger.error(`Unexpected error updating ${entry.name} subscription:\n${err.stack}`);
|
||||||
}
|
}
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import os from "os";
|
import os from "os";
|
||||||
import util from "util";
|
import util from "util";
|
||||||
import { Gauge } from "./prometheus.js";
|
import { Gauge } from "./prometheus.js";
|
||||||
import { SystemInfo } from "./data/index.js";
|
import { PluginDetails, SystemInfo } from "./data/index.js";
|
||||||
|
|
||||||
function filterCpuModel() {
|
function filterCpuModel() {
|
||||||
const model = os.cpus()[0].model;
|
const model = os.cpus()[0].model;
|
||||||
@@ -125,7 +125,8 @@ export async function gatherSystemInfo(
|
|||||||
id: SystemInfo["id"],
|
id: SystemInfo["id"],
|
||||||
canRestart: boolean,
|
canRestart: boolean,
|
||||||
restartRequired: boolean,
|
restartRequired: boolean,
|
||||||
startedAtMs: number
|
startedAtMs: number,
|
||||||
|
plugins?: PluginDetails[],
|
||||||
) {
|
) {
|
||||||
const cpus = os.cpus();
|
const cpus = os.cpus();
|
||||||
const currentTotalCpuMs = cpus.map(({ times }) => times.user + times.idle + times.irq + times.nice + times.sys);
|
const currentTotalCpuMs = cpus.map(({ times }) => times.user + times.idle + times.irq + times.nice + times.sys);
|
||||||
@@ -160,5 +161,6 @@ export async function gatherSystemInfo(
|
|||||||
startedAtMs,
|
startedAtMs,
|
||||||
now,
|
now,
|
||||||
false,
|
false,
|
||||||
|
plugins,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"./src/config/*.ts",
|
"./src/config/*.ts",
|
||||||
"./src/data/*.ts",
|
"./src/data/*.ts",
|
||||||
"./src/external/*.ts",
|
"./src/external/*.ts",
|
||||||
|
"./src/browser_polyfills.ts",
|
||||||
"./src/check_singleton_import.ts",
|
"./src/check_singleton_import.ts",
|
||||||
"./src/datastore.ts",
|
"./src/datastore.ts",
|
||||||
"./src/errors.ts",
|
"./src/errors.ts",
|
||||||
|
|||||||
Vendored
-3
@@ -22,6 +22,3 @@ declare module "*.md" {
|
|||||||
declare const webRoot: string;
|
declare const webRoot: string;
|
||||||
/** Path static assets are served under, ends with a slash. */
|
/** Path static assets are served under, ends with a slash. */
|
||||||
declare const staticRoot: string;
|
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": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.6.1",
|
"@ant-design/icons": "catalog:",
|
||||||
"@clusterio/lib": "workspace:^",
|
"@clusterio/lib": "workspace:^",
|
||||||
"@sinclair/typebox": "catalog:",
|
"@sinclair/typebox": "catalog:",
|
||||||
"@swc/core": "catalog:",
|
"@vitejs/plugin-react": "catalog:",
|
||||||
"antd": "catalog:",
|
"antd": "catalog:",
|
||||||
"assert": "^2.1.0",
|
"assert": "^2.1.0",
|
||||||
"browserify-zlib": "^0.2.0",
|
"browserify-zlib": "^0.2.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"clean-webpack-plugin": "^4.0.0",
|
|
||||||
"css-loader": "^7.1.5",
|
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"file-loader": "^6.2.0",
|
|
||||||
"null-loader": "^4.0.1",
|
|
||||||
"os-browserify": "^0.3.0",
|
"os-browserify": "^0.3.0",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
@@ -53,20 +49,18 @@
|
|||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-markdown": "catalog:",
|
"react-markdown": "catalog:",
|
||||||
|
"react-router": "catalog:",
|
||||||
"react-router-dom": "catalog:",
|
"react-router-dom": "catalog:",
|
||||||
|
"semver": "catalog:",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"style-loader": "^4.0.0",
|
|
||||||
"swc-loader": "^0.2.7",
|
|
||||||
"terser-webpack-plugin": "^5.6.1",
|
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"util": "^0.12.5",
|
"util": "^0.12.5",
|
||||||
"webpack": "catalog:",
|
"vite": "catalog:"
|
||||||
"webpack-cli": "catalog:",
|
|
||||||
"webpack-manifest-plugin": "^6.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "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;
|
control: Control;
|
||||||
container: any;
|
container: any;
|
||||||
package: any;
|
package: any;
|
||||||
}, lib.PluginWebpackEnvInfo>;
|
}, lib.PluginWebEnvInfo>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collection of web plugin hooks
|
* Collection of web plugin hooks
|
||||||
@@ -258,7 +258,7 @@ export class BaseWebPlugin {
|
|||||||
constructor(
|
constructor(
|
||||||
public container: any,
|
public container: any,
|
||||||
packageData: any,
|
packageData: any,
|
||||||
public info: lib.PluginWebpackEnvInfo,
|
public info: lib.PluginWebEnvInfo,
|
||||||
public control: Control,
|
public control: Control,
|
||||||
public logger: lib.Logger,
|
public logger: lib.Logger,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -16,22 +16,7 @@ import { pages } from "./pages";
|
|||||||
|
|
||||||
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
|
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
|
||||||
|
|
||||||
async function loadScript(url: string) {
|
async function loadPluginInfos(): Promise<[lib.PluginWebEnvInfo[], 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]> {
|
|
||||||
let response = await fetch(`${webRoot}api/plugins`);
|
let response = await fetch(`${webRoot}api/plugins`);
|
||||||
let pluginList: lib.PluginWebApi[];
|
let pluginList: lib.PluginWebApi[];
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -43,8 +28,7 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
|
|||||||
}
|
}
|
||||||
let pluginSetKey = loadedPluginSetKey(pluginList);
|
let pluginSetKey = loadedPluginSetKey(pluginList);
|
||||||
|
|
||||||
let pluginInfos: lib.PluginWebpackEnvInfo[] = [];
|
let pluginInfos: lib.PluginWebEnvInfo[] = [];
|
||||||
await __webpack_init_sharing__("default");
|
|
||||||
for (let meta of pluginList) {
|
for (let meta of pluginList) {
|
||||||
if (meta.web.error) {
|
if (meta.web.error) {
|
||||||
logger.error(`Failed to load plugin ${meta.name}: ${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;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await loadScript(`${staticRoot}${meta.web.main}`);
|
let container = await import(/* @vite-ignore */ `${staticRoot}${meta.web.main}`);
|
||||||
let container: any = (window as { [key: string]: any })[`plugin_${meta.name}`];
|
if (typeof container.get !== "function") {
|
||||||
if (!container) {
|
throw new Error("Plugin web bundle does not export get");
|
||||||
throw new Error(`Plugin did not expose its container via plugin_${meta.name}`);
|
|
||||||
}
|
}
|
||||||
await container.init(__webpack_share_scopes__.default);
|
let pluginInfo = (await container.get("./")).plugin;
|
||||||
let pluginInfo = (await container.get("./"))().plugin;
|
|
||||||
pluginInfo.container = container;
|
pluginInfo.container = container;
|
||||||
pluginInfo.package = (await container.get("./package.json"))();
|
pluginInfo.package = (await container.get("./package.json")).default;
|
||||||
pluginInfo.enabled = meta.enabled;
|
pluginInfo.enabled = meta.enabled;
|
||||||
pluginInfos.push(pluginInfo);
|
pluginInfos.push(pluginInfo);
|
||||||
|
|
||||||
@@ -83,7 +65,7 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPlugins(
|
async function loadPlugins(
|
||||||
pluginInfos: lib.PluginWebpackEnvInfo[],
|
pluginInfos: lib.PluginWebEnvInfo[],
|
||||||
control: Control
|
control: Control
|
||||||
) {
|
) {
|
||||||
const plugins = new Map<string, WebPlugin.BaseWebPlugin>();
|
const plugins = new Map<string, WebPlugin.BaseWebPlugin>();
|
||||||
@@ -94,8 +76,7 @@ async function loadPlugins(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moduleFactory = await pluginInfo.container.get(pluginInfo.webEntrypoint);
|
const webModule = await pluginInfo.container.get(pluginInfo.webEntrypoint);
|
||||||
const webModule = moduleFactory();
|
|
||||||
|
|
||||||
const pluginContext: WebPlugin.WebPluginContext = {
|
const pluginContext: WebPlugin.WebPluginContext = {
|
||||||
control,
|
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");
|
let [clipboardPermision, setClipboardPermission] = useState<PermissionState>("granted");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ type DownloadDependenciesProps = {
|
|||||||
mods: lib.ModRecord[];
|
mods: lib.ModRecord[];
|
||||||
onChange: (change: ModChange) => void;
|
onChange: (change: ModChange) => void;
|
||||||
builtInModNames: string[];
|
builtInModNames: string[];
|
||||||
}
|
};
|
||||||
function DownloadDependenciesButton(props: DownloadDependenciesProps) {
|
function DownloadDependenciesButton(props: DownloadDependenciesProps) {
|
||||||
const control = useContext(ControlContext);
|
const control = useContext(ControlContext);
|
||||||
const [modInfos] = useMods();
|
const [modInfos] = useMods();
|
||||||
@@ -904,7 +904,7 @@ type SettingsTableFieldProps = {
|
|||||||
allowedValues: any;
|
allowedValues: any;
|
||||||
locale: Map<string, any>;
|
locale: Map<string, any>;
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
}
|
};
|
||||||
const SettingsTableField = memo((props: SettingsTableFieldProps) => {
|
const SettingsTableField = memo((props: SettingsTableFieldProps) => {
|
||||||
const name = props.name;
|
const name = props.name;
|
||||||
const isBoolean = props.type === "bool-setting";
|
const isBoolean = props.type === "bool-setting";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
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";
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
@@ -7,92 +7,7 @@ import { useAccount } from "../model/account";
|
|||||||
import { notifyErrorHandler } from "../util/notify";
|
import { notifyErrorHandler } from "../util/notify";
|
||||||
import ControlContext from "./ControlContext";
|
import ControlContext from "./ControlContext";
|
||||||
|
|
||||||
type NpmActionProps = {
|
/** Updates Clusterio on a machine, plugins are installed and updated from the plugins page */
|
||||||
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>
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NpmButton(props: {
|
export function NpmButton(props: {
|
||||||
target: "controller" | { hostId: number },
|
target: "controller" | { hostId: number },
|
||||||
canRestart?: boolean,
|
canRestart?: boolean,
|
||||||
@@ -101,138 +16,72 @@ export function NpmButton(props: {
|
|||||||
const control = useContext(ControlContext);
|
const control = useContext(ControlContext);
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [formAction, setFormAction] = useState<string | undefined>(undefined);
|
const [restart, setRestart] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [allowed, setAllowed] = useState(true);
|
||||||
|
|
||||||
const [allows, setAllows] = useState({
|
|
||||||
remoteUpdates: true, pluginUpdates: true, pluginInstall: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const isController = props.target === "controller";
|
||||||
const hostId = props.target === "controller" ? undefined : props.target.hostId;
|
const hostId = props.target === "controller" ? undefined : props.target.hostId;
|
||||||
useEffect(() => {
|
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 (props.target === "controller") {
|
||||||
if (!account.hasPermission("core.controller.get_config")) {
|
control.send(new lib.ControllerConfigGetRequest()).then(serializedConfig => {
|
||||||
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) => {
|
|
||||||
const config = lib.ControllerConfig.fromJSON(serializedConfig, "control");
|
const config = lib.ControllerConfig.fromJSON(serializedConfig, "control");
|
||||||
setAllows({
|
setAllowed(config.get("controller.allow_remote_updates"));
|
||||||
remoteUpdates: config.get("controller.allow_remote_updates"),
|
|
||||||
pluginUpdates: config.get("controller.allow_plugin_updates"),
|
|
||||||
pluginInstall: config.get("controller.allow_plugin_install"),
|
|
||||||
});
|
|
||||||
}).catch(notifyErrorHandler("Failed to fetch controller config for remote updates"));
|
}).catch(notifyErrorHandler("Failed to fetch controller config for remote updates"));
|
||||||
} else {
|
} else {
|
||||||
if (!account.hasPermission("core.host.get_config")) {
|
control.sendTo(props.target, new lib.HostConfigGetRequest()).then(serializedConfig => {
|
||||||
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) => {
|
|
||||||
const config = lib.HostConfig.fromJSON(serializedConfig, "control");
|
const config = lib.HostConfig.fromJSON(serializedConfig, "control");
|
||||||
setAllows({
|
setAllowed(config.get("host.allow_remote_updates"));
|
||||||
remoteUpdates: config.get("host.allow_remote_updates"),
|
|
||||||
pluginUpdates: config.get("host.allow_plugin_updates"),
|
|
||||||
pluginInstall: config.get("host.allow_plugin_install"),
|
|
||||||
});
|
|
||||||
}).catch(notifyErrorHandler("Failed to fetch host config for remote updates"));
|
}).catch(notifyErrorHandler("Failed to fetch host config for remote updates"));
|
||||||
}
|
}
|
||||||
}, [hostId]);
|
}, [hostId]);
|
||||||
|
|
||||||
const coreUpdatePermission = `core.${props.target === "controller" ? "controller" : "host"}.update` as const;
|
const canRestart = account.hasPermission(isController ? "core.controller.restart" : "core.host.restart");
|
||||||
|
|
||||||
function onValuesChange({ action } : { action?: string }) {
|
|
||||||
if (action) {
|
|
||||||
setFormAction(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let applyAction: () => Promise<void>;
|
|
||||||
const setApplyAction = (func: () => Promise<void>) => { applyAction = func; };
|
|
||||||
async function onOk() {
|
async function onOk() {
|
||||||
if (!applyAction) {
|
if (props.target === "controller") {
|
||||||
form.setFields([{ name: "action", errors: ["Action is required"] }]);
|
await control.send(new lib.ControllerUpdateRequest());
|
||||||
return;
|
} else {
|
||||||
|
await control.sendTo(props.target, new lib.HostUpdateRequest());
|
||||||
}
|
}
|
||||||
await applyAction();
|
if (restart) {
|
||||||
|
|
||||||
const values = form.getFieldsValue();
|
|
||||||
if (values.restart) {
|
|
||||||
// Ternary not used because TS disliked it
|
|
||||||
if (props.target === "controller") {
|
if (props.target === "controller") {
|
||||||
await control.sendTo(props.target, new lib.ControllerRestartRequest());
|
await control.send(new lib.ControllerRestartRequest());
|
||||||
} else {
|
} else {
|
||||||
await control.sendTo(props.target, new lib.HostRestartRequest());
|
await control.sendTo(props.target, new lib.HostRestartRequest());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormAction(undefined);
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Button
|
<Tooltip title={allowed ? undefined : "Remote updates are disabled on this machine"}>
|
||||||
type="default"
|
<Button disabled={props.disabled || !allowed} onClick={() => { setOpen(true); }}>
|
||||||
disabled={props.disabled}
|
Update Clusterio
|
||||||
onClick={() => { setOpen(true); }}
|
</Button>
|
||||||
>Updates</Button>
|
</Tooltip>
|
||||||
<Modal
|
<Modal
|
||||||
title="Updates"
|
title="Update Clusterio"
|
||||||
okText="Apply"
|
okText="Update"
|
||||||
open={open}
|
open={open}
|
||||||
okButtonProps={{disabled: formAction === undefined}}
|
onOk={() => { onOk().catch(notifyErrorHandler("Error updating Clusterio")); }}
|
||||||
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
|
|
||||||
onCancel={() => { setOpen(false); }}
|
onCancel={() => { setOpen(false); }}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
|
<p>Updates Clusterio within the version range in the machine's package.json.</p>
|
||||||
<Form.Item label="Action" name="action">
|
{canRestart && <Checkbox
|
||||||
<Radio.Group value={formAction}>
|
checked={restart}
|
||||||
{account.hasPermission(coreUpdatePermission)
|
disabled={props.canRestart === false}
|
||||||
? <Radio.Button value="remote_update" disabled={!allows.remoteUpdates}>
|
onChange={event => { setRestart(event.target.checked); }}
|
||||||
Update Clusterio
|
>Restart on completion</Checkbox>}
|
||||||
</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>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasNpmButtonPermission(controller: boolean) {
|
export function hasNpmButtonPermission(controller: boolean) {
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
return account.hasAnyPermission(
|
return account.hasPermission(controller ? "core.controller.update" : "core.host.update");
|
||||||
controller ? "core.controller.update" : "core.host.update",
|
|
||||||
"core.plugin.update", "core.plugin.install"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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