mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 23:04:52 +09:00
Compare commits
184
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 | ||
|
|
7fa511873b | ||
|
|
b474573633 | ||
|
|
ba518faa05 | ||
|
|
abb04f3258 | ||
|
|
3a81cfd94b | ||
|
|
ee097bfa6f | ||
|
|
d7fc3aaec4 | ||
|
|
5355a0170a | ||
|
|
e16d0bc410 | ||
|
|
a6d2d48e48 | ||
|
|
c3abf27266 | ||
|
|
5d7ecff418 | ||
|
|
046f0af705 | ||
|
|
5b217c38b6 | ||
|
|
0f201961a2 | ||
|
|
cb450690ba | ||
|
|
0149c72639 | ||
|
|
3f79cdb1a6 | ||
|
|
9c4e354738 | ||
|
|
0bd7fccdf3 | ||
|
|
fdec6bc249 | ||
|
|
17f029e39e | ||
|
|
5e4dcada50 | ||
|
|
ca7caedfbe | ||
|
|
fe416da26c | ||
|
|
18afae4686 | ||
|
|
240180d847 | ||
|
|
b8c1e89dff | ||
|
|
b10807b6d6 | ||
|
|
4128f75328 | ||
|
|
ec7b5f2db8 | ||
|
|
37d910fe86 | ||
|
|
360e12a560 | ||
|
|
f44e36f2cf | ||
|
|
39df22be65 | ||
|
|
fd39e2a171 | ||
|
|
079740d991 | ||
|
|
6126b30350 | ||
|
|
2035f5bc2c | ||
|
|
bedfe0873b | ||
|
|
9ecd497f1e | ||
|
|
feb3253bec | ||
|
|
4fd1d56b4a | ||
|
|
0fb9e793d7 | ||
|
|
cbf2b10782 | ||
|
|
ee39945c83 | ||
|
|
bf73afeab2 | ||
|
|
f7c78d9f74 | ||
|
|
ed1e4ee3cb | ||
|
|
916b57b37c | ||
|
|
4228b40562 | ||
|
|
b32f5416a1 | ||
|
|
c70be25dc9 | ||
|
|
eb869dac5b | ||
|
|
1f6e0519bd | ||
|
|
7175571d5b | ||
|
|
667f1e3c0c | ||
|
|
ead8ede42a | ||
|
|
e3099e1e05 | ||
|
|
41dc294542 | ||
|
|
5ca10d579d | ||
|
|
c06555c5da | ||
|
|
43243eab86 | ||
|
|
ba800e853d | ||
|
|
857102956a | ||
|
|
357ad19e05 | ||
|
|
c746b74bae | ||
|
|
5634651de1 | ||
|
|
2529683c2b | ||
|
|
c260baaf03 | ||
|
|
1ad5be0ff6 | ||
|
|
f12b016cec | ||
|
|
1d14148606 | ||
|
|
004fb13f1c | ||
|
|
f67097257b | ||
|
|
9cc5c64852 | ||
|
|
a81d98e981 | ||
|
|
5e2ecdd143 | ||
|
|
c28e617b22 | ||
|
|
0504fe15ed | ||
|
|
ad779a8799 | ||
|
|
a1a57b8c97 | ||
|
|
923ba10d2a | ||
|
|
905560d17b | ||
|
|
93ead85e5d | ||
|
|
45fedf28a0 | ||
|
|
aa06b9afe3 | ||
|
|
f3d67ed7d9 | ||
|
|
5adbb0283b | ||
|
|
b0e07ea73b | ||
|
|
8487c6d923 | ||
|
|
c798b2a33f | ||
|
|
0e559cf000 | ||
|
|
e810020db5 | ||
|
|
0a6f614074 | ||
|
|
25c3c9991e | ||
|
|
c4f48b6c12 | ||
|
|
d5d4eb7fef | ||
|
|
e3a684ff3b | ||
|
|
a75354f9bb | ||
|
|
ba74ab048b | ||
|
|
30b00b4715 | ||
|
|
580c8ba493 | ||
|
|
fb53845f2a | ||
|
|
8bfc42c44c | ||
|
|
1f37905be0 | ||
|
|
7a64706c82 | ||
|
|
4b0238b64b | ||
|
|
788ae17657 | ||
|
|
ec51adc71c | ||
|
|
7467fa6b8f | ||
|
|
5ca61414b3 | ||
|
|
40249d83c8 | ||
|
|
6f6795dc1f | ||
|
|
e0755cb4ee | ||
|
|
71feb86ce8 | ||
|
|
346e1acc97 | ||
|
|
46e6445973 | ||
|
|
28eb3a07e0 | ||
|
|
42c7fcd147 | ||
|
|
6afc3c7821 | ||
|
|
ef943f22a4 | ||
|
|
e33d06a5cc | ||
|
|
70dff9ef68 | ||
|
|
03ff234f0f | ||
|
|
8363c5f0ef | ||
|
|
e455d17bb7 | ||
|
|
aec0fc7a1e | ||
|
|
24586a4b7c | ||
|
|
2c506df38a | ||
|
|
04c08efb0e | ||
|
|
b4cbb1294a | ||
|
|
137211d55d | ||
|
|
48e8c53776 | ||
|
|
72360cbdf4 | ||
|
|
f581c70ccd | ||
|
|
9c8a308ea9 | ||
|
|
dc9096303c | ||
|
|
7fa1e90ed7 | ||
|
|
16666c3ee0 | ||
|
|
7ee67fc178 |
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
# Default owners for all files in the repository.
|
||||||
|
* @psihius @Danielv123 @Hornwitser @Cooldude2606 @ILLISIS
|
||||||
@@ -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 }}-
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
name: Classify CI changes
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
outputs:
|
||||||
|
build_and_test:
|
||||||
|
description: Run the test and coverage matrix, compatibility tests, and mod build.
|
||||||
|
value: ${{ jobs.changes.outputs.build_and_test }}
|
||||||
|
js_lint:
|
||||||
|
description: Run the JavaScript/TypeScript linter.
|
||||||
|
value: ${{ jobs.changes.outputs.js_lint }}
|
||||||
|
lua_lint:
|
||||||
|
description: Run the Lua linter.
|
||||||
|
value: ${{ jobs.changes.outputs.lua_lint }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
build_and_test: ${{ steps.syntax.outputs.build_and_test || steps.paths.outputs.code }}
|
||||||
|
js_lint: ${{ steps.lint.outputs.js_lint }}
|
||||||
|
lua_lint: ${{ steps.lint.outputs.lua_lint }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: dorny/paths-filter@v4
|
||||||
|
id: paths
|
||||||
|
with:
|
||||||
|
token: ''
|
||||||
|
base: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
list-files: json
|
||||||
|
predicate-quantifier: every
|
||||||
|
filters: |
|
||||||
|
# Files requiring validation beyond documentation checks.
|
||||||
|
code:
|
||||||
|
- '**'
|
||||||
|
- '!**/*.md'
|
||||||
|
- '!docs/**/*.{png,jpg,svg,gif}'
|
||||||
|
# Sources and configuration owned by each linter.
|
||||||
|
# Workflow scripts and shared build configuration run both linters.
|
||||||
|
js:
|
||||||
|
- '**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}'
|
||||||
|
- '!.github/**'
|
||||||
|
- '!**/{webpack,babel,rollup,vite,jest}.config.*'
|
||||||
|
lua:
|
||||||
|
- '{*.lua,**/*.lua,.emmyrc.json}'
|
||||||
|
- '!.github/**'
|
||||||
|
# Modified JS/TS source eligible for the comment-only shortcut.
|
||||||
|
# TypeScript type changes still count as substantive changes.
|
||||||
|
syntax:
|
||||||
|
- modified: '**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}'
|
||||||
|
# Source fixtures and templates always receive full validation.
|
||||||
|
- '!test/file/**'
|
||||||
|
- '!packages/create/templates/**'
|
||||||
|
- '!.github/**'
|
||||||
|
- '!**/{eslint,webpack,babel,rollup,vite,jest}.config.*'
|
||||||
|
- name: Select linters
|
||||||
|
id: lint
|
||||||
|
env:
|
||||||
|
CODE_FILES: ${{ steps.paths.outputs.code_files }}
|
||||||
|
JS_FILES: ${{ steps.paths.outputs.js_files }}
|
||||||
|
LUA_FILES: ${{ steps.paths.outputs.lua_files }}
|
||||||
|
run: |
|
||||||
|
# Unclassified non-documentation files can affect either language.
|
||||||
|
jq -nr --argjson code "$CODE_FILES" --argjson js "$JS_FILES" --argjson lua "$LUA_FILES" '
|
||||||
|
($code - $js - $lua | length > 0) as $shared |
|
||||||
|
"js_lint=\($shared or ($js | length > 0))",
|
||||||
|
"lua_lint=\($shared or ($lua | length > 0))"
|
||||||
|
' >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Install Difftastic
|
||||||
|
if: steps.paths.outputs.syntax == 'true'
|
||||||
|
env:
|
||||||
|
DIFFT_VERSION: 0.71.0
|
||||||
|
DIFFT_SHA256: 61aea5394a53c56f144637cf39b3a0c0dafa27769731745e5851ff276e6478da
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/difftastic"
|
||||||
|
curl -fsSL "https://github.com/Wilfred/difftastic/releases/download/$DIFFT_VERSION/difft-$DIFFT_VERSION-x86_64-unknown-linux-gnu.tar.gz" -o "$RUNNER_TEMP/difftastic.tar.gz"
|
||||||
|
echo "$DIFFT_SHA256 $RUNNER_TEMP/difftastic.tar.gz" | sha256sum -c
|
||||||
|
tar -xzf "$RUNNER_TEMP/difftastic.tar.gz" -C "$RUNNER_TEMP/difftastic"
|
||||||
|
echo "$RUNNER_TEMP/difftastic" >> "$GITHUB_PATH"
|
||||||
|
- name: Check for substantive changes
|
||||||
|
id: syntax
|
||||||
|
if: steps.paths.outputs.code == 'true'
|
||||||
|
env:
|
||||||
|
CODE_FILES: ${{ steps.paths.outputs.code_files }}
|
||||||
|
SYNTAX_FILES: ${{ steps.paths.outputs.syntax_files }}
|
||||||
|
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||||
|
HEAD_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
# Skip only when every non-documentation file is an ordinary source edit.
|
||||||
|
echo 'build_and_test=true' >> "$GITHUB_OUTPUT"
|
||||||
|
# A new branch has no previous commit; keep full validation on its first push.
|
||||||
|
if [[ "$BASE" == 0000000000000000000000000000000000000000 ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! jq -en --argjson code "$CODE_FILES" --argjson syntax "$SYNTAX_FILES" \
|
||||||
|
'$code - $syntax | length == 0' > /dev/null; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git cat-file -e "$BASE^{commit}"
|
||||||
|
jq -j '.[] | ., "\u0000"' <<< "$SYNTAX_FILES" > "$RUNNER_TEMP/syntax-files"
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
# Mode changes and symlinks are substantive even when text is identical.
|
||||||
|
[[ -z "$(git diff --summary --no-renames "$BASE" "$HEAD_SHA" -- "$file")" ]] || exit 0
|
||||||
|
git ls-tree "$HEAD_SHA" -- "$file" | grep -qE '^100(644|755) blob ' || exit 0
|
||||||
|
before="$RUNNER_TEMP/before.${file##*.}"
|
||||||
|
after="$RUNNER_TEMP/after.${file##*.}"
|
||||||
|
git show "$BASE:$file" > "$before"
|
||||||
|
git show "$HEAD_SHA:$file" > "$after"
|
||||||
|
# Difftastic ignores ALL comments; retain compiler/bundler/coverage directives.
|
||||||
|
if grep -Eq '@ts-|@jsx|@internal|<reference\b|<amd-|sourceMappingURL|sourceURL|webpack|vite|[#@]__|/\*!|(istanbul|c8|v8)[[:space:]]+ignore' "$before" "$after"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
case "$file" in
|
||||||
|
*.js|*.cjs|*.mjs|*.jsx)
|
||||||
|
# JavaScript JSDoc can contain type information.
|
||||||
|
if grep -Fq '/**' "$before" "$after"; then exit 0; fi ;;
|
||||||
|
esac
|
||||||
|
if ! difft --ignore-comments --check-only --exit-code "$before" "$after"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done < "$RUNNER_TEMP/syntax-files"
|
||||||
|
echo 'build_and_test=false' >> "$GITHUB_OUTPUT"
|
||||||
@@ -8,7 +8,12 @@ env:
|
|||||||
EMMYLUA_CHECK_VERSION: "0.25.1"
|
EMMYLUA_CHECK_VERSION: "0.25.1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
changes:
|
||||||
|
uses: ./.github/workflows/ci-changes.yml
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
|
needs: changes
|
||||||
|
if: ${{ !cancelled() && needs.changes.outputs.lua_lint != 'false' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|||||||
@@ -11,14 +11,20 @@ env:
|
|||||||
NODE_VERSION: 24.x
|
NODE_VERSION: 24.x
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
changes:
|
||||||
|
uses: ./.github/workflows/ci-changes.yml
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
needs: changes
|
||||||
|
if: ${{ !cancelled() && needs.changes.outputs.build_and_test != 'false' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
# Supported node versions are active lts and maintenance see https://nodejs.org/en/about/releases/
|
# Test Node.js 24 and 26 across the Factorio matrix; Node.js 22 runs coverage below.
|
||||||
node-version: [24.x]
|
# Node.js release schedule: https://nodejs.org/en/about/releases/
|
||||||
|
node-version: [24.x, 26.x]
|
||||||
|
|
||||||
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
|
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
|
||||||
factorio-version: [1.1.110, 2.0.77, 2.1.17]
|
factorio-version: [1.1.110, 2.0.77, 2.1.17]
|
||||||
@@ -57,6 +63,8 @@ jobs:
|
|||||||
path: temp/test/logs
|
path: temp/test/logs
|
||||||
|
|
||||||
version-compat:
|
version-compat:
|
||||||
|
needs: changes
|
||||||
|
if: ${{ !cancelled() && needs.changes.outputs.build_and_test != 'false' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
env:
|
env:
|
||||||
@@ -86,9 +94,11 @@ jobs:
|
|||||||
node scripts/version-compat-test.js
|
node scripts/version-compat-test.js
|
||||||
--controller ${{ matrix.controller }}
|
--controller ${{ matrix.controller }}
|
||||||
--host ${{ matrix.host }}
|
--host ${{ matrix.host }}
|
||||||
--published-version $PUBLISHED_VERSION
|
--published-version "$PUBLISHED_VERSION"
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
|
needs: changes
|
||||||
|
if: ${{ !cancelled() && needs.changes.outputs.js_lint != 'false' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
@@ -100,6 +110,8 @@ jobs:
|
|||||||
- run: pnpm run lint
|
- run: pnpm run lint
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
needs: changes
|
||||||
|
if: ${{ !cancelled() && needs.changes.outputs.build_and_test != 'false' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ jobs:
|
|||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
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='./{plugins,packages}/*' 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
|
||||||
@@ -37,7 +39,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
- run: pnpm install --no-frozen-lockfile
|
- run: pnpm install --no-frozen-lockfile
|
||||||
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
|
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match run build-mod --output-dir ../../dist
|
||||||
- run: node ./publish.js factorio ./dist
|
- run: node ./publish.js factorio ./dist
|
||||||
env:
|
env:
|
||||||
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
|
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
|
||||||
|
|||||||
+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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Working on Clusterio
|
||||||
|
|
||||||
|
Clusterio manages distributed Factorio servers through a controller, hosts, and plugins.
|
||||||
|
|
||||||
|
Read [contributing](docs/contributing.md) for the project map, setup, commands, debugging, tests, and code style.
|
||||||
|
For PR reviews, follow the [review guide](docs/pr-review-guide.md), including its candidate ledger and final evidence audit.
|
||||||
|
Use the detailed guides for the affected area:
|
||||||
|
|
||||||
|
- [Communication and routing](docs/communication.md), [wire protocol](docs/devs/protocol.md), and [configuration](docs/config-system.md).
|
||||||
|
- [Plugins](docs/writing-plugins.md), [plugin migration](docs/devs/plugin-migration-to-2.0.md), and [Factorio/Lua](docs/developing-for-clusterio.md).
|
||||||
|
- [Compatibility policies](docs/decisions.md) and [releases](docs/release-workflow.md).
|
||||||
|
|
||||||
|
|
||||||
|
## Working rules
|
||||||
|
|
||||||
|
- Inspect `git status` before editing.
|
||||||
|
Preserve unrelated work and external plugin repositories; continue an existing task branch.
|
||||||
|
- Read the relevant source and tests before changing behavior.
|
||||||
|
If documentation and implementation disagree, report the discrepancy and distinguish observed behavior from intended policy.
|
||||||
|
Update affected documentation in the same change.
|
||||||
|
- Follow nearby code and `.oxlintrc.json`: tabs, double quotes, semicolons, and `.js` extensions in relative TypeScript imports.
|
||||||
|
Import shared public APIs through `@clusterio/lib` and check Node/browser exports when changing them.
|
||||||
|
- Run commands from the root using the pinned pnpm version and the [documented Node requirements](docs/contributing.md#supported-nodejs-and-factorio-versions).
|
||||||
|
Install with `pnpm install --no-frozen-lockfile`; the lockfile is intentionally ignored.
|
||||||
|
- `pnpm build` compiles TypeScript.
|
||||||
|
It does not regenerate validators or production web bundles; use the appropriate package's `prepare` script.
|
||||||
|
Edit sources, not generated outputs.
|
||||||
|
- Start validation with focused tests, then run `pnpm test` and `pnpm lint` for code changes when possible.
|
||||||
|
Root tests include integration setup even with fast/external exclusions.
|
||||||
|
The harness needs Factorio, recreates `temp/test/`, and shares ports 8880/4443; do not run competing suites.
|
||||||
|
- Report checks, failures, skips, and unverified behavior precisely.
|
||||||
|
Documentation-only changes need path, command, and formatting checks.
|
||||||
|
- Preview cleanup with `node clean.js --dry-run`; see the [cleanup scopes](docs/contributing.md#cleaning-generated-and-runtime-files).
|
||||||
|
Runtime-data deletion and release publication require explicit authorization.
|
||||||
|
Keep local configs, credentials, runtime data, and generated output out of commits.
|
||||||
|
- Follow the [PR template](.github/pull_request_template.md), including `### Changelog` (`None.` when appropriate).
|
||||||
+211
-3
@@ -31,6 +31,216 @@ 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
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Added per instance options to choose which parts of the player inventory sync covers. [#968](<https://github.com/clusterio/clusterio/issues/968>)
|
||||||
|
- Added opt-in UDP messaging between instances and the Factorio server via `factorio.enable_lua_udp`, with a `sendUdp` plugin API, `clusterio_api.send_udp` in-game and a `LuaUdp` plugin feature flag. [[#969](<https://github.com/clusterio/clusterio/issues/969>)](<https://github.com/clusterio/clusterio/issues/969>)
|
||||||
|
- Added notes that can be attached to hosts, instances, users, roles, mod packs, mods and saves in the web interface and through ctl. [#903](<https://github.com/clusterio/clusterio/issues/903>)
|
||||||
|
- Added tools to inspect, replace, delete and unlock player data stored by inventory sync, in the web interface and as `clusterioctl inventory-sync` commands. [#1003](<https://github.com/clusterio/clusterio/issues/1003>)
|
||||||
|
- Notes in the web interface render as Markdown and link `@<type>/<id>` mentions to the resource. [#1000](<https://github.com/clusterio/clusterio/pull/1000>)
|
||||||
|
- Added timestamps to log lines in the web interface console. [#1137](<https://github.com/clusterio/clusterio/pull/1137>)
|
||||||
|
- Added syncing of quick bar slots that hold a specific blueprint, blueprint book or planner, including ones from the player's blueprint library, on Factorio 2.1. [#1092](<https://github.com/clusterio/clusterio/issues/1092>)
|
||||||
|
- Added a `set_destination` remote interface to Player Sync. A plugin that moves a player to another instance calls it before `connect_to_server`, and the player's data is sent to that instance while the client reconnects, so the join applies it without a download. [#1099](<https://github.com/clusterio/clusterio/issues/1099>)
|
||||||
|
- Added a cluster research queue to research_sync: servers can follow one research queue, set from the research screen on any following server, with a checkbox on the lab window. [#1115](<https://github.com/clusterio/clusterio/pull/1115>)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Fixed instances that start at the same time each downloading a new Factorio version into the same files and leaving a corrupt install. [[#1105](<https://github.com/clusterio/clusterio/issues/1105>)](<https://github.com/clusterio/clusterio/issues/1105>)
|
||||||
|
- Fixed `controller.max_send_buffer_size` counting buffered messages in UTF-16 code units instead of UTF-8 bytes, which let non-ASCII messages go over the limit. [[#1108](<https://github.com/clusterio/clusterio/issues/1108>)](<https://github.com/clusterio/clusterio/issues/1108>)
|
||||||
|
- Removed incompatible Server Select and Discord Bridge releases from the installer's plugin choices so selecting all offered plugins no longer installs them. [#1102](<https://github.com/clusterio/clusterio/issues/1102>)
|
||||||
|
- Fixed reading map exchange strings from Factorio 2.1.18 and later, which gave wrong enemy evolution and expansion settings and made `instance load-scenario --map-exchange-string` fail. [[#1131](<https://github.com/clusterio/clusterio/issues/1131>)](<https://github.com/clusterio/clusterio/issues/1131>)
|
||||||
|
- Fixed Player Sync erroring when the controller returned no data for a downloading player, which left the player waiting for the timeout. [#1094](<https://github.com/clusterio/clusterio/issues/1094>)
|
||||||
|
- Fixed spoilable items arriving fresh after being serialized, the spoil percent is now kept. [#1093](<https://github.com/clusterio/clusterio/issues/1093>)
|
||||||
|
- Fixed named personal logistic groups losing their requests and trash rules when joining a server where the group was missing or empty. [#1091](<https://github.com/clusterio/clusterio/issues/1091>)
|
||||||
|
- Fixed Player Sync leaving quick bar slots from an earlier visit in place, which made moved or cleared slots come back after changing server. [#1092](<https://github.com/clusterio/clusterio/issues/1092>)
|
||||||
|
- Fixed research_sync sending research it received from the controller back to it, so every instance got the research again each time an instance started. [#1110](<https://github.com/clusterio/clusterio/issues/1110>)
|
||||||
|
- Fixed items in main inventory slots from armor equipment such as toolbelts not fitting in the inventory when joining another server. [#1100](<https://github.com/clusterio/clusterio/issues/1100>)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Replaced jszip with yauzl and yazl so saves and mods are no longer loaded fully into memory when patched, read or exported. [#1002](<https://github.com/clusterio/clusterio/issues/1002>)
|
||||||
|
- Bumped dependency versions, including typebox 0.34 and react-router 7. Plugins using `@sinclair/typebox` should update to ^0.34. [#1113](<https://github.com/clusterio/clusterio/pull/1113>)
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
|
||||||
|
Many thanks to the following for contributing to this release:
|
||||||
|
[@psihius](<https://github.com/psihius>), [@ILLISIS](<https://github.com/ILLISIS>), [@bbassie](<https://github.com/bbassie>), [@ColumbusLabs](<https://github.com/ColumbusLabs>)
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
|
||||||
|
Commits: 101 Pull Requests: 29 Files Changed: 135 Additions: +8,830 Deletions: -994
|
||||||
|
|
||||||
|
## Version 2.0.2
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Fixed installation with selected plugins by relying on automatic plugin discovery, and ensured installer failures return a nonzero exit status. [#1082](<https://github.com/clusterio/clusterio/pull/1082>), [#1084](<https://github.com/clusterio/clusterio/issues/1084>)
|
||||||
|
- Fixed web plugin `controllerConnectionEvent` handlers receiving the connector's event data instead of the event name. [#1083](<https://github.com/clusterio/clusterio/issues/1083>)
|
||||||
|
- Updated installation instructions to recommend latest Node.js LTS and corrected outdated mod management, CLI, plugin, and release documentation. [#1086](<https://github.com/clusterio/clusterio/pull/1086>)
|
||||||
|
|
||||||
|
### Meta
|
||||||
|
|
||||||
|
- Made player-auth login tests independent of wall-clock timing and added verification-token expiry coverage. [#1087](<https://github.com/clusterio/clusterio/pull/1087>)
|
||||||
|
- Added Node.js 26 to the CI test matrix across all tested Factorio versions. [#1088](<https://github.com/clusterio/clusterio/pull/1088>)
|
||||||
|
|
||||||
|
Many thanks to the following for contributing to this release:
|
||||||
|
[@psihius](<https://github.com/psihius>), [@bbassie](<https://github.com/bbassie>)
|
||||||
|
|
||||||
|
## Version 2.0.1
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Removed obsolete development-branch warnings from controller, host and ctl startup. [#1078](<https://github.com/clusterio/clusterio/pull/1078>)
|
||||||
|
- Removed the obsolete alpha label and development warning from the Clusterio Library mod title and description. [#1079](<https://github.com/clusterio/clusterio/pull/1079>)
|
||||||
|
- Fixed Factorio mod release uploads to use valid multipart requests and files from the requested output directory, uploading variants in version order. [#1080](<https://github.com/clusterio/clusterio/pull/1080>)
|
||||||
|
|
||||||
|
Many thanks to the following for contributing to this release:
|
||||||
|
[@psihius](<https://github.com/psihius>)
|
||||||
|
|
||||||
|
## Version 2.0.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Mod packs now show informational notices for disabled recommended mods and available compatible updates. [#949](<https://github.com/clusterio/clusterio/issues/949>)
|
||||||
|
- Added host, server name, save copy and start options to the create instance dialog in the web UI. [#405](<https://github.com/clusterio/clusterio/issues/405>)
|
||||||
|
- Added `InstanceSaveGameRequest` and `clusterioctl instance save-game` to save the running game of an instance. [#405](<https://github.com/clusterio/clusterio/issues/405>)
|
||||||
|
- Added `permissions` to `PluginDeclaration` and a `Permissions` type registry so plugin permission names are checked at compile time. [#987](<https://github.com/clusterio/clusterio/issues/987>)
|
||||||
|
- Added Update All to the web interface and `--all` to the ctl update commands, updating the core package and all npm installed plugins in one npm call. [#910](<https://github.com/clusterio/clusterio/issues/910>)
|
||||||
|
- Added typed hook system for plugin registration. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||||
|
- Added `plugin install <package>` command to controller, host and ctl which installs a plugin from npm and adds it to the plugin list. [#1008](<https://github.com/clusterio/clusterio/issues/1008>)
|
||||||
|
- Added a guard that prevents controller and host restart requests from unintentionally downgrading Clusterio. [#950](<https://github.com/clusterio/clusterio/issues/950>)
|
||||||
|
- Added option to load static assets from a separate URL from where the controller's web API is hosted at. [#983](<https://github.com/clusterio/clusterio/pull/983>)
|
||||||
|
- Added `clusteriocontroller copy-static` command to copy static web assets to a folder to ease hosting assets on a separate server. [#983](<https://github.com/clusterio/clusterio/pull/983>)
|
||||||
|
- Added Mark Restart Required to the host and controller pages and matching ctl commands, for plugins installed by other means than the install request. [#1043](<https://github.com/clusterio/clusterio/pull/1043>)
|
||||||
|
- Added the RoleViewPageBeforePermissions web extension slot for plugin components above the permissions on the role page. [#1045](<https://github.com/clusterio/clusterio/pull/1045>)
|
||||||
|
- Added a list of permission groups to the role page, highlighting groups with granted permissions. [#1044](<https://github.com/clusterio/clusterio/pull/1044>)
|
||||||
|
- Added `clusteriohost create-scenario` to create a scenario with the Clusterio modules patched in for use outside of Clusterio. [#1014](<https://github.com/clusterio/clusterio/pull/1014>)
|
||||||
|
- Plugins can declare a `migrationEntrypoint` to migrate the config fields and role permissions core stores for them. Configs track the plugin version they were last loaded with in a hidden `<plugin>.version` field. [#1073](<https://github.com/clusterio/clusterio/pull/1073>)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Deprecation warning DEP0190 is no longer printed when installing. [#851](<https://github.com/clusterio/clusterio/issues/851>)
|
||||||
|
- Events sent to subscribers are no longer addressed to the request id of the request that created the subscription. [#847](<https://github.com/clusterio/clusterio/issues/847>)
|
||||||
|
- Factorio account token no longer written to the log when a mod download fails. [#795](<https://github.com/clusterio/clusterio/issues/795>)
|
||||||
|
- Failed downloads no longer leave a temporary file and an open file handle behind, which could shift the file names used by later downloads. [#957](<https://github.com/clusterio/clusterio/pull/957>)
|
||||||
|
- Requests that could not be forwarded to the next hop are no longer answered a second time when the link disconnects. [#650](<https://github.com/clusterio/clusterio/issues/650>)
|
||||||
|
- Events broadcast from an instance to all hosts are now delivered to every host instead of only the one running the instance. [#575](<https://github.com/clusterio/clusterio/issues/575>)
|
||||||
|
- Web interface now asks to be reloaded when the controller's plugins change, instead of continuing against plugins that are no longer loaded. [#804](<https://github.com/clusterio/clusterio/issues/804>)
|
||||||
|
- Plugin list no longer reports a plugin added since the page was opened as having failed to load. [#729](<https://github.com/clusterio/clusterio/issues/729>)
|
||||||
|
- Instance console no longer hides the Factorio server's replies to console commands, or Factorio's own errors, when filtering to game activity. [#853](<https://github.com/clusterio/clusterio/issues/853>)
|
||||||
|
- Allow player online autosave slots to be disabled while autosaves are enabled. [#966](<https://github.com/clusterio/clusterio/issues/966>)
|
||||||
|
- Plugin web bundles are now loaded relative to `staticRoot` instead of `webRoot`. [#980](<https://github.com/clusterio/clusterio/issues/980>)
|
||||||
|
- Fixed inventory_sync not preserving the personal roboport toggle, the equipment movement bonus toggle, game view settings such as alt mode, and toggleable mod shortcuts between instances. [#898](<https://github.com/clusterio/clusterio/issues/898>)
|
||||||
|
- Schema validation errors are now included when an InvalidMessage is logged from IPC and event handlers. [#986](<https://github.com/clusterio/clusterio/issues/986>)
|
||||||
|
- Fixed clusterioctl not being able to reference hosts, instances, mod packs and roles by an integer like name. [#886](<https://github.com/clusterio/clusterio/issues/886>)
|
||||||
|
- Fixed the web-only plugin template referencing a permission that was never defined. [#987](<https://github.com/clusterio/clusterio/issues/987>)
|
||||||
|
- Config files are no longer world readable by default. [#1005](<https://github.com/clusterio/clusterio/issues/1005>)
|
||||||
|
- Fixed failed recipe notification names being duplicated on every upload from the same instance in inventory sync. [#964](<https://github.com/clusterio/clusterio/issues/964>)
|
||||||
|
- Instances refuse to start with a mod pack for a newer Factorio version than the instance runs, instead of Factorio silently loading default mod settings. [#1006](<https://github.com/clusterio/clusterio/issues/1006>)
|
||||||
|
- Documented the config options missing from configuration.md, marked the internal ones and noted the validation rules. [#985](<https://github.com/clusterio/clusterio/issues/985>)
|
||||||
|
- Fixed concurrent saves of the same file racing on a shared temporary file. [#1026](<https://github.com/clusterio/clusterio/pull/1026>)
|
||||||
|
- Fixed control connections leaving their log subscription and debug dump listeners attached after closing. [#1027](<https://github.com/clusterio/clusterio/pull/1027>)
|
||||||
|
- Fixed the Load Scenario modal reading the wrong field for map settings. [#1032](<https://github.com/clusterio/clusterio/pull/1032>)
|
||||||
|
- Fixed the controller exiting when a control message's permission check threw an unexpected error. [#1033](<https://github.com/clusterio/clusterio/pull/1033>)
|
||||||
|
- Fixed the host exiting on server log lines that look like actions but do not match the expected format. [#1034](<https://github.com/clusterio/clusterio/pull/1034>)
|
||||||
|
- Fixed the controller crashing when a control connection with the WebSocket debug dump active was resumed. [#1035](<https://github.com/clusterio/clusterio/pull/1035>)
|
||||||
|
- Fixed deleted users keeping their permissions on any open connection until they disconnected. [#1030](<https://github.com/clusterio/clusterio/pull/1030>)
|
||||||
|
- Restored pagination on the instance save list. [#967](<https://github.com/clusterio/clusterio/issues/967>)
|
||||||
|
- Mod dependency resolution now only picks releases made for the mod pack's Factorio version. [#965](<https://github.com/clusterio/clusterio/issues/965>)
|
||||||
|
- Inventory sync no longer uploads an empty inventory over the stored one after a failed download. [#912](<https://github.com/clusterio/clusterio/issues/912>)
|
||||||
|
- Inventory sync now releases players who leave while using a temporary inventory, instead of showing them as busy until the instance stops. [#975](<https://github.com/clusterio/clusterio/pull/975>)
|
||||||
|
- Fixed reading map exchange strings from Factorio 2.1.13 and later, which broke `instance load-scenario --map-exchange-string` on current Factorio. [#977](<https://github.com/clusterio/clusterio/pull/977>)
|
||||||
|
- Fixed instances getting stuck on a host after a failed assign, and instance create requests no longer accept `instance.assigned_host`. [#1029](<https://github.com/clusterio/clusterio/pull/1029>)
|
||||||
|
- Fixed the host exiting when an instance's whitelist or server settings config changed while it was starting, or when tags contained non-string values. [#1028](<https://github.com/clusterio/clusterio/pull/1028>)
|
||||||
|
- Fixed a host being able to modify the controller's stored save list for, or forge log messages attributed to, instances assigned to other hosts. [#1036](<https://github.com/clusterio/clusterio/pull/1036>)
|
||||||
|
- Fixed a stop request or RCON command hanging when the Factorio process exited before its RCON connection was established. [#1031](<https://github.com/clusterio/clusterio/pull/1031>)
|
||||||
|
- Fixed core plugins needlessly installing the web_ui, controller, host and ctl packages. [#1021](<https://github.com/clusterio/clusterio/pull/1021>)
|
||||||
|
- Installing a plugin on a host or the controller now marks it as needing a restart. [#1042](<https://github.com/clusterio/clusterio/pull/1042>)
|
||||||
|
- Fixed plugin manifest path resolution when loading web assets. [#1046](<https://github.com/clusterio/clusterio/issues/1046>)
|
||||||
|
- A plugin whose web build is expected but missing is reported again: `Controller.loadJsonObject(path, true)` throws on a missing file instead of returning `{}`, and a malformed JSON file always throws. [#1046](<https://github.com/clusterio/clusterio/issues/1046>)
|
||||||
|
- Preserve full configuration field names when using the CLI config editor. [#796](<https://github.com/clusterio/clusterio/issues/796>)
|
||||||
|
- Plugin metrics hook handlers may return nothing. [#1052](<https://github.com/clusterio/clusterio/pull/1052>)
|
||||||
|
- Fixed save names given when starting an instance or creating a save not being checked, allowing files outside the instance directory to be written or modified. [#1053](<https://github.com/clusterio/clusterio/pull/1053>)
|
||||||
|
- Fixed access tokens from request headers being written to the controller and cluster logs. [#1054](<https://github.com/clusterio/clusterio/pull/1054>)
|
||||||
|
- Fixed subscriptions and log following continuing after the user lost permission for them. [#1056](<https://github.com/clusterio/clusterio/pull/1056>)
|
||||||
|
- Fixed web interface data staying marked as up to date after the user lost permission to view it, and not loading after permission was granted. [#1058](<https://github.com/clusterio/clusterio/pull/1058>)
|
||||||
|
- Fixed ctl config edit showing fields that cannot be edited and leaving out field descriptions. [#1051](<https://github.com/clusterio/clusterio/issues/1051>)
|
||||||
|
- Fixed the reason an instance failed to start being logged as "undefined". [#1065](<https://github.com/clusterio/clusterio/issues/1065>)
|
||||||
|
- Fixed subspace inventory combinators missing items after instance startup. [#1069](<https://github.com/clusterio/clusterio/issues/1069>)
|
||||||
|
- Fixed the controller crashing when a WebSocket client sent an invalid frame before completing the handshake. [#1063](<https://github.com/clusterio/clusterio/issues/1063>)
|
||||||
|
- Fixed an instance reusing a stored game port that another process holds, which made it fail to start until the port was freed. [#1065](<https://github.com/clusterio/clusterio/issues/1065>)
|
||||||
|
- Fixed credentials in request query parameters, such as OAuth codes, being written to the controller's HTTP log. [#1074](<https://github.com/clusterio/clusterio/pull/1074>)
|
||||||
|
- Fixed plugin `ctlConfigFields` not being registered in the clusterioctl config. [#1075](<https://github.com/clusterio/clusterio/pull/1075>)
|
||||||
|
- Fixed startup failures and plugin loading when installing Clusterio from published npm packages after the ES module migration. [#1070](<https://github.com/clusterio/clusterio/pull/1070>)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Global chat now filters server-specific GPS, blueprint, train, and train-stop rich-text tags before relaying messages between instances. [#593](<https://github.com/clusterio/clusterio/issues/593>)
|
||||||
|
- Instance console filter is now a labelled Game/Instance control instead of a single-label toggle, and Factorio's output in the log consoles is prefixed with `[server]` like every other log level. [#853](<https://github.com/clusterio/clusterio/issues/853>)
|
||||||
|
- Inventory sync sends only the recipe notifications that differ from the destination instance's state on download. [#964](<https://github.com/clusterio/clusterio/issues/964>)
|
||||||
|
- Log the source of responses received without a pending request, the peer of timed out connections and the number of messages resent on session resume. [#1007](<https://github.com/clusterio/clusterio/issues/1007>)
|
||||||
|
- Plugins that are not expected to have a web build are no longer shown with an error in the plugins page. [#1004](<https://github.com/clusterio/clusterio/issues/1004>)
|
||||||
|
- Plugins should now return a function to register handlers via hooks. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||||
|
- Plugin templates from `@clusterio/create` use entrypoint functions and hooks. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||||
|
- Improved typing for plugin APIs and webui extension points. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||||
|
- Plugin hooks run concurrently instead of one plugin at a time. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||||
|
- Access tokens are also accepted as an `Authorization: Bearer` header on HTTP API endpoints. [#1009](<https://github.com/clusterio/clusterio/issues/1009>)
|
||||||
|
- `plugin install` and `plugin add` with a package name now check that they are run from a Clusterio installation directory. [#1008](<https://github.com/clusterio/clusterio/issues/1008>)
|
||||||
|
- Bumped pnpm to 12, the GitHub Actions used by CI to their current majors, and the CI Factorio version to 2.1.17. [#1022](<https://github.com/clusterio/clusterio/pull/1022>)
|
||||||
|
- Bumped dependencies, including eslint 10, yargs 18, chalk 6, @types/express 5 and @types/node 24. [#1023](<https://github.com/clusterio/clusterio/pull/1023>)
|
||||||
|
- Plugin discovery now traverses dependencies only after confirming the parent package is a Clusterio plugin. [#963](<https://github.com/clusterio/clusterio/pull/963>)
|
||||||
|
- Converted the project to using ES Modules. [#984](<https://github.com/clusterio/clusterio/pull/984>)
|
||||||
|
- Converted the bundled plugins to entrypoint functions. [#1052](<https://github.com/clusterio/clusterio/pull/1052>)
|
||||||
|
- Added `controller.max_subscription_filters` config to limit the number of filters a connection can subscribe with for one event. [#1055](<https://github.com/clusterio/clusterio/pull/1055>)
|
||||||
|
- Added `controller.max_send_buffer_size` config to limit how much the controller buffers for a connection that does not acknowledge messages. [#1057](<https://github.com/clusterio/clusterio/pull/1057>)
|
||||||
|
- Added links to the ExpCluster and Chat Relay community plugins in the README. [#1062](<https://github.com/clusterio/clusterio/pull/1062>)
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- `staticRoot` in the web interface now includes the `static/` prefix. Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part. [#980](<https://github.com/clusterio/clusterio/issues/980>)
|
||||||
|
- `checkPermission` and the web UI `hasPermission` helpers take `PermissionName`; TypeScript plugins must augment the `Permissions` interface. [#987](<https://github.com/clusterio/clusterio/issues/987>)
|
||||||
|
- Replaced `plugins` map with `loadedPlugins` set across core classes (except WebUi). ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||||
|
- The `/metrics` endpoint requires a user token with the new `core.controller.metrics` permission. Concurrent scrapes share a single gather from the hosts. [#1009](<https://github.com/clusterio/clusterio/issues/1009>)
|
||||||
|
- Config files now have to be explicitly created. Use the `--create-config` flag to get the old behaviour of creating it if missing. [#548](<https://github.com/clusterio/clusterio/issues/548>)
|
||||||
|
- Renamed clusterioctl's config-control subcommand to config. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
|
||||||
|
- Renamed clusterioctl's main class from Control to Ctl. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
|
||||||
|
- Renamed clusterioctl's config entries from control.[name] to ctl.[name]. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
|
||||||
|
- Plugin entrypoints must now specify the full filename including the `.js` extension. [#984](<https://github.com/clusterio/clusterio/pull/984>)
|
||||||
|
- Renamed the inventory_sync plugin to player_sync, published as `@clusterio/plugin-player_sync`. Config fields, the inventory view permission, the controller database file and save data are migrated on load. Uninstall `@clusterio/plugin-inventory_sync` before installing the new package. [#1072](<https://github.com/clusterio/clusterio/pull/1072>)
|
||||||
|
|
||||||
|
### Meta
|
||||||
|
|
||||||
|
- Added a `NO_EXTERNAL_TEST` flag to skip tests that use external APIs. [#976](<https://github.com/clusterio/clusterio/pull/976>)
|
||||||
|
- Added a CI check that the controller and host stay compatible with the latest published release. [#978](<https://github.com/clusterio/clusterio/pull/978>)
|
||||||
|
- Added a Lua lint CI job using emmylua_check. [#979](<https://github.com/clusterio/clusterio/pull/979>)
|
||||||
|
- Added a plugin migration guide for 2.0 and documented that minor versions may contain breaking changes for the plugin API. [#1050](<https://github.com/clusterio/clusterio/pull/1050>)
|
||||||
|
|
||||||
|
Many thanks to the following for contributing to this release:
|
||||||
|
[@bbassie](<https://github.com/bbassie>), [@lntutor](<https://github.com/lntutor>), [@ColumbusLabs](<https://github.com/ColumbusLabs>), [@floze-the-genius](<https://github.com/floze-the-genius>), [@Cooldude2606](<https://github.com/Cooldude2606>), [@solarcloud7](<https://github.com/solarcloud7>), [@Hornwitser](<https://github.com/Hornwitser>), [@ILLISIS](<https://github.com/ILLISIS>), [@Danielv123](<https://github.com/Danielv123>), [@PHIDIAS0303](<https://github.com/PHIDIAS0303>)
|
||||||
|
|
||||||
## Version 2.0.0-alpha.27
|
## Version 2.0.0-alpha.27
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -47,14 +257,12 @@ Many thanks to the following for contributing to this release:
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- Allow player online autosave slots to be disabled while autosaves are enabled. [#966](<https://github.com/clusterio/clusterio/issues/966>)
|
|
||||||
- Silenced two Ant Design console warnings in the Web UI: the deprecated Modal `destroyOnClose` prop, and a duplicate-value warning from the instance version selector. [#940](<https://github.com/clusterio/clusterio/pull/940>)
|
- Silenced two Ant Design console warnings in the Web UI: the deprecated Modal `destroyOnClose` prop, and a duplicate-value warning from the instance version selector. [#940](<https://github.com/clusterio/clusterio/pull/940>)
|
||||||
- Surface the mod portal's own error when downloading mods for an unsupported Factorio version. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
- Surface the mod portal's own error when downloading mods for an unsupported Factorio version. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||||
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
|
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
- Global chat now filters server-specific GPS, blueprint, train, and train-stop rich-text tags before relaying messages between instances. [#593](<https://github.com/clusterio/clusterio/issues/593>)
|
|
||||||
- Reworked the site layout into a fixed sidebar with a top bar. [#924](<https://github.com/clusterio/clusterio/pull/924>)
|
- Reworked the site layout into a fixed sidebar with a top bar. [#924](<https://github.com/clusterio/clusterio/pull/924>)
|
||||||
- The builtin "recycler" mod is now included correctly as builtin. [#925](<https://github.com/clusterio/clusterio/issues/925>)
|
- The builtin "recycler" mod is now included correctly as builtin. [#925](<https://github.com/clusterio/clusterio/issues/925>)
|
||||||
- Mod `info.json` versions (a mod's version and its `factorio_version`) are now parsed leniently, matching how the game reads them, instead of being validated against a fixed list of Factorio versions. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
- Mod `info.json` versions (a mod's version and its `factorio_version`) are now parsed leniently, matching how the game reads them, instead of being validated against a fixed list of Factorio versions. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||||
@@ -543,7 +751,7 @@ Many thanks to the following for contributing to this release:
|
|||||||
## Version 2.0.0-alpha.14
|
## Version 2.0.0-alpha.14
|
||||||
|
|
||||||
This release does not retain backwards compatibility with 2.0.0-alpha.13 meaning all parts of the cluster needs to updated at the same time.
|
This release does not retain backwards compatibility with 2.0.0-alpha.13 meaning all parts of the cluster needs to updated at the same time.
|
||||||
See the [migration guide](/docs/migrating-from-2.0.0-alpha.13.md) for how to update.
|
See the [migration guide](https://github.com/clusterio/clusterio/blob/e0755cb4ee7bce0068c844ab6271da481a843e62/docs/migrating-from-2.0.0-alpha.13.md) for how to update.
|
||||||
|
|
||||||
### Major Features
|
### Major Features
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ Discord for development/support/play: https://discord.gg/5XuDkje
|
|||||||
|
|
||||||
## Important notice
|
## Important notice
|
||||||
|
|
||||||
Clusterio 2.0 is still in alpha, however the previous stable has been abandoned and is no longer supported.
|
Clusterio 2.0 is the current stable release and requires Node.js 22 or newer.
|
||||||
Despite being alpha it's reasonably stable now and there's no major breakages expected before a stable release of 2.0.
|
For new installations, use the latest Node.js LTS release (see [Installation](#installation)).
|
||||||
If you are starting a new cluster, it's highly recommended to use the 2.0 alpha.
|
The previous stable series is no longer supported.
|
||||||
|
If you are upgrading from a 2.0 alpha, review the [changelog](CHANGELOG.md) before upgrading.
|
||||||
|
Plugin authors should also follow the [2.0 migration guide](docs/devs/plugin-migration-to-2.0.md).
|
||||||
|
|
||||||
|
|
||||||
### Ways to support me/the project:
|
### Ways to support me/the project:
|
||||||
@@ -40,7 +42,7 @@ If you are starting a new cluster, it's highly recommended to use the 2.0 alpha.
|
|||||||
* [Setting up remote hosts](#setting-up-remote-hosts)
|
* [Setting up remote hosts](#setting-up-remote-hosts)
|
||||||
* [Setting up clusterioctl](#setting-up-clusterioctl)
|
* [Setting up clusterioctl](#setting-up-clusterioctl)
|
||||||
* [Known issues](#known-issues)
|
* [Known issues](#known-issues)
|
||||||
* [Common problems](#Common-problems)
|
* [Common problems](#common-problems)
|
||||||
|
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
@@ -71,11 +73,12 @@ 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.
|
||||||
- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked between servers.
|
- [Lobby](/plugins/lobby/README.md): lobby servers where players pick the server to join, and listing of the cluster in the Factorio server browser.
|
||||||
|
- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked and the research queue between servers.
|
||||||
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the controller.
|
- [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.
|
||||||
- [Player Auth](/plugins/player_auth/README.md): Provides authentication to the cluster via logging into a Factorio server.
|
- [Player Auth](/plugins/player_auth/README.md): Provides authentication to the cluster via logging into a Factorio server.
|
||||||
- [Inventory Sync](/plugins/inventory_sync/README.md): Synchronizes your inventory between servers.
|
- [Player Sync](/plugins/player_sync/README.md): Synchronizes your inventory and player settings between servers.
|
||||||
|
|
||||||
There's also plugins developed and maintained by the community:
|
There's also plugins developed and maintained by the community:
|
||||||
|
|
||||||
@@ -83,6 +86,12 @@ There's also plugins developed and maintained by the community:
|
|||||||
By Hornwitser.
|
By Hornwitser.
|
||||||
- [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) (@hornwitser/server_select): In-game GUI for connecting to other servers in the cluster.
|
- [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) (@hornwitser/server_select): In-game GUI for connecting to other servers in the cluster.
|
||||||
Originally by Godmave, ported to 2.0 by Hornwitser.
|
Originally by Godmave, ported to 2.0 by Hornwitser.
|
||||||
|
- [ExpCluster](https://github.com/explosivegaming/ExpCluster) (@expcluster/*): A collection of plugins and scenario features including role and group syncing, moderation tools, quality of life additions, and discord account linking.
|
||||||
|
See repo readme for plugin breakdown. Maintained by Cooldude2606.
|
||||||
|
- [Chat Relay](https://github.com/PHIDIAS0303/chat_relay) (@PHIDIAS0303/chat_relay): Chat relay of Discord and Factorio, seperate channel.
|
||||||
|
|
||||||
|
The installer offers the six plugins maintained in this repository.
|
||||||
|
Community plugins are versioned and released independently of Clusterio; check their compatibility before installing them manually.
|
||||||
|
|
||||||
Want to make your own plugin?
|
Want to make your own plugin?
|
||||||
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
|
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
|
||||||
@@ -92,8 +101,9 @@ Note that from 2.0 onwards minor versions of Clusterio may contain breaking chan
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Clusterio runs on all LTS version of NodeJS, it is distributed via npm and comes with a guided install script.
|
Install the latest [Node.js LTS release](https://nodejs.org/en/download), which is recommended for new Clusterio installations.
|
||||||
If you already have a recent Node.js installed, you can set it up in a new directory with:
|
Clusterio is distributed via npm and comes with a guided install script.
|
||||||
|
If you already have a supported Node.js version installed, you can set it up in a new directory with:
|
||||||
|
|
||||||
npm init "@clusterio"
|
npm init "@clusterio"
|
||||||
|
|
||||||
@@ -102,12 +112,14 @@ Check out all our of decisions for [node, factorio, and operation support](/docs
|
|||||||
|
|
||||||
### Ubuntu setup
|
### Ubuntu setup
|
||||||
|
|
||||||
1. Install Node.js v18 or higher.
|
1. Install the latest Node.js LTS release using the NodeSource repository for Ubuntu/Debian.
|
||||||
Note the version of Node.js provided by the Ubuntu/debian repos are too old and you will have to use the nodesource PPA
|
The distribution's default repository may provide an older version.
|
||||||
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - &&\
|
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - &&\
|
||||||
sudo apt-get install -y nodejs
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
Verify the installation with `node --version` and `npm --version`.
|
||||||
|
|
||||||
2. Log in as the user you wish to install Clusterio as (do not use root), and then create a new directory and run the Clusterio installer:
|
2. Log in as the user you wish to install Clusterio as (do not use root), and then create a new directory and run the Clusterio installer:
|
||||||
|
|
||||||
mkdir clusterio
|
mkdir clusterio
|
||||||
@@ -139,7 +151,7 @@ Your instances (save files etc) will be stored there.
|
|||||||
|
|
||||||
### Windows setup
|
### Windows setup
|
||||||
|
|
||||||
1. Download and install the latest LTS release from http://nodejs.org.
|
1. Download and install the latest [Node.js LTS release](https://nodejs.org/en/download).
|
||||||
|
|
||||||
2. Create a new empty directory for the installation and navigate into it.
|
2. Create a new empty directory for the installation and navigate into it.
|
||||||
Open a PowerShell window in this new directory by Shift+Right clicking inside it and choosing "Open PowerShell window here" and then run the following command.
|
Open a PowerShell window in this new directory by Shift+Right clicking inside it and choosing "Open PowerShell window here" and then run the following command.
|
||||||
@@ -153,7 +165,8 @@ Your instances (save files etc) will be stored there.
|
|||||||
|
|
||||||
### MacOS Setup
|
### MacOS Setup
|
||||||
|
|
||||||
1. Install the latest Node.js LTS release from http://nodejs.org or use brew (`brew install node`).
|
1. Install the latest [Node.js LTS release](https://nodejs.org/en/download).
|
||||||
|
If you use [nvm](https://github.com/nvm-sh/nvm#installing-and-updating), run `nvm install --lts`.
|
||||||
|
|
||||||
2. Open Terminal or Command prompt in the directory you want to install to and run the following commands.
|
2. Open Terminal or Command prompt in the directory you want to install to and run the following commands.
|
||||||
|
|
||||||
@@ -207,7 +220,11 @@ See the [readme for @clusterio/controller](/packages/controller/README.md) for m
|
|||||||
|
|
||||||
## Managing Factorio mods
|
## Managing Factorio mods
|
||||||
|
|
||||||
Mods in clusterio are managed as parts of modpacks. There is currently no mod portal integration, so you first need to navigate to the mods section of the web UI and upload your mods. You can then create a modpack from the uploaded mods by navigating to the mods page, hitting `create`. Once you have a modpack, you can assign it to an instance under Instance config -> Factorio -> Mod pack. You can also set a default modpack for new instances in the controller settings.
|
Mods in Clusterio are managed as parts of mod packs.
|
||||||
|
On the Mods page of the web interface, use **Search** to browse the Factorio Mod Portal and download mods to the controller, or upload mod zip files directly.
|
||||||
|
Mod Portal downloads require the controller's [Factorio account credentials](docs/configuration.md#controllerfactorio_username) to be configured.
|
||||||
|
Use **Create** in the Mod Packs section to create a mod pack, then add the mods you want to use.
|
||||||
|
Assign the mod pack to an instance under Instance config -> Factorio -> Mod pack, or set a default mod pack for new instances in the controller settings.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -223,7 +240,9 @@ Once this is done, the mod settings will show up under the modpack configuration
|
|||||||
## Setting up shared storage
|
## Setting up shared storage
|
||||||
|
|
||||||
The chests teleporting items to and from the shared storage on the controller has been moved into the Subspace Storage mod and plugin.
|
The chests teleporting items to and from the shared storage on the controller has been moved into the Subspace Storage mod and plugin.
|
||||||
To get this to work you will need to install the Subspace Storage plugin and copy the [Subspace Storage mod](https://mods.factorio.com/mod/subspace_storage) and the [Clusterio Lib mod](https://mods.factorio.com/mod/clusterio_lib) into the `sharedMods` folder on each host installation.
|
To get this to work, install the Subspace Storage plugin on the controller and hosts using the [plugin installation instructions](#installing-plugins).
|
||||||
|
Download or upload the [Subspace Storage mod](https://mods.factorio.com/mod/subspace_storage) and the [Clusterio Lib mod](https://mods.factorio.com/mod/clusterio_lib) to the controller, add them to a mod pack, and assign it to each participating instance as described in [Managing Factorio mods](#managing-factorio-mods).
|
||||||
|
Hosts fetch the mods from the controller and synchronize each instance's mods folder with its assigned mod pack when the instance starts.
|
||||||
|
|
||||||
Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), this was for the old 1.2.x version of Clusterio and is not compatible with Clusterio 2.0.
|
Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), this was for the old 1.2.x version of Clusterio and is not compatible with Clusterio 2.0.
|
||||||
|
|
||||||
@@ -271,20 +290,13 @@ If you want to use a different user for the command line interface, you can gene
|
|||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
||||||
- When the `server_select` plugin is installed you may see `[error] Unhandled event server_select:UpdateInstancesEvent` logged when starting an instance.
|
- `@hornwitser/server_select@2.0.1` fails to load with Clusterio 2.0 because its entrypoints omit the required `.js` extension.
|
||||||
This should be ignored as it does not indicate an error occured.
|
`@hornwitser/discord_bridge@0.2.5` is not automatically discovered because it lacks the `clusterio-plugin` keyword.
|
||||||
|
The Clusterio 2.0.2 installer still offers these versions; leave them unchecked until compatible plugin releases are available.
|
||||||
|
If already installed, stop the controller and host, run `npm uninstall @hornwitser/server_select @hornwitser/discord_bridge` in each affected installation directory, and restart them to continue with the core plugins.
|
||||||
|
See [#1102](https://github.com/clusterio/clusterio/issues/1102).
|
||||||
- After updating Factorio you may get the error `Error: Expected empty response but got "Cannot execute command. Error: [string "clusterio_private.update_instance(..."]:1: attempt to index global 'clusterio_private' (a nil value)?` on instance start.
|
- After updating Factorio you may get the error `Error: Expected empty response but got "Cannot execute command. Error: [string "clusterio_private.update_instance(..."]:1: attempt to index global 'clusterio_private' (a nil value)?` on instance start.
|
||||||
This is due to Factorio applying a migration replacing the patched content of the save with the original scenario, attempting to start the instance again should resolve the issue.
|
This is due to Factorio applying a migration replacing the patched content of the save with the original scenario, attempting to start the instance again should resolve the issue.
|
||||||
- In the mod pack view of the web interface the "base" mod is displayed with an error complaining about the mod being missing from the controller storage.
|
|
||||||
This is due to the "base" mod currently not being connectly handled as a built-in mod to Factorio in the web interface, this error message should be ignored.
|
|
||||||
- After migrating from Alpha 13 to Alpha 14 running an instance data export fails with the error `Upload failed: 401 Unauthorized` due to the host token being issued to a slave.
|
|
||||||
To workaround this find the id of the host in the host config and generate a new host token with the same id.
|
|
||||||
Stop the host and replace the value of the `host.controller_token` field in `config-host.json` with the new host token before starting it up again.
|
|
||||||
- After migrating from Alpha 13 to Alpha 14 you get kicked out from the web interface due to the master to controller rename.
|
|
||||||
If you don't have the authentication token readily available you can manually migrate the token by opening the Inspect/Developer Tools on the page and running the following piece of JavaScript in the Console:
|
|
||||||
```
|
|
||||||
localStorage.controller_token = localStorage.master_token
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Common problems
|
## Common problems
|
||||||
|
|||||||
@@ -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"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<mxfile host="Electron" modified="2023-07-15T21:19:41.336Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/20.8.16 Chrome/106.0.5249.199 Electron/21.4.0 Safari/537.36" etag="WQippoIg5xK5LnRPeLWb" version="20.8.16" type="device"><diagram id="wKlty5uROKaaHdmDaOKw" name="Page-1">5Vtdk6I4FP01Ps4UEEB9bO12Pmp3qmvdqnlOQ4R0R+KE2Or++k0kgBBEelTQoR9suFwgOeee5N4YB2C63H5hcBX+TX1EBpbhbwfgcWBZznAsPqVhlxhsABJDwLCfmMzcMMf/IWU0lHWNfRQXHDmlhONV0ejRKEIeL9ggY3RTdFtQUnzrCgZIM8w9SHTrT+zzUHXLMHL7V4SDkJcuLGHqqwxxCH26OTCBpwGYMkp5crTcThGR0KWwJPfNjlzN2sVQxJvc4Lz9M3Hs7zvzZR7gHwEh3/59/OQodt4hWasOq9byXYoAo+vIR/IpxgBMNiHmaL6Cnry6EZQLW8iXRJyZ4jDrpfQNCIxjdRy/Ie6F6mSBCZlSQpk4j2gkHjWBBAeROCVowaU7Z/QNlXx8zATFmEq/mK4lwBMdBoXMO2IcbQ9MCpYviC4RZzvhsqkgNKwiE6ogCrJ7c6DFgcL6A7hnjWyEu3ka90NEBxbwIRotPAkZjMPsGUVQhZvrjdDLojFtK8Sw6D9ish04CpQZMk/p1j2TEHU1lYwaMobq9ICuzOWQLmBdiy5XY2tKIwEmIYgJ+zOjHhKQnaWcEoOG4RhoUUWaYZiPk6m8Q7ThwL7Y/12GAbvIwFhnwDSrGLgWAeZYAxf5YpxWp5TxkAY0guQpt06K8Oc+f1G6UqC/Is53KnjhmtNaSswXaCKrmhL36WFWB70YrpiHanqoOsghCxA/HYqy97VEMkQgx+/FWayKl/2tD4zB3YHDiuKIxwdPfpaGPD7cYnyYdmnu+ZC7OEjen8dH1pEzhlj3BkJmAd2RYVSFzHQInP0VFPkPMk/JpzlhmWHZ2X0jXtfLVdpo5fD7UWaBhmE2vokwGxXjxho6LcQN0Ab7GfQ4ZZgK6xyxdznmX3CkbxAjVxzps3E8xdjteqy3bmGsv4Bwz1CpfVcqHRYjCJhtqNTutUqB0blKR7ek0lNaPE7aGSp17kqlpRwMOG2o1Om3Su2uVQp0Amrw7t06Q7kuGTVcachWJy9OmG3fwLB61UIXuA2HzduodLOZNo0Rt77UHTt17tcZZsHwBoKm04wZjBoGFbiRqLI6qGxTkHoyG4s3FDHuvLIFva9s04TobnRazujaqG1TkHqq0+5rW9vU4O2bTq0706nRQXWbgtRXnXZe3dq38BXPVYslu+ka08VleB4x+rLDT/QiDN8iUdUvZLjLBHgmPj2yjoUNU4+Ti4qlAS9XFEv5q6uqb9DdVrVStdPHJVzBUMDd/bWm6YVP8V4HD8JBQLLNL4qjQP6fpgRKj8+i+caEUO9N/H/EMGBwmb5GNDt5U3KfRrZAlhcZrdwEdHrvkGQJe5A8KPMS+/5e+lUBVAyxZutTlxhMS7VJxcLTuM3wcP7MDUnWhegyh26BLxvohDkVfF1tR5Kj5x7P4S6WYX889+izwqoYq8xWrkeZ/pWlnr7kGb0nscFeraoabBI7iuTJVOFEaKe2jyX2WuZum6MiT3YJ/yRHUnfVLLza7okHJTmU9qBLVQNOZc4zF1Mhkq1U+5Pl5tVey9IaWqd1CVrVpb67s2bqu7ednNrMVbEK2u7MNdTg/kF99PlVlPdHd9L2SiLlmauCsbHbImNAF8hXGvMauu5XLtqWw84XI/W67Y8F324Pe3Ga//QmSQHyny+Bp/8B</diagram></mxfile>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE svg
|
||||||
|
PUBLIC '-//W3C//DTD SVG 1.1//EN'
|
||||||
|
'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
|
||||||
|
<!-- Exported from clusterio_2.0_block_diagram.drawio with draw.io Desktop 32.4.1. Export settings: SVG, light theme, white background, width 1000. Native SVG labels: disable Formatted Text and Word Wrap in an export copy, preserving the title at font size 20. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="background: #ffffff; background-color: #ffffff; color-scheme: light;" version="1.1" width="1001px" height="1001px" viewBox="0 0 1001 1001" role="img" aria-labelledby="diagram-title diagram-description">
|
||||||
|
<desc id="diagram-description">A controller and two hosts run across two physical servers. Each host manages three Factorio servers. The hosts and web or command-line clients connect to the controller over WebSocket.</desc>
|
||||||
|
<title id="diagram-title">Clusterio 2.0 system architecture</title>
|
||||||
|
<defs/>
|
||||||
|
<rect fill="#ffffff" width="100%" height="100%" x="0" y="0" style="fill: rgb(255, 255, 255);"/>
|
||||||
|
<g>
|
||||||
|
<g data-cell-id="0">
|
||||||
|
<g data-cell-id="1">
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-59">
|
||||||
|
<g>
|
||||||
|
<rect x="0" y="0" width="1000" height="1000" fill="none" stroke="none" transform="rotate(90,500,500)" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-21">
|
||||||
|
<g>
|
||||||
|
<rect x="40" y="140" width="440" height="640" rx="26.4" ry="26.4" fill="#dae8fc" stroke="#6c8ebf" stroke-width="2" stroke-dasharray="6 6" pointer-events="all" style="fill: rgb(218, 232, 252); stroke: rgb(108, 142, 191);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-6">
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="180" width="220" height="60" fill="#0050ef" stroke="#001dbc" stroke-width="2" pointer-events="all" style="fill: rgb(0, 80, 239); stroke: rgb(0, 29, 188);"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
|
||||||
|
<text x="190" y="220">Controller Process</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-19">
|
||||||
|
<g>
|
||||||
|
<path d="M 120 400 L 120 280 L 120 252.74" fill="none" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(0, 110, 175);"/>
|
||||||
|
<path d="M 120 242.24 L 127 256.24 L 120 252.74 L 113 256.24 Z" fill="#006eaf" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="all" style="fill: rgb(0, 110, 175); stroke: rgb(0, 110, 175);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-26">
|
||||||
|
<g>
|
||||||
|
<path d="M 220 550 L 160 550 L 160 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-23">
|
||||||
|
<g>
|
||||||
|
<rect x="220" y="520" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
|
||||||
|
<text x="330" y="560">Factorio Server</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-29">
|
||||||
|
<g>
|
||||||
|
<path d="M 220 630 L 140 630 L 140 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-24">
|
||||||
|
<g>
|
||||||
|
<rect x="220" y="600" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
|
||||||
|
<text x="330" y="640">Factorio Server</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-28">
|
||||||
|
<g>
|
||||||
|
<path d="M 220 710 L 120 710 L 120 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-25">
|
||||||
|
<g>
|
||||||
|
<rect x="220" y="680" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
|
||||||
|
<text x="330" y="720">Factorio Server</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-35">
|
||||||
|
<g>
|
||||||
|
<rect x="520" y="360" width="440" height="420" rx="25.2" ry="25.2" fill="#dae8fc" stroke="#6c8ebf" stroke-width="2" stroke-dasharray="6 6" pointer-events="all" style="fill: rgb(218, 232, 252); stroke: rgb(108, 142, 191);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-44">
|
||||||
|
<g>
|
||||||
|
<path d="M 600 400 L 600 320 L 190 320 L 190 252.74" fill="none" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(0, 110, 175);"/>
|
||||||
|
<path d="M 190 242.24 L 197 256.24 L 190 252.74 L 183 256.24 Z" fill="#006eaf" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="all" style="fill: rgb(0, 110, 175); stroke: rgb(0, 110, 175);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-37">
|
||||||
|
<g>
|
||||||
|
<path d="M 700 550 L 640 550 L 640 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-38">
|
||||||
|
<g>
|
||||||
|
<rect x="700" y="520" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
|
||||||
|
<text x="810" y="560">Factorio Server</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-39">
|
||||||
|
<g>
|
||||||
|
<path d="M 700 630 L 620 630 L 620 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-40">
|
||||||
|
<g>
|
||||||
|
<rect x="700" y="600" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
|
||||||
|
<text x="810" y="640">Factorio Server</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-41">
|
||||||
|
<g>
|
||||||
|
<path d="M 700 710 L 600 710 L 600 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-42">
|
||||||
|
<g>
|
||||||
|
<rect x="700" y="680" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
|
||||||
|
<text x="810" y="720">Factorio Server</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-46">
|
||||||
|
<g>
|
||||||
|
<path d="M 560 210 L 312.74 210" fill="none" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(0, 110, 175);"/>
|
||||||
|
<path d="M 302.24 210 L 316.24 203 L 312.74 210 L 316.24 217 Z" fill="#006eaf" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="all" style="fill: rgb(0, 110, 175); stroke: rgb(0, 110, 175);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-45">
|
||||||
|
<g>
|
||||||
|
<rect x="560" y="180" width="320" height="60" fill="#1ba1e2" stroke="#006eaf" stroke-width="2" pointer-events="all" style="fill: rgb(27, 161, 226); stroke: rgb(0, 110, 175);"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
|
||||||
|
<text x="720" y="220">Web Interface / clusterioctl</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-49">
|
||||||
|
<g>
|
||||||
|
<rect x="60" y="40" width="580" height="60" fill="none" stroke="none" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#000000" font-family="Helvetica" text-anchor="start" font-size="40px" style="fill: rgb(0, 0, 0);">
|
||||||
|
<text x="64" y="88">Clusterio 2.0 Block Diagram</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-51">
|
||||||
|
<g>
|
||||||
|
<rect x="352" y="860" width="100" height="40" rx="8" ry="8" fill="#dae8fc" stroke="#6c8ebf" stroke-width="2" stroke-dasharray="6 6" pointer-events="all" style="fill: rgb(218, 232, 252); stroke: rgb(108, 142, 191);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-52">
|
||||||
|
<g>
|
||||||
|
<rect x="60" y="860" width="220" height="40" fill="none" stroke="none" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#000000" font-family="Helvetica" text-anchor="start" font-size="24px" style="fill: rgb(0, 0, 0);">
|
||||||
|
<text x="64" y="890">Physical Server</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-54">
|
||||||
|
<g>
|
||||||
|
<path d="M 836 880 L 923.26 880" fill="none" stroke="#001dbc" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(0, 29, 188);"/>
|
||||||
|
<path d="M 933.76 880 L 919.76 887 L 923.26 880 L 919.76 873 Z" fill="#001dbc" stroke="#001dbc" stroke-width="2" stroke-miterlimit="10" pointer-events="all" style="fill: rgb(0, 29, 188); stroke: rgb(0, 29, 188);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-55">
|
||||||
|
<g>
|
||||||
|
<rect x="544" y="860" width="260" height="40" fill="none" stroke="none" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#000000" font-family="Helvetica" text-anchor="start" font-size="24px" style="fill: rgb(0, 0, 0);">
|
||||||
|
<text x="548" y="890">WebSocket connection</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-56">
|
||||||
|
<g>
|
||||||
|
<rect x="352" y="920" width="100" height="40" fill="#0050ef" stroke="#001dbc" stroke-width="2" pointer-events="all" style="fill: rgb(0, 80, 239); stroke: rgb(0, 29, 188);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-57">
|
||||||
|
<g>
|
||||||
|
<rect x="60" y="920" width="192" height="40" fill="none" stroke="none" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#000000" font-family="Helvetica" text-anchor="start" font-size="24px" style="fill: rgb(0, 0, 0);">
|
||||||
|
<text x="64" y="950">Node.js Process</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-36">
|
||||||
|
<g>
|
||||||
|
<rect x="560" y="400" width="220" height="60" fill="#0050ef" stroke="#001dbc" stroke-width="2" pointer-events="all" style="fill: rgb(0, 80, 239); stroke: rgb(0, 29, 188);"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
|
||||||
|
<text x="670" y="440">Host Process</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g data-cell-id="5kRB54Jy1bSgiNgllITD-9">
|
||||||
|
<g>
|
||||||
|
<rect x="80" y="400" width="220" height="60" fill="#0050ef" stroke="#001dbc" stroke-width="2" pointer-events="all" style="fill: rgb(0, 80, 239); stroke: rgb(0, 29, 188);"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
|
||||||
|
<text x="190" y="440">Host Process</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
+43
-21
@@ -1,36 +1,58 @@
|
|||||||
# Communication Layer
|
# Communication Layer
|
||||||
|
|
||||||
<sub>This document describes the communication layer in Clusterio and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
|
Clusterio uses links, connectors, and message classes to communicate between controllers, hosts, instances, and control clients.
|
||||||
|
A link sends and receives messages through a connector, while message classes define the requests and events that can be exchanged.
|
||||||
Communication is facilitated by the lib/link library and is based on three core abstractions: links, connectors and messages.
|
See the [system overview](how-it-works.md#system-overview) for a diagram of the processes and their connections, and the [protocol reference](devs/protocol.md) for wire formats and session flow.
|
||||||
Links represents one side of a two way communication pipe, connectors deal with connecting and establishing communication, and messages are the content sent over the link.
|
|
||||||
|
|
||||||
|
|
||||||
## Link
|
## Links and routing
|
||||||
|
|
||||||
The Link class represents one side of a two way communication channel where the actual communication is done by a connector associated with the link.
|
[Link](../packages/lib/src/link/link.ts) handles request/response matching, event dispatch, message registration, and routing through an attached router.
|
||||||
The link also knows what the endpoint types it's connected to are via the source and target attributes.
|
Its connector identifies the local and remote endpoints through `connector.src` and `connector.dst`, both `Address` objects.
|
||||||
|
|
||||||
The endpoint types currently implemented are controller, host, instance, and control.
|
The controller's `WsServer` stores one link for each host or control connection.
|
||||||
Although host and instances run in the same Node.js program they use virtual links to communicate between them in order to simplify the communication architecture.
|
On a host, the `Host` object is a link to the controller, and each `Instance` is linked to a corresponding `InstanceConnection` stored on the host.
|
||||||
|
The command-line client's `Ctl` object and the web interface's `Control` object each act as a link to the controller.
|
||||||
|
|
||||||
Upon creation the link will register with the connector in order to receive and process messages from it.
|
Requests can pass through several links before reaching their destination.
|
||||||
The messages received are validated using the validator registered for the type of the message.
|
Each receiving link checks the message name, allowed endpoint types, and source address before routing it.
|
||||||
A message for which there is no validator for is considered an error.
|
The controller checks the user's permissions before validating the message data; permission callbacks therefore receive untrusted data.
|
||||||
|
See [message permissions](writing-plugins.md#static-permission).
|
||||||
|
|
||||||
|
At the destination, the registered schema validates the payload and `fromJSON` restores the message before its handler runs.
|
||||||
|
|
||||||
|
Routing can perform work before the destination handler runs.
|
||||||
|
In particular, the [host router](../packages/host/src/Host.ts) can start or wake an instance for messages marked as instance-starting.
|
||||||
|
|
||||||
|
|
||||||
## Connector
|
## Connectors and sessions
|
||||||
|
|
||||||
The connector used with a link is responsible for establishing and/or maintain the connection that messages are sent over.
|
[Connectors](../packages/lib/src/link/connectors.ts) transport messages, assign sequence numbers, and deliver incoming messages to links.
|
||||||
Reconnect logic is expected to be implemented in the connector, though this has not yet been done.
|
WebSocket connectors connect hosts and control clients to the controller and validate incoming message structures, including their type and addresses.
|
||||||
|
Hosts and instances share a Node.js process and communicate in memory through paired `VirtualConnector` objects.
|
||||||
|
|
||||||
There's also the virtual connector which lets two links in the same program be connected to each other without having to establish a network connection.
|
The protocol reference describes [connection and recovery behavior](devs/protocol.md#connection-flow), [connector states and events](devs/protocol.md#connector-flow), and [graceful disconnects](devs/protocol.md#graceful-disconnect).
|
||||||
|
For handling these events in a plugin, including queueing work and preparing for disconnection, see [connection handling](writing-plugins.md#handling-connection-events).
|
||||||
|
|
||||||
|
|
||||||
## Message
|
## Message definitions and registration
|
||||||
|
|
||||||
There's two types of messages currently implemented apart from the Link builtin close message and that's Request and Event.
|
Core request and event classes live under [packages/lib/src/data/](../packages/lib/src/data/).
|
||||||
The builtin messages are defined in [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
|
Their static `type`, `src`, and `dst` properties specify the message type and allowed endpoint types, while their schema and permission fields define the payload and access rules.
|
||||||
|
Plugin classes also supply a `plugin` name.
|
||||||
|
|
||||||
The messages define which links they are valid on and only attaches handlers for those links.
|
`Link.register` registers request and event classes and compiles their payload validators.
|
||||||
There's also a forwarding mechanism for when a request or event is to be forwarded another hop.
|
Core classes are registered by the link module; `registerPluginMessages` registers each plugin's `messages` array.
|
||||||
|
It also registers the plugin's `errors` array with `Link.registerError`, so an error thrown by a request handler is rejected on the requester as the same class.
|
||||||
|
Incoming messages invoke handlers attached with `link.handle(MessageClass, handler)`.
|
||||||
|
Plugins attach these handlers from their entrypoint functions rather than relying on method-name conventions.
|
||||||
|
|
||||||
|
Send a message with `link.sendTo(destination, new MessageClass(...))`.
|
||||||
|
Requests return a promise for the response; events do not return a handler result.
|
||||||
|
Use broadcast destinations only for events.
|
||||||
|
When using a request's source as a later event destination, remove its request ID with `Address.withoutRequestId()`.
|
||||||
|
See [writing plugins](writing-plugins.md#defining-link-messages) for a complete example.
|
||||||
|
|
||||||
|
When changing a wire message structure in [messages_core.ts](../packages/lib/src/data/messages_core.ts), rebuild the packages with `pnpm -r run prepare` to regenerate the standalone validator.
|
||||||
|
When changing a request or event class, keep serialization, allowed endpoints, permission checks, registration, and handlers consistent.
|
||||||
|
The [contributor guide](contributing.md#checking-your-code-for-errors) explains how to run focused tests.
|
||||||
+23
-8
@@ -3,18 +3,18 @@
|
|||||||
<sub>This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
|
<sub>This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
|
||||||
|
|
||||||
Clusterio uses a field based configuration system to manage settings for the controller, instances, hosts, and the clusterioctl utility.
|
Clusterio uses a field based configuration system to manage settings for the controller, instances, hosts, and the clusterioctl utility.
|
||||||
Plugins can add their own config fields for the controller, host and instance configs.
|
Plugins can add their own config fields for the controller, host, instance, and ctl configs.
|
||||||
The fields are defined early in the startup, and fields for disabled plugins will still be created.
|
The fields are defined early in the startup, and fields for disabled plugins will still be created.
|
||||||
|
|
||||||
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields`, `hostConfigFields` and `instanceConfigFields` properties of the `plugin` export.
|
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins add fields through `controllerConfigFields`, `hostConfigFields`, `instanceConfigFields`, and `ctlConfigFields` on the `plugin` export.
|
||||||
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
|
Controller, host, and instance configs also receive `<plugin_name>.load_plugin` to enable/disable loading on startup and a hidden `<plugin_name>.version` for migrations.
|
||||||
|
|
||||||
|
|
||||||
## Defing Fields
|
## Defining Fields
|
||||||
|
|
||||||
Fields are defined as POJS object properties where the name of the property correspond to the name of the configuration field.
|
Fields are defined as POJS object properties where the name of the property correspond to the name of the configuration field.
|
||||||
The property name is the primary way the field is accessed both in code and on the command line, as such it should follow the lower\_case\_underscore style and consist of a group name and a field name separated by a dot.
|
The property name is the primary way the field is accessed both in code and on the command line, as such it should follow the lower\_case\_underscore style and consist of a group name and a field name separated by a dot.
|
||||||
For plugins the group name is the name of the plugin, e.g., the `level` field of the `foo_frobber` plugin would be named `foo_frobber.lavel`.
|
For plugins the group name is the name of the plugin, e.g., the `level` field of the `foo_frobber` plugin would be named `foo_frobber.level`.
|
||||||
|
|
||||||
**access**:
|
**access**:
|
||||||
The locations this config field can be read and modified from.
|
The locations this config field can be read and modified from.
|
||||||
@@ -44,7 +44,7 @@ For plugins the group name is the name of the plugin, e.g., the `level` field of
|
|||||||
|
|
||||||
**initialValue** (optional if optional is true):
|
**initialValue** (optional if optional is true):
|
||||||
Value this config field should take in a newly initialized config.
|
Value this config field should take in a newly initialized config.
|
||||||
This can also be an async function returning the value to use.
|
This can also be a synchronous function returning the value to use.
|
||||||
|
|
||||||
**validator** (optional):
|
**validator** (optional):
|
||||||
Function called with the value being set and the config it belongs to.
|
Function called with the value being set and the config it belongs to.
|
||||||
@@ -83,7 +83,22 @@ For plugins the group name is the name of the plugin, e.g., the `level` field of
|
|||||||
|
|
||||||
**inputComponent** (optional):
|
**inputComponent** (optional):
|
||||||
Name of the input component the web interface should render for this field instead of the default for its type.
|
Name of the input component the web interface should render for this field instead of the default for its type.
|
||||||
Plugins can add their own components through `inputComponents` on their web plugin class.
|
Plugins register components with the `control.hooks.inputComponents` hook in their web entrypoint.
|
||||||
|
The hook returns an object whose keys match the field's `inputComponent` value.
|
||||||
|
|
||||||
|
For example, with `inputComponent: "foo_frobber_level"` on a field:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import LevelInput from "./LevelInput.js";
|
||||||
|
|
||||||
|
export default async function({ control, plugin }) {
|
||||||
|
control.hooks.inputComponents.attach(plugin.name, () => ({
|
||||||
|
foo_frobber_level: LevelInput,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`LevelInput` must implement the `InputComponent` contract exported by `@clusterio/web_ui`.
|
||||||
|
|
||||||
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
|
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
|
||||||
|
|
||||||
@@ -101,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
|
||||||
|
|||||||
+52
-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.
|
||||||
@@ -606,6 +636,23 @@ Like factorio.enable_save_patching, plugins that need script commands can't be l
|
|||||||
Defaults to true.
|
Defaults to true.
|
||||||
|
|
||||||
|
|
||||||
|
### factorio.enable_lua_udp
|
||||||
|
|
||||||
|
Enable UDP messaging between the host and the Factorio server by passing `--enable-lua-udp` to Factorio.
|
||||||
|
This lets plugins send UDP packets to the server and receive data sent with `clusterio_api.send_udp` in-game.
|
||||||
|
Requires Factorio 2.1.10 or later, earlier versions crash when a packet is received on a headless server.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
|
|
||||||
|
### factorio.lua_udp_port
|
||||||
|
|
||||||
|
UDP port on localhost the Factorio server receives Lua UDP messages on when `factorio.enable_lua_udp` is enabled.
|
||||||
|
If null a random port in the dynamic range is assigned each time the instance starts.
|
||||||
|
|
||||||
|
Defaults to null.
|
||||||
|
|
||||||
|
|
||||||
### factorio.enable_whitelist
|
### factorio.enable_whitelist
|
||||||
|
|
||||||
Turn on the whitelist on the server.
|
Turn on the whitelist on the server.
|
||||||
@@ -627,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.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+168
-33
@@ -3,23 +3,28 @@
|
|||||||
You are probably reading this because you want to contribute code to Clusterio.
|
You are probably reading this because you want to contribute code to Clusterio.
|
||||||
Great! We need people like you.
|
Great! We need people like you.
|
||||||
Though we have some conventions and workflows that we kindly ask you to follow.
|
Though we have some conventions and workflows that we kindly ask you to follow.
|
||||||
We have included all the pratical considerations in this document.
|
This document covers the development workflow, commands, and repository layout.
|
||||||
Although, we recomend checking out out our [past decisions](/docs/decisions.md) if you have the time.
|
Read our [past decisions](/docs/decisions.md) for compatibility policies and design rationale.
|
||||||
|
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
|
- [Project map](#project-map)
|
||||||
- [Workflow](#workflow)
|
- [Workflow](#workflow)
|
||||||
- [Starting a Feature Branch](#starting-a-feature-branch)
|
- [Starting a Feature Branch](#starting-a-feature-branch)
|
||||||
- [Setting up the Development Environment](#setting-up-the-development-environment)
|
- [Setting up the Development Environment](#setting-up-the-development-environment)
|
||||||
|
- [Build outputs](#build-outputs)
|
||||||
|
- [Debugging](#debugging)
|
||||||
- [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)
|
||||||
- [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)
|
||||||
- [Publishing releases to npm](#publishing-releases-to-npm)
|
- [Publishing releases to npm](#publishing-releases-to-npm)
|
||||||
- [Changelog](#changelog)
|
- [Changelog](#changelog)
|
||||||
- [Supported Node.js and Factorio Version](#supported-node.js-and-factorio-version)
|
- [Supported Node.js and Factorio Versions](#supported-nodejs-and-factorio-versions)
|
||||||
- [Code Style](#code-style)
|
- [Code Style](#code-style)
|
||||||
- [Naming Style](#naming-style)
|
- [Naming Style](#naming-style)
|
||||||
- [Units](#units)
|
- [Units](#units)
|
||||||
@@ -28,6 +33,28 @@ Although, we recomend checking out out our [past decisions](/docs/decisions.md)
|
|||||||
- [Markdown](#markdown)
|
- [Markdown](#markdown)
|
||||||
|
|
||||||
|
|
||||||
|
## Project map
|
||||||
|
|
||||||
|
Clusterio manages distributed Factorio servers through a controller, hosts, and plugins.
|
||||||
|
|
||||||
|
| Location | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `packages/controller/` | Cluster state, users and roles, connections, HTTP API, and serving the web UI. Start with `src/Controller.ts` and `src/ControlConnection.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/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 Vite build. |
|
||||||
|
| `packages/create/` | Installer and JavaScript/TypeScript plugin templates. |
|
||||||
|
| `plugins/` | Bundled plugins, including their Node, web, Lua, and test code. |
|
||||||
|
| `external_plugins/` | Plugins developed in independent repositories and included in the local workspace. |
|
||||||
|
| `packages/host/modules/` | Lua modules injected into saves. |
|
||||||
|
| `packages/host/lua/` | Factorio mod sources, including `clusterio_lib`. |
|
||||||
|
| `test/` | Root test suite, fixtures, helpers, integration harness, and manual tests. Plugin tests also live under `plugins/*/test/`. |
|
||||||
|
|
||||||
|
Start with [how it works](how-it-works.md), [communication](communication.md), and the [config system](config-system.md) for architecture.
|
||||||
|
See [writing plugins](writing-plugins.md) and [developing for Clusterio](developing-for-clusterio.md) for plugin and Factorio contracts.
|
||||||
|
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
While we don't require you to follow this workflow, it makes things much simpler for both you and us if you choose to do so.
|
While we don't require you to follow this workflow, it makes things much simpler for both you and us if you choose to do so.
|
||||||
@@ -52,16 +79,20 @@ Do not run `git pull upstream` it will mess thing up!
|
|||||||
|
|
||||||
### Setting up the Development Environment
|
### Setting up the Development Environment
|
||||||
|
|
||||||
The project is organized into several packages that are all tied together using pnpm's workspace feature.
|
Run commands from the repository root unless stated otherwise.
|
||||||
In order to get the development environment up and running you will need to run the following command:
|
Use the latest Node.js LTS release and the pnpm version pinned by `packageManager` in root `package.json`.
|
||||||
|
Install dependencies with:
|
||||||
|
|
||||||
pnpm install
|
pnpm install --no-frozen-lockfile
|
||||||
|
|
||||||
This installs dependencies needed by tests and links the packages up so they work from the git work tree.
|
This installs dependencies and builds the packages.
|
||||||
To start a cluster from the repo you will first need to initialize it, this can be done using the installer:
|
`pnpm-lock.yaml` is intentionally ignored; do not force-add it or assume a frozen-lockfile install is supported.
|
||||||
|
For a fresh development cluster, run the installer and select standalone mode to create `config-controller.json`, `config-host.json`, and the credentials needed to connect them:
|
||||||
|
|
||||||
node packages/create --dev
|
node packages/create --dev
|
||||||
|
|
||||||
|
You can skip this step if you already have a working controller and host configuration.
|
||||||
|
|
||||||
To add plugins to use in the development environment use
|
To add plugins to use in the development environment use
|
||||||
|
|
||||||
node packages/ctl plugin add ./path/to/plugin
|
node packages/ctl plugin add ./path/to/plugin
|
||||||
@@ -79,29 +110,60 @@ By default node does not enable source maps, to get stack traces pointing to the
|
|||||||
|
|
||||||
Note that to use `clusterioctl` you will have to create a ctl config first:
|
Note that to use `clusterioctl` you will have to create a ctl config first:
|
||||||
|
|
||||||
node packages/controller bootstrap create-ctl-config [admin-user]
|
node packages/controller bootstrap create-ctl-config <admin-user>
|
||||||
|
|
||||||
Once you've set up the cluster you can use `node packages/controller run` to run the controller and `node packages/host run` to run the host which connects to the controller.
|
Use the name of an existing admin user.
|
||||||
|
Run `node --enable-source-maps packages/controller run` and `node --enable-source-maps packages/host run` in separate terminals.
|
||||||
|
|
||||||
When editing TypeScript files ran by Node.js you need to run `pnpm build` to compile the files to JavaScript before restarting the controller/host/ctl for changes take effect.
|
When editing TypeScript files ran by Node.js you need to run `pnpm build` to compile the files to JavaScript before restarting the controller/host/ctl for changes take effect.
|
||||||
To have TypeScript watch for changes and rebuild immediadly you may run `pnpm watch` instead.
|
To have TypeScript watch for changes and rebuild immediadly you may run `pnpm watch` instead.
|
||||||
|
|
||||||
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 run -r --if-present prepare` before you start the controller without these flags again.
|
|
||||||
|
|
||||||
|
### Build outputs
|
||||||
|
|
||||||
|
| Command | Output |
|
||||||
|
| --- | --- |
|
||||||
|
| `pnpm build` | Compiled JavaScript and type declarations in each package's `dist/node/` and, where applicable, `dist/browser/`. |
|
||||||
|
| `pnpm watch` | TypeScript compilation as files change; restart affected Node processes afterward. |
|
||||||
|
| `pnpm -r run prepare` | Compiled packages, generated validators, and production web bundles. |
|
||||||
|
| `pnpm build-mod` | Factorio mod archives in root `dist/`. |
|
||||||
|
|
||||||
|
`pnpm 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.
|
||||||
|
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
Establish the controller, host, instance, Factorio version, enabled plugins, and exact reproduction first.
|
||||||
|
Enable source maps and confirm the affected process is running rebuilt code.
|
||||||
|
Use the contextual logger so host, instance, and plugin metadata remain available.
|
||||||
|
Development logs are in `logs/`; integration logs are in `temp/test/logs/` and are uploaded by CI.
|
||||||
|
|
||||||
|
For a failing request or event, start with its handler and the input that triggers the failure.
|
||||||
|
Handlers are registered with `handle(MessageClass, handler)`; searching for the message class will help you find its handler.
|
||||||
|
Inspect the input and the state used by the failing operation, then try to reproduce the failure in a [focused test](#checking-your-code-for-errors).
|
||||||
|
If the handler is never reached, investigate message registration, routing, and permission checks using the [communication guide](communication.md).
|
||||||
|
For errors reported by Factorio, start with the Lua stack trace and the module or mod it identifies; the [developing-for-Clusterio guide](developing-for-clusterio.md) explains how that code communicates with the host.
|
||||||
|
|
||||||
|
For connection and recovery bugs, test disconnect, reconnect, timeout, and shutdown as applicable.
|
||||||
|
A cached controller status does not prove the current state of a host or Factorio process.
|
||||||
|
Recovery mode disables normal plugin loading; account for it when diagnosing missing plugin behavior and [Lua state migrations](developing-for-clusterio.md#initialisation-and-recovery).
|
||||||
|
Verify UI behavior in the browser: successful compilation does not verify rendering, permissions, or subscriptions.
|
||||||
|
|
||||||
|
|
||||||
### Starting a Feature Branch
|
### Starting a Feature Branch
|
||||||
|
|
||||||
Development is done exclusively in feature branches.
|
Development is done exclusively in feature branches.
|
||||||
This means the first thing to do before starting any development at all is to make a new branch starting from the master branch of the main repository.
|
To start a new feature branch, first check out master from the main repository.
|
||||||
To do this check out that branch from the main repository.
|
|
||||||
|
|
||||||
git fetch upstream
|
git fetch upstream
|
||||||
git checkout upstream/master
|
git checkout upstream/master
|
||||||
@@ -117,18 +179,61 @@ But it's nice to have its name be somewhat descriptive of what it implements.
|
|||||||
|
|
||||||
### Checking your code for errors
|
### Checking your code for errors
|
||||||
|
|
||||||
There's automated tests and linting setup for the project.
|
Tests use Mocha and Node assertions, mostly as JavaScript ESM importing compiled packages.
|
||||||
Please do add tests for any code you add and run both:
|
Run the test suite and linter from the repository root:
|
||||||
|
|
||||||
pnpm run test
|
pnpm run test
|
||||||
pnpm run lint
|
pnpm run lint
|
||||||
|
|
||||||
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
|
`pnpm test` builds TypeScript and runs root and bundled-plugin tests, excluding `test/manual/`.
|
||||||
|
Run tests from the root: individual package `test` scripts are often placeholders that deliberately fail.
|
||||||
|
Use `pnpm cover` for the full suite with c8 coverage.
|
||||||
|
|
||||||
The full test suite can take a while to run.
|
The full test suite can take a while to run.
|
||||||
Use `pnpm run fast-test` to skip tests marked as slow, or `pnpm run no-external-test` to skip tests that depend on external APIs such as the Factorio mod portal or GitHub.
|
Use `pnpm run fast-test` to skip tests marked as slow, or `pnpm run no-external-test` to skip tests that depend on external APIs such as the Factorio mod portal or GitHub.
|
||||||
These correspond to setting the `FAST_TEST` and `NO_EXTERNAL_TEST` environment variables and can be combined.
|
These correspond to setting the `FAST_TEST` and `NO_EXTERNAL_TEST` environment variables and can be combined.
|
||||||
When writing tests that take a lot of time call `slowTest(this)` at the start of the test, and for tests that call out to external APIs call `externalTest(this)`, both are exported from `test/integration/index.js`.
|
For example, `FAST_TEST=y NO_EXTERNAL_TEST=y pnpm test` combines both exclusions.
|
||||||
|
These flags still run the remaining integration setup and tests; neither produces an isolated unit-test run.
|
||||||
|
When writing slow tests call `slowTest(this)`, and for external API tests call `externalTest(this)`; both are exported from `test/common.js`.
|
||||||
|
For tests that need a Factorio installation, call `requiresFactorio(this)` in the test or pass the suite to `requiresFactorio`, imported from `test/integration/index.js`.
|
||||||
|
`test/common.js` also provides JSON round-trip/schema helpers, and `test/mock.js` provides shared mocks.
|
||||||
|
|
||||||
|
For a focused test, build first and select the file explicitly instead of appending it to the full-suite script:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm build
|
||||||
|
NODE_EXTRA_CA_CERTS="$PWD/test/file/tls/cert.pem" pnpm exec mocha \
|
||||||
|
--enable-source-maps --check-leaks test/lib/helpers.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the example file with the relevant test; use `--grep` to narrow it further.
|
||||||
|
Preserve the test TLS CA configuration for HTTPS integration tests.
|
||||||
|
Use `--require ./test/setup_logging.js` when an isolated test needs the shared logger configured.
|
||||||
|
Some tests import `test/integration/index.js`, which installs root hooks and starts a controller and host even for a targeted invocation.
|
||||||
|
|
||||||
|
The integration harness recreates its configs, database, mods, and instances under `temp/test/` and uses HTTP port 8880 and HTTPS port 4443.
|
||||||
|
Do not run competing integration suites in the same checkout or on those ports.
|
||||||
|
It locates Factorio through `host.factorio_directory` in root `config-host.json`, defaulting to `factorio/`.
|
||||||
|
The full harness currently requires a usable Factorio installation: a missing directory fails discovery, and an empty directory fails setup while selecting a version.
|
||||||
|
Some individual tests have conditional skips, but these do not make a full run without Factorio succeed.
|
||||||
|
|
||||||
|
Use the relevant production bundle, mod build, or [manual scenario](../test/manual/README.md) for changes affecting those surfaces.
|
||||||
|
For documentation changes, check that links, paths, and example commands are correct.
|
||||||
|
|
||||||
|
|
||||||
|
### Cleaning generated and runtime files
|
||||||
|
|
||||||
|
Inspect `node clean.js --help` and preview the selected scope with `--dry-run` before cleaning.
|
||||||
|
|
||||||
|
| Command | Removal scope |
|
||||||
|
| --- | --- |
|
||||||
|
| `node clean.js --dry-run` | Preview workspace package/plugin build output and Vite dependency caches; omit `--dry-run` to clean, or use `pnpm clean`. |
|
||||||
|
| `node clean.js tests --dry-run` | Also preview root `.nyc_output/`, `coverage/`, the whole `temp/` directory, and `static/`. |
|
||||||
|
| `node clean.js all --dry-run` | Also preview runtime databases, instances, logs, mods, local configs, `FactorioAdminToken.txt`, and `plugin-list.json`. |
|
||||||
|
|
||||||
|
`all` deletes cluster runtime data, including configurations and saves; back up anything you want to keep before using it.
|
||||||
|
The cleaner excludes `external_plugins/` and does not remove mod archives in root `dist/`.
|
||||||
|
Do not commit local configs, tokens, databases, saves, logs, or generated build output.
|
||||||
|
|
||||||
|
|
||||||
### Submitting a Pull Request
|
### Submitting a Pull Request
|
||||||
@@ -145,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.
|
||||||
@@ -200,12 +336,14 @@ The format of this log should be self explanatory, please follow it carefully. P
|
|||||||
|
|
||||||
## Supported Node.js and Factorio Versions
|
## Supported Node.js and Factorio Versions
|
||||||
|
|
||||||
Clusterio run on Node.js v18 and up, make sure to check that the Node builtin API's and npm packages you use are supported on these versions.
|
For development, use the latest Node.js LTS release, following the [installation instructions](../README.md#installation).
|
||||||
The GitHub Actions tests are run on whatever version of Node.js v18 and v20 is the default.
|
Make sure the Node.js built-in APIs and npm packages you use remain compatible with the supported versions.
|
||||||
|
The GitHub Actions tests currently run on Node.js 24 and 26 across the Factorio matrix, with Node.js 22 used for coverage; see the [CI workflow](../.github/workflows/node.js.yml) for the version matrix.
|
||||||
|
|
||||||
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up, including the latest experimental release.
|
For Factorio, active support covers the latest stable version and the previous major version, using version lines such as 2.1, 2.0 and 1.1 as described in the [Factorio support policy](decisions.md#factorio).
|
||||||
It's recommended that you use the lua API reference for 0.17.69, as there's no information on what version Factorio API's were introduced in.
|
Older versions from 0.17 onwards receive passive support where it does not restrict actively supported versions.
|
||||||
The GitHub Actions tests are run against the latest experimental release.
|
Check the Lua API documentation for the Factorio versions your changes target and preserve compatibility with the actively supported versions.
|
||||||
|
The GitHub Actions tests use pinned Factorio releases; see the [CI workflow](../.github/workflows/node.js.yml) for the current version matrix.
|
||||||
|
|
||||||
Check out all our of decisions for [node, factorio, and operation support](/docs/decisions.md#supportability).
|
Check out all our of decisions for [node, factorio, and operation support](/docs/decisions.md#supportability).
|
||||||
|
|
||||||
@@ -226,13 +364,10 @@ Fields in configs uses lowercase_underscore.
|
|||||||
JavaScript files are named using lowercase_underscore for files that export multiple items.
|
JavaScript files are named using lowercase_underscore for files that export multiple items.
|
||||||
Files containing and exporting a single class should be named the same as the class in PascalCase.
|
Files containing and exporting a single class should be named the same as the class in PascalCase.
|
||||||
|
|
||||||
Relative imports in lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
|
Use ESM imports, including `.js` extensions in TypeScript relative imports.
|
||||||
E.g.:
|
For namespace imports within lib, use the `lib` prefix to distinguish generic module names, for example `import * as libFileOps from "./file_ops.js";`.
|
||||||
|
Production consumers should import shared public APIs through `@clusterio/lib` rather than reaching into its internals.
|
||||||
import libLink from "./link";
|
When adding public shared exports, check both `packages/lib/index.ts` and `packages/lib/browser.ts` and keep Node-only dependencies out of browser code.
|
||||||
import libLuaTools from "./lua_tools";
|
|
||||||
|
|
||||||
Note that no sub-modules of the lib package is imported directly from the outside of it with the exception of `build_mod`.
|
|
||||||
|
|
||||||
For lua code lowercase_underscore is used for everything.
|
For lua code lowercase_underscore is used for everything.
|
||||||
|
|
||||||
@@ -241,7 +376,7 @@ For lua code lowercase_underscore is used for everything.
|
|||||||
When naming variables whos unit is not a base SI unit like seconds or metres the unit used should be suffixed in the variable name:
|
When naming variables whos unit is not a base SI unit like seconds or metres the unit used should be suffixed in the variable name:
|
||||||
E.g.:
|
E.g.:
|
||||||
|
|
||||||
const startTimeMs = date.now();
|
const startTimeMs = Date.now();
|
||||||
const endNs = process.hrtime();
|
const endNs = process.hrtime();
|
||||||
|
|
||||||
This makes it easier to spot different unit bases are being mixed in the code.
|
This makes it easier to spot different unit bases are being mixed in the code.
|
||||||
@@ -282,7 +417,7 @@ For example:
|
|||||||
### Markdown
|
### Markdown
|
||||||
|
|
||||||
Markdown files are formatted using one line per sentence to improve diff reports when making changes.
|
Markdown files are formatted using one line per sentence to improve diff reports when making changes.
|
||||||
Headings have two spaces before them and on space after them to make them stand out, the heading at the top of the file does not have any spaces before it, and heading immediadly followed by another heading have one space between them.
|
Headings have two blank lines before them and one after them; the top heading has no blank lines before it, and consecutive headings have one blank line between them.
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
```md
|
```md
|
||||||
|
|||||||
+27
-18
@@ -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
|
||||||
@@ -452,11 +459,13 @@ While we aim to increase the number of useful checks performed by CI in the futu
|
|||||||
|
|
||||||
🧾 Time Immemorial
|
🧾 Time Immemorial
|
||||||
|
|
||||||
> We do not use any CD actions, and have no plans of implementing them.
|
> We publish packages and mods when a maintainer publishes a stable GitHub release.
|
||||||
|
|
||||||
Unlike CI, we do not find continuous deployment useful at our current stage of the project.
|
Changes merged into the main branch are bundled into releases before publication.
|
||||||
This is due to the frequent breaking changes merged into the main branch, which we prefer to bundle together as a single version.
|
Publishing a stable GitHub release triggers the [Publish workflow](../.github/workflows/publish.yml), which publishes npm packages and Factorio mods.
|
||||||
In the future, we may automate the deployment process, but it will always require manual activation.
|
Publishing a prerelease or pushing a tag alone does not trigger publication.
|
||||||
|
Running clusters are updated by their operators.
|
||||||
|
See the [release workflow](release-workflow.md) for the publication procedure.
|
||||||
|
|
||||||
|
|
||||||
### Mocha
|
### Mocha
|
||||||
@@ -475,5 +484,5 @@ However, we are always looking for a better framework which is worth the effort
|
|||||||
|
|
||||||
> We perform all tests through GitHub actions using Ubuntu latest, using all supported versions of node and all supported versions of factorio.
|
> We perform all tests through GitHub actions using Ubuntu latest, using all supported versions of node and all supported versions of factorio.
|
||||||
|
|
||||||
We perform tests on all versions of NodeJS and Factorio that we support.
|
The [current CI workflow](../.github/workflows/node.js.yml) tests selected Node.js major versions and pinned Factorio releases.
|
||||||
However, we do not conduct tests on all supported operating systems, as the added demand on our testing resources would be significant, and the value it would provide for a cross-platform engine like NodeJS would be limited.
|
However, we do not conduct tests on all supported operating systems, as the added demand on our testing resources would be significant, and the value it would provide for a cross-platform engine like NodeJS would be limited.
|
||||||
@@ -10,7 +10,9 @@ This document describes the various different types of content that can be creat
|
|||||||
- [event_handler interface](#event_handler-interface)
|
- [event_handler interface](#event_handler-interface)
|
||||||
- [Communicating with Clusterio](#communicating-with-clusterio)
|
- [Communicating with Clusterio](#communicating-with-clusterio)
|
||||||
- [Clusterio Modules](#clusterio-modules)
|
- [Clusterio Modules](#clusterio-modules)
|
||||||
|
- [Initialisation and recovery](#initialisation-and-recovery)
|
||||||
- [Clusterio Lua API](#clusterio-lua-api)
|
- [Clusterio Lua API](#clusterio-lua-api)
|
||||||
|
- [Compatibility library](#compatibility-library)
|
||||||
- [Clusterio Plugins](#clusterio-plugins)
|
- [Clusterio Plugins](#clusterio-plugins)
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ The following entries are recognized by `add_lib`, all of which are optional:
|
|||||||
Callback called when the callback of `script.on_configuration_changed` is invoked.
|
Callback called when the callback of `script.on_configuration_changed` is invoked.
|
||||||
- `events`:
|
- `events`:
|
||||||
Table mapping event ids as defined in `defines.events` to callbacks.
|
Table mapping event ids as defined in `defines.events` to callbacks.
|
||||||
For example the table `{ [defines.events.on_player_died] = foo }` will call bar with the usual `event` table as argument every time a player dies.
|
For example the table `{ [defines.events.on_player_died] = foo }` will call foo with the usual `event` table as argument every time a player dies.
|
||||||
- `on_nth_tick`:
|
- `on_nth_tick`:
|
||||||
Table mapping the nth tick number to a callback.
|
Table mapping the nth tick number to a callback.
|
||||||
For example the table `{ [30] = bar }` will call bar every 30th tick.
|
For example the table `{ [30] = bar }` will call bar every 30th tick.
|
||||||
@@ -92,7 +94,7 @@ For example a module could be defined as
|
|||||||
```lua
|
```lua
|
||||||
-- example.lua
|
-- example.lua
|
||||||
local function my_handler(event)
|
local function my_handler(event)
|
||||||
local name = game.player[event.player_index].name
|
local name = game.get_player(event.player_index).name
|
||||||
game.print("It appears that " .. name .. " has died")
|
game.print("It appears that " .. name .. " has died")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -176,12 +178,27 @@ This means always declearing your top level variables and functions local and ex
|
|||||||
You can require other files in your module by prefixing your require paths with `modules/your_module_name/`.
|
You can require other files in your module by prefixing your require paths with `modules/your_module_name/`.
|
||||||
It's also possible to require files from other modules this way too.
|
It's also possible to require files from other modules this way too.
|
||||||
|
|
||||||
The global variables and data stored in the global table is shared by all Clusterio modules as well as the scenario, so it's important that you use unique names.
|
Persistent state is shared by all save-patched modules and the scenario.
|
||||||
It's recommended to prefix the global variables and data in the global table that your module has with the name of your module.
|
Use `compat.script_data` to access the version-appropriate persistent table (`global` or `storage`), and keep your state under a unique module name.
|
||||||
|
See the [compatibility library](#compatibility-library) below.
|
||||||
|
|
||||||
|
|
||||||
|
### Initialisation and recovery
|
||||||
|
|
||||||
Because modules can be patched into an existing game you cannot rely on the `on_init` callback to be called in Clusterio Modules.
|
Because modules can be patched into an existing game you cannot rely on the `on_init` callback to be called in Clusterio Modules.
|
||||||
Nor can you rely on the `on_configuration_changed` callback, as this is not called when level code changes.
|
Nor can you rely on the `on_configuration_changed` callback, as this is not called when level code changes.
|
||||||
The Clusterio Lua API provides the custom `on_server_startup` event that can be used as a substitute, see the next section.
|
The Clusterio Lua API provides `on_server_startup` for setup and migrations, but some Factorio events can run before it.
|
||||||
|
In particular, player-leaving, console-command, and configuration-change handlers must not assume startup has initialized their state.
|
||||||
|
|
||||||
|
Use an idempotent state accessor in handlers that can run early.
|
||||||
|
It should migrate any legacy namespace before creating fresh state, fill missing fields, and return the same state on repeated calls.
|
||||||
|
A guard that returns early because the new namespace is absent can silently discard recovery work on an older save.
|
||||||
|
See [player_sync's state accessor](../plugins/player_sync/module/get_script_data.lua) for an implementation that handles a renamed namespace and [the module template](../packages/create/templates/common/module/control.lua) for lifecycle setup.
|
||||||
|
|
||||||
|
Use `on_load` only to restore local references and other operations permitted by Factorio's load lifecycle; do not create or migrate persistent state there.
|
||||||
|
Perform state changes in startup or other events that permit them.
|
||||||
|
Recovery mode skips normal Node plugin loading, so save data may still use an older plugin namespace when Lua handlers run.
|
||||||
|
Test old and current saves, early events, repeated access, and save/load behavior on the relevant supported Factorio versions.
|
||||||
|
|
||||||
|
|
||||||
## Clusterio Lua API
|
## Clusterio Lua API
|
||||||
@@ -190,6 +207,29 @@ Clusterio provides a save patched module as well as a regular Factorio mod for i
|
|||||||
This includes tools sending data to plugins, script events to listen for, and an item serialization module.
|
This includes tools sending data to plugins, script events to listen for, and an item serialization module.
|
||||||
|
|
||||||
|
|
||||||
|
### Compatibility library
|
||||||
|
|
||||||
|
Use the compatibility helpers for APIs that differ between supported Factorio versions:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- In a save-patched module:
|
||||||
|
local compat = require("modules/clusterio/compat")
|
||||||
|
-- In a mod, use require("__clusterio_lib__/compat") instead.
|
||||||
|
|
||||||
|
local function encode_example()
|
||||||
|
local script_data = compat.script_data
|
||||||
|
local encoded = compat.table_to_json({ example = true })
|
||||||
|
local decoded = compat.json_to_table(encoded)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
`script_data` selects the persistent state table, while the JSON helpers select the available runtime API.
|
||||||
|
Access persistent state from the appropriate lifecycle callbacks, after initialisation or loading has made it available.
|
||||||
|
The JSON helpers also work before runtime; other compatibility helpers may require `game` to be available.
|
||||||
|
Legacy examples using `global` or `game.table_to_json` directly are version-specific.
|
||||||
|
See [compat.lua](../packages/host/modules/clusterio/compat.lua) and the [Factorio support policy](decisions.md#factorio) before introducing version checks.
|
||||||
|
|
||||||
|
|
||||||
### clusterio_api library
|
### clusterio_api library
|
||||||
|
|
||||||
**require path**
|
**require path**
|
||||||
@@ -228,7 +268,8 @@ Event data:
|
|||||||
|
|
||||||
Raised when Clusterio is starting up the server.
|
Raised when Clusterio is starting up the server.
|
||||||
It can be used to initialize clusterio related features in a mod, and as a stand-in for the `on_init` and `on_configuration_changed` event for modules.
|
It can be used to initialize clusterio related features in a mod, and as a stand-in for the `on_init` and `on_configuration_changed` event for modules.
|
||||||
It is invoked on the first tick the server runs after the save has been patched, before most other events.
|
It is invoked on the first tick the server runs after the save has been patched, before most other events but not all of them.
|
||||||
|
See [initialisation and recovery](#initialisation-and-recovery) for handlers that can run earlier.
|
||||||
|
|
||||||
Use this event to initialize and/or migrate the data structures you need.
|
Use this event to initialize and/or migrate the data structures you need.
|
||||||
Keep in mind that both mods and Clusterio modules can switch from any version to any version so it should be able to handle both forwards and backwards migrations gracefully.
|
Keep in mind that both mods and Clusterio modules can switch from any version to any version so it should be able to handle both forwards and backwards migrations gracefully.
|
||||||
@@ -236,7 +277,7 @@ Keep in mind that both mods and Clusterio modules can switch from any version to
|
|||||||
#### clusterio_api.send_json(channel, data)
|
#### clusterio_api.send_json(channel, data)
|
||||||
|
|
||||||
Send JSON data to Clusterio over the given channel.
|
Send JSON data to Clusterio over the given channel.
|
||||||
The `data` argument should be table that can be serialized with `game.table_to_json`.
|
The `data` argument should be a table that can be serialized with `compat.table_to_json`.
|
||||||
Clusterio plugins can listen to channels and will receive an event with the data sent here.
|
Clusterio plugins can listen to channels and will receive an event with the data sent here.
|
||||||
See the [Communicating with Factorio section](writing-plugins.md#communicating-with-factorio) in the Writing Plugins document for more information.
|
See the [Communicating with Factorio section](writing-plugins.md#communicating-with-factorio) in the Writing Plugins document for more information.
|
||||||
|
|
||||||
@@ -249,7 +290,20 @@ Strings embedded into the tables sent must be valid UTF-8 text.
|
|||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
- `channel`: string identifying which channel to send it on.
|
- `channel`: string identifying which channel to send it on.
|
||||||
- `data`: table that can be converted to JSON with game.table_to_json
|
- `data`: table that can be converted to JSON with `compat.table_to_json`
|
||||||
|
|
||||||
|
#### clusterio_api.send_udp(channel, data)
|
||||||
|
|
||||||
|
Send data to Clusterio over the given channel using UDP instead of stdout.
|
||||||
|
Clusterio plugins receive it as `udp-channel_name` events, see the [Communicating over UDP section](writing-plugins.md#communicating-over-udp) in the Writing Plugins document.
|
||||||
|
Requires `factorio.enable_lua_udp` to be enabled on the instance and Factorio 2.1.10 or later, otherwise the data is silently dropped.
|
||||||
|
The port to send to is passed to the module with a script command on startup, so `factorio.enable_script_commands` must be enabled as well.
|
||||||
|
|
||||||
|
**Note**: Delivery is not guaranteed and payloads should be kept well below 32 kB.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `channel`: string identifying which channel to send it on.
|
||||||
|
- `data`: string with the payload to send, any bytes are allowed.
|
||||||
|
|
||||||
|
|
||||||
### serialize library
|
### serialize library
|
||||||
@@ -260,7 +314,7 @@ From a module
|
|||||||
From a mod
|
From a mod
|
||||||
`local serialize = require("__clusterio_lib__/serialize")`
|
`local serialize = require("__clusterio_lib__/serialize")`
|
||||||
|
|
||||||
Serializes inventories and item stacks into a table that in turn can be converted into JSON with `game.table_to_json`.
|
Serializes inventories and item stacks into a table that in turn can be converted into JSON with `compat.table_to_json`.
|
||||||
Properly handles the extra data for most items.
|
Properly handles the extra data for most items.
|
||||||
|
|
||||||
#### serialize.serialize_equipment_grid(LuaEquipmentGrid)
|
#### serialize.serialize_equipment_grid(LuaEquipmentGrid)
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ See [Breaking Changes](/docs/decisions.md#breaking-changes) in the decision log.
|
|||||||
Clusterio's packages are now ES modules and plugins should be too.
|
Clusterio's packages are now ES modules and plugins should be too.
|
||||||
Add `"type": "module"` to the plugin's `package.json` and replace `require()` and `module.exports` with `import` and `export`.
|
Add `"type": "module"` to the plugin's `package.json` and replace `require()` and `module.exports` with `import` and `export`.
|
||||||
|
|
||||||
|
Keep `"clusterio-plugin"` in the package's `"keywords"` for automatic discovery, and keep `"main"` pointing to the plugin info module (or use the default `index.js`).
|
||||||
|
An `"exports"` map is not required for ES modules.
|
||||||
|
If you add one, expose `"./package.json": "./package.json"`; otherwise Clusterio cannot discover or load the plugin by its package name.
|
||||||
|
Clusterio currently reads the implementation path from `"main"` rather than conditional exports.
|
||||||
|
See [Plugin Structure](../writing-plugins.md#plugin-structure) for a compatible package example.
|
||||||
|
|
||||||
Relative imports must include the full filename with the `.js` extension.
|
Relative imports must include the full filename with the `.js` extension.
|
||||||
This also applies in TypeScript, where you import `./messages.js` even though the source file is `messages.ts`.
|
This also applies in TypeScript, where you import `./messages.js` even though the source file is `messages.ts`.
|
||||||
Importing a directory no longer resolves to its `index.js`.
|
Importing a directory no longer resolves to its `index.js`.
|
||||||
|
|||||||
@@ -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`.
|
||||||
+114
-84
@@ -2,38 +2,32 @@
|
|||||||
|
|
||||||
Clusterio communicates using a simple homebrew message passing protocol based on JSON over WebSocket with limited support for reliable transport of messages.
|
Clusterio communicates using a simple homebrew message passing protocol based on JSON over WebSocket with limited support for reliable transport of messages.
|
||||||
The protocol is implemented in [packages/lib/src/link](/packages/lib/src/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
|
The protocol is implemented in [packages/lib/src/link](/packages/lib/src/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
|
||||||
|
The wire schemas are defined in [messages_core.ts](/packages/lib/src/data/messages_core.ts); see the [communication overview](../communication.md) for how links, connectors, and routing fit together.
|
||||||
|
|
||||||
Two general kinds of messages are supported: events and requests.
|
Two general kinds of messages are supported: events and requests.
|
||||||
Events are messages sent by one party and received by the other and offer no notification for the sender on whether it was actually received.
|
Events are messages sent by one party and received by the other and offer no notification for the sender on whether it was actually received.
|
||||||
Requests are pairs of request-response messages where one side sends the requests and the other side replies with a response.
|
Requests are pairs of request-response messages where one side sends the requests and the other side replies with a response.
|
||||||
Apart from the handshake used to establish connections the protocol is directionless, and either party can send or receive both requests and events.
|
Apart from the handshake used to establish connections the protocol is directionless, and either party can send or receive both requests and events.
|
||||||
|
|
||||||
Events and requests are defined as instances of the `Request` and `Event` classes, and specify a unique type string, who can send and receive the message, optional forwarding of the message and the format of the data payload as a JSON schema.
|
|
||||||
All messages sent and received are validated with JSON schemas to catch potential errors in sending messages and avoid having to write lots of validation code in message handlers.
|
|
||||||
|
|
||||||
Messages are sent by calling the `.send()` method of the message instance and passing a `Link` instance to send it over and the data to send.
|
|
||||||
For the controller there's a `Link` instance for each host and control connection stored in the `WsServer` class.
|
|
||||||
For hosts the `Host` instance is a link to the controller, and each `Instance` instance is an in-memory link to the `Host` instance and has a corresponding `InstanceConnection` link stored on the `Host` instance.
|
|
||||||
For ctl there's a `Ctl` instance and web_ui there's a `Control` instance that acts as the link to the controller.
|
|
||||||
|
|
||||||
Messages received invokes message handlers that were registered when the link was brought up.
|
|
||||||
These handlers are named after the name of the property the message is stored under with `EventHandler` appended for events and `RequestHandler` appended for requests.
|
|
||||||
The handlers are defined on the link class itself for core messages, and on the plugin class in the case of plugin defined messages.
|
|
||||||
|
|
||||||
|
|
||||||
## Connection flow
|
## Connection flow
|
||||||
|
|
||||||
Clients connect via WebSocket to the `/api/socket` endpoint and upon establishing connection to the server the connection is in the handshake phase and the server will send a `hello` message to the client.
|
Clients connect via WebSocket to the `/api/socket` endpoint and upon establishing connection to the server the connection is in the handshake phase and the server will send a `hello` message to the client.
|
||||||
If the client has no previous session with the server it sends a `register_host` or `register_control` message depending on what kind of client it is to the server.
|
If the client has no previous session with the server it sends a `registerHost` or `registerControl` message depending on what kind of client it is to the server.
|
||||||
If the register is valid then the server will respond with a `ready` message with a session token and the connection has left the handshake phase, otherwise the server will close the connection with a WebSocket close code indicating the kind of failure.
|
If the registration is valid, the server responds with a `ready` message containing the client's address, a session token, and session parameters.
|
||||||
|
The connection has then left the handshake phase; otherwise the server closes it with a WebSocket close code indicating the kind of failure.
|
||||||
|
|
||||||
Upon leaving the handshake phase both parties should start sending heartbeat messages with the sequence number of the last received message.
|
Upon leaving the handshake phase both parties should start sending heartbeat messages with the sequence number of the last received message.
|
||||||
Messages sent over the link should be stored in a send buffer and only cleared from the send buffer when a heartbeat with a `last_seq` equal or or higher is received or the session is invalidated.
|
Routable messages are stored in a send buffer until a heartbeat acknowledges an equal or higher sequence number, or the session ends.
|
||||||
|
The heartbeat's top-level `seq` acknowledges receipt by the transport, not successful completion of a message handler.
|
||||||
|
|
||||||
If the connection goes stale or is unexpectedly closed the client can reastablish the same session by connecting to the server again and sending a `resume` message after received the `hello` from the server.
|
If the connection goes stale or is unexpectedly closed, the client can re-establish the same session by connecting again and sending a `resume` message after receiving `hello`.
|
||||||
If the session resume is successful then the server will respond with a `continue` message and the connection is active again.
|
If the session resume is successful then the server will respond with a `continue` message and the connection is active again.
|
||||||
The client should then re-send all messages it has in its send buffer that is greater than the `last_seq` number in the `continue` message.
|
The client then re-sends buffered messages newer than the `lastSeq` in `continue`.
|
||||||
Otherwise the server will respond with `invalidate` and the client is expected to drop its send buffer start a new session with `register_host` or `register_control`.
|
The server likewise re-sends messages newer than the `lastSeq` supplied by the client in `resume`.
|
||||||
|
Otherwise the server responds with `invalidate`, and the client discards the old send buffer and registers a new session.
|
||||||
|
Session expiry, buffer overflow, or process failure can lose pending work; plugins must not treat transport recovery as an application-level exactly-once guarantee.
|
||||||
|
The [send-buffer setting](../configuration.md#controllermax_send_buffer_size) controls how much unacknowledged message data the controller retains for a connection.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -41,36 +35,42 @@ Otherwise the server will respond with `invalidate` and the client is expected t
|
|||||||
## Connector flow
|
## Connector flow
|
||||||
|
|
||||||
To manage the complexity of connecting, reconnecting, resuming or invalidating dropped sessions the WebSocket connection for a `Link` class is managed by a connector.
|
To manage the complexity of connecting, reconnecting, resuming or invalidating dropped sessions the WebSocket connection for a `Link` class is managed by a connector.
|
||||||
There's three kinds of connectors defined: `WebSocketClientConnector`, `WebSocketServerConnector` and `VirtualConnector`.
|
There's three kinds of connectors defined: `WebSocketClientConnector`, `WsServerConnector` and `VirtualConnector`.
|
||||||
The virtual connector is for in-memory connections and doesn't have any connection state logic.
|
The virtual connector is for in-memory connections and doesn't have any connection state logic.
|
||||||
|
|
||||||
For the WebSocket connectors there's four states they can exist in:
|
For the WebSocket connectors there's four states they can exist in:
|
||||||
|
|
||||||
- `closed` - The connector has just been created or has been explicitly closed.
|
- `closed` - The connector has just been created or has been explicitly closed.
|
||||||
- `connecting` (client only) - The connector is in the process of trying to connect to the server.
|
- `connecting` (client only) - The connector is in the process of trying to connect to the server.
|
||||||
- `connected` - The connector is successfully connected.
|
- `connected` - The connector is successfully connected.
|
||||||
- `resuming` - The connection dropped and the connector is in the process of attempting to resume it.
|
- `resuming` - The connection dropped and the connector is in the process of attempting to resume it.
|
||||||
|
|
||||||
Along with the following events emitted when transition between states:
|
Along with the following events emitted when transition between states:
|
||||||
- `connect` From `connecting` (client) or `closed` (server) to `connected`
|
|
||||||
- `drop` - From `connected` to `resuming`
|
- `connect` - A new session is established, moving from `connecting` (client) or `closed` (server) to `connected`.
|
||||||
- `resume` - From `resuming` to `connected`
|
- `drop` - The transport was lost but the session is retained for resumption, moving from `connected` to `resuming`.
|
||||||
- `invalidate` - From `resuming` to `connecting` or `closed`
|
- `resume` - The retained session is reconnected and unacknowledged buffered messages are replayed, moving from `resuming` to `connected`.
|
||||||
- `close` - From any state to `closed`
|
- `invalidate` - The previous session cannot be reused, moving from `resuming` to `connecting` (client only); server session expiry emits `close` instead.
|
||||||
|
- `close` - The connector is closed, moving from any state to `closed`.
|
||||||
|
|
||||||
|
Invalidation or closure rejects pending requests with `SessionLost`.
|
||||||
|
Plugins must handle lost sessions, stale state, and application retries where required; see [connection handling](../writing-plugins.md#handling-connection-events) for the plugin hooks and queueing behavior.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
### WsClientConnector
|
### WsClientConnector
|
||||||
|
|
||||||
The client connector will keep trying to establish and mantain a connection after `.connect()` is called on it, stopping only in the case of authentication failure or `.close()`/`.setClosing()` being called.
|
The client connector, named `WebSocketClientConnector` in the source, keeps trying to establish and maintain a connection after `.connect()` is called, stopping on terminal failures such as authentication or policy rejection, or when `.close()`/`.setClosing()` is called.
|
||||||
Other than that, the logic is pretty straight forward.
|
For example, policy violation (1008) stops retries, while try-again-later (1013), used for send-buffer overflow, permits reconnecting.
|
||||||
|
Connection retries use exponential backoff.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
### WsServerConnector
|
### WsServerConnector
|
||||||
|
|
||||||
The server connector doesn't do the work of connecting or validating the handshake.
|
The [server connector](/packages/controller/src/WsServerConnector.ts) doesn't do the work of connecting or validating the handshake.
|
||||||
Instead it's handed over a WebSocket connection from `WsServer` once there's only the step of sending the final message left to do.
|
Instead it's handed over a WebSocket connection from `WsServer` once there's only the step of sending the final message left to do.
|
||||||
Because of this there's no `connecting` state, and once a session times out from the `resuming` state it goes directly to closed.
|
Because of this there's no `connecting` state, and once a session times out from the `resuming` state it goes directly to closed.
|
||||||
|
|
||||||
@@ -79,19 +79,26 @@ Because of this there's no `connecting` state, and once a session times out from
|
|||||||
|
|
||||||
## Wire format
|
## Wire format
|
||||||
|
|
||||||
All communication in the protocol is done via WebSocket text frames containing a JSON payload with the following properties:
|
All communication uses WebSocket text frames containing a JSON object.
|
||||||
|
Every frame has a `type` string; the other fields depend on that type.
|
||||||
|
Requests, responses, and events carry an integer transport sequence number in `seq`, plus source and destination addresses in `src` and `dst`.
|
||||||
|
Message-specific data is carried in `data` when present.
|
||||||
|
|
||||||
- `seq: integer | null` - The sequence number of the message, can only be null for handshake and heartbeats.
|
An address is `[type, id]`, optionally followed by a request ID: `[type, id, requestId]`.
|
||||||
- `type: string` - The type of message
|
Address types are 0 for controller, 1 for host, 2 for instance, 3 for control, and 4 for broadcast.
|
||||||
- `data: object` - Message data
|
The controller's ID is 0; a broadcast address uses its ID to identify the target endpoint type.
|
||||||
|
For example, `[2, 123]` addresses instance 123, while `[4, 2]` addresses all instances.
|
||||||
|
Broadcast destinations are for events.
|
||||||
|
|
||||||
Before a connection is established it's in the handshake phase and the `seq` property is `null`.
|
A request puts its request ID in `src`; the response returns that ID in `dst` so the sender can match it to the pending request.
|
||||||
The `type` property is used to distinguish which of handshake, heartbeat, events or requests this message is and determines the expected format of the `data` property.
|
This ID is separate from transport `seq`, which is used for acknowledgements and buffering.
|
||||||
|
|
||||||
|
|
||||||
### Handshake Messages
|
### Handshake Messages
|
||||||
|
|
||||||
The following messages are the only valid messages that can be sent during the handshake phase.
|
The following messages are used during the handshake phase.
|
||||||
|
Their listed fields belong inside the frame's `data` object, except for `invalidate`, which has no data.
|
||||||
|
Property names are case-sensitive.
|
||||||
|
|
||||||
|
|
||||||
#### `hello`
|
#### `hello`
|
||||||
@@ -102,7 +109,7 @@ Sent by the controller when the WebSocket connection has been opened.
|
|||||||
- `plugins: Object<string, string>` -
|
- `plugins: Object<string, string>` -
|
||||||
Object mapping plugin names to plugin version for plugins that are loaded on the controller.
|
Object mapping plugin names to plugin version for plugins that are loaded on the controller.
|
||||||
|
|
||||||
#### `register_host`
|
#### `registerHost`
|
||||||
|
|
||||||
Host handshake for establishing a new connection session.
|
Host handshake for establishing a new connection session.
|
||||||
|
|
||||||
@@ -112,7 +119,7 @@ Host handshake for establishing a new connection session.
|
|||||||
- `plugins: Object<string, string>` -
|
- `plugins: Object<string, string>` -
|
||||||
Object mapping plugin names to plugin version for plugins that are available on the host.
|
Object mapping plugin names to plugin version for plugins that are available on the host.
|
||||||
|
|
||||||
#### `register_control`
|
#### `registerControl`
|
||||||
|
|
||||||
Control handshake for enstablishing a new connection session.
|
Control handshake for enstablishing a new connection session.
|
||||||
|
|
||||||
@@ -123,29 +130,32 @@ Control handshake for enstablishing a new connection session.
|
|||||||
|
|
||||||
Handshake for resuming an existing connection session where the connection had dropped unexpectedly.
|
Handshake for resuming an existing connection session where the connection had dropped unexpectedly.
|
||||||
|
|
||||||
- `session_token: string` - Session token to resume session with.
|
- `sessionToken: string` - Session token to resume session with.
|
||||||
- `last_seq: integer | null` - Last message seq received by the client.
|
- `lastSeq: integer` (optional) - Last transport sequence number received by the client; omitted if none has been received.
|
||||||
|
|
||||||
#### `invalidate`
|
#### `invalidate`
|
||||||
|
|
||||||
Sent by the controller in reply to a resume for a session that cannot be resumed.
|
Sent by the controller in reply to a resume for a session that cannot be resumed.
|
||||||
The client should start a new session with a register\_\* message in response.
|
The frame is `{"type":"invalidate"}`, without a `data` object.
|
||||||
|
The client should start a new session with `registerHost` or `registerControl` in response.
|
||||||
|
|
||||||
#### `continue`
|
#### `continue`
|
||||||
|
|
||||||
Sent by the controller to signal resuming the session was successful.
|
Sent by the controller to signal resuming the session was successful.
|
||||||
|
|
||||||
- `last_seq: integer | null` - Last message seq received by the server.
|
- `lastSeq: integer` (optional) - Last transport sequence number received by the server; omitted if none has been received.
|
||||||
- `session_timeout: integer` - Session timeout in seconds.
|
- `sessionTimeout: number` - Session timeout in seconds.
|
||||||
- `heartbeat_interval: number` - Rate client should send heartbeats at.
|
- `heartbeatInterval: number` - Interval between heartbeats in seconds.
|
||||||
|
|
||||||
#### `ready`
|
#### `ready`
|
||||||
|
|
||||||
Sent by the controller to signal the connection has been established with a new session.
|
Sent by the controller to signal the connection has been established with a new session.
|
||||||
|
|
||||||
- `session_token: string` - Token to resume the session with.
|
- `src: Address` - The client's address, used as the source of messages it sends.
|
||||||
- `session_timeout: integer` - Session timeout in seconds.
|
- `sessionToken: string` - Token to resume the session with.
|
||||||
- `heartbeat_interval: number` - Rate client should send heartbeats at.
|
- `sessionTimeout: number` - Session timeout in seconds.
|
||||||
|
- `heartbeatInterval: number` - Interval between heartbeats in seconds.
|
||||||
|
- `account` (optional) - Account details for a control client: a `name` and a `roles` array whose entries contain `name`, `id`, and `permissions`.
|
||||||
|
|
||||||
|
|
||||||
### Heartbeat message
|
### Heartbeat message
|
||||||
@@ -158,64 +168,84 @@ The heartbeat should contain the `seq` of the last received message, and receivi
|
|||||||
|
|
||||||
Sent by both the controller and clients at the interval given in the continue/ready messages.
|
Sent by both the controller and clients at the interval given in the continue/ready messages.
|
||||||
|
|
||||||
- `last_seq: integer` - Last message seq received over the link.
|
- `seq: integer` (optional, top-level) - Last transport sequence number received over the link.
|
||||||
|
|
||||||
|
For example, `{"type":"heartbeat","seq":123}` acknowledges messages through sequence 123.
|
||||||
|
Before receiving any messages, send `{"type":"heartbeat"}`; there is no `data.last_seq` field.
|
||||||
|
|
||||||
### Events
|
### Events
|
||||||
|
|
||||||
Events are messages send in one direction over the link, that the receiver is expected to act upon, but not make any replies (for that there's [Requests](#requests).
|
Events are messages sent in one direction that the receiver acts upon without replying; use [requests](#requests) when a response is needed.
|
||||||
By convention message types ending in `*_event` are events.
|
Their wire `type` is `"event"`, with a separate `name` identifying the registered event class.
|
||||||
The data payload of an event consists solely of event specific properties.
|
Plugin names are prefixed, for example `foo_frobber:SomeEvent`.
|
||||||
|
The `data` payload, when present, contains event-specific properties.
|
||||||
|
|
||||||
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core events and their contents.
|
See [packages/lib/src/data/](/packages/lib/src/data/) for core event definitions.
|
||||||
Plugins may defined their own events as well.
|
Plugins may define their own events as well.
|
||||||
|
|
||||||
#### `*_event`
|
#### `event`
|
||||||
|
|
||||||
- any properties defined by the event type.
|
- `seq: integer` - Transport sequence number.
|
||||||
|
- `src: Address` - Source address.
|
||||||
|
- `dst: Address` - Destination address, including broadcast destinations.
|
||||||
|
- `name: string` - Registered event name.
|
||||||
|
- `data` (optional) - Payload defined by the event class.
|
||||||
|
|
||||||
|
|
||||||
### Requests
|
### Requests
|
||||||
|
|
||||||
Requests are messages which expects a response in return.
|
Requests expect a response in return.
|
||||||
They function a lot like HTTP requests except that both parties of a connection can inniate a request that is expected to be responded to.
|
They function like HTTP requests except that either party of a connection can initiate one.
|
||||||
By convention message types ending in `*_request` are requests, and is expected to be replied to with a corresponding `*_response` message.
|
The wire `type` is `"request"`; a successful reply uses `"response"`, and a failed request uses `"responseError"`.
|
||||||
If an error occured while processing the request an error response containing an error property in the data payload is sent instead.
|
The request's `name` identifies the registered class, prefixed with the plugin name for plugin messages, such as `foo_frobber:Frobnicate`.
|
||||||
|
|
||||||
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core requests and their contents.
|
See [packages/lib/src/data/](/packages/lib/src/data/) for core request definitions.
|
||||||
Plugins may define their own requests as well.
|
Plugins may define their own requests as well.
|
||||||
|
|
||||||
#### `*_request`
|
#### `request`
|
||||||
|
|
||||||
- any properties defined by the request type.
|
- `seq: integer` - Transport sequence number.
|
||||||
|
- `src: Address` - Source address, including the request ID.
|
||||||
|
- `dst: Address` - Destination address.
|
||||||
|
- `name: string` - Registered request name.
|
||||||
|
- `data` (optional) - Payload defined by the request class.
|
||||||
|
|
||||||
#### `*_response` success
|
#### `response` success
|
||||||
|
|
||||||
- `seq: integer` - The seq number of the request message.
|
- `seq: integer` - This response's transport sequence number, not the request's sequence number.
|
||||||
- any additional properties defined by the request type.
|
- `src: Address` - Responding endpoint.
|
||||||
|
- `dst: Address` - Requester's address, including the original request ID.
|
||||||
|
- `data` (optional) - Payload defined by the request class's `Response` definition.
|
||||||
|
|
||||||
#### `*_response` error
|
#### `responseError`
|
||||||
|
|
||||||
- `seq: integer` - The seq number of the request message.
|
- `seq: integer` - This error response's transport sequence number.
|
||||||
- `error: string` - Human readable text message describing the error.
|
- `src: Address` - Responding endpoint.
|
||||||
|
- `dst: Address` - Requester's address, including the original request ID.
|
||||||
|
- `data.message: string` - Human-readable error description.
|
||||||
|
- `data.code: string` (optional) - 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.
|
||||||
|
|
||||||
|
|
||||||
### Graceful Disconnect
|
### Graceful Disconnect
|
||||||
|
|
||||||
To ensure no data is lost when closing the link it's important to send a prepare disconnect request and wait for it to complete before closing the WebSocket.
|
To reduce data loss when closing a link, ask the peer to prepare and wait for it to finish before closing the WebSocket.
|
||||||
Either party of the link can send this request and the party sending the request should be the one that initates the close of the WebSocket.
|
Either party can initiate the disconnect and is then responsible for closing the socket.
|
||||||
|
These are connector control frames, not registered request/response classes; forced closure and process crashes can still lose work.
|
||||||
|
|
||||||
#### `prepare_disconnect_request`
|
#### `disconnect` with `data: "prepare"`
|
||||||
|
|
||||||
Request graceful disconnect of the connection.
|
The frame `{"type":"disconnect","data":"prepare"}` requests a graceful disconnect.
|
||||||
The receiving party should stop tasks that send events and requests over the link and wait for pending requests it has over the link to complete, but it should not stop responding to requests sent to it.
|
The receiving party should stop tasks that send events and requests over the link and wait for pending requests it has over the link to complete, but it should not stop responding to requests sent to it.
|
||||||
Only after this process is done should the response be sent.
|
Only after this process is done should the response be sent.
|
||||||
|
|
||||||
#### `prepare_disconnect_response`
|
#### `disconnect` with `data: "ready"`
|
||||||
|
|
||||||
Signals the preparation for disconnect is complete and that the connection can be closed.
|
The frame `{"type":"disconnect","data":"ready"}` signals that preparation is complete and the connection can be closed.
|
||||||
The party that initiatied the prepare disconnect request may still send other requests/events over the link to finish its part of the disconnection proceedure.
|
When using `WebSocketBaseConnector.disconnect()`, the initiator must stop producing new work and await its own `Link.prepareDisconnect()` before calling `disconnect()`.
|
||||||
|
The connector closes as soon as the peer sends `ready` or the timeout expires; it does not drain the initiator's pending requests itself.
|
||||||
If the error response variant is received it should be logged if possible and treated as a success.
|
There is no error-response variant; the connector also has a timeout so an unresponsive peer cannot block shutdown indefinitely.
|
||||||
|
|
||||||
|
|
||||||
### Example handshake
|
### Example handshake
|
||||||
@@ -224,10 +254,10 @@ Server hello after opening the WebSocket.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"seq": null,
|
|
||||||
"type": "hello",
|
"type": "hello",
|
||||||
"data": {
|
"data": {
|
||||||
"version": "2.0.0"
|
"version": "2.0.0",
|
||||||
|
"plugins": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -236,12 +266,12 @@ Host registering a new connection session.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"seq": null,
|
"type": "registerHost",
|
||||||
"type": "register_host",
|
|
||||||
"data": {
|
"data": {
|
||||||
"token": "xyzabc123",
|
"token": "xyzabc123",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"id": 123
|
"id": 123,
|
||||||
|
"plugins": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -250,12 +280,12 @@ Controller reply signaling the session is established and ready.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"seq": null,
|
|
||||||
"type": "ready",
|
"type": "ready",
|
||||||
"data": {
|
"data": {
|
||||||
"session_token": "xyz.azy.bzy",
|
"src": [1, 123],
|
||||||
"session_timeout": 60,
|
"sessionToken": "xyz.azy.bzy",
|
||||||
"heartbeat_interval": 15
|
"sessionTimeout": 60,
|
||||||
|
"heartbeatInterval": 15
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,3 +1,25 @@
|
|||||||
|
# How Clusterio works
|
||||||
|
|
||||||
|
Clusterio runs a group of Factorio servers as a cluster, managed through a web interface or the `clusterioctl` command-line client.
|
||||||
|
The controller is the central point of contact: clients connect to it to manage the cluster, and hosts connect to it to receive commands and exchange messages.
|
||||||
|
Each host manages its local Factorio server processes, with each server belonging to an instance.
|
||||||
|
|
||||||
|
|
||||||
|
## System overview
|
||||||
|
|
||||||
|
The diagram shows one possible layout with two physical servers, each running a host and three Factorio servers.
|
||||||
|
The controller shares a machine with one host in this example, but it can run on a separate machine.
|
||||||
|
The number of hosts and instances can vary with the needs of the cluster.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The blue arrows point from the hosts and clients to the controller they connect to; messages can travel in both directions over those WebSocket connections.
|
||||||
|
The orange lines connect each host to the Factorio processes it manages.
|
||||||
|
Open the [full-size diagram](assets/clusterio_2.0_block_diagram.svg) to zoom in, or use the [Draw.io source](assets/clusterio_2.0_block_diagram.drawio) to edit it.
|
||||||
|
|
||||||
|
For how messages are handled and routed between these components, see the [communication guide](communication.md).
|
||||||
|
|
||||||
|
|
||||||
## Save Patching
|
## Save Patching
|
||||||
|
|
||||||
In order to efficiently get data in and out of running Factorio servers and to provide custom interfaces Clusterio uses save patching<sup>[1]</sup> to add code to the game.
|
In order to efficiently get data in and out of running Factorio servers and to provide custom interfaces Clusterio uses save patching<sup>[1]</sup> to add code to the game.
|
||||||
|
|||||||
+68
-10
@@ -30,9 +30,10 @@ Create a host config. `--id` id the host id. `--name` is the host name. `--gener
|
|||||||
|
|
||||||
### Update Clusterio
|
### Update Clusterio
|
||||||
|
|
||||||
ctl> host update [--id <host id>] [--name <host name>] [--restart]
|
ctl> host update <host> [--all] [--restart]
|
||||||
|
|
||||||
If remote updates are enabled on the host, this will attempt to update `@clusterio/host` on the machine. Changes will not be applied until the host is restarted. If the `--restart` flag is provided, the host will automatically restart once the update is complete.
|
Specify the host by name or ID.
|
||||||
|
If remote updates are enabled on the host, this will attempt to update `@clusterio/host` on the machine. Add `--all` to also update all npm-installed plugins. Changes will not be applied until the host is restarted. If the `--restart` flag is provided, the host will automatically restart once the update is complete.
|
||||||
|
|
||||||
Remote updates are enabled by default but can be disabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the following command locally while the host is offline:
|
Remote updates are enabled by default but can be disabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the following command locally while the host is offline:
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ Remote updates are enabled by default but can be disabled on a per-machine basis
|
|||||||
|
|
||||||
The controller also supports remote updates, following the same logic. The corresponding commands are:
|
The controller also supports remote updates, following the same logic. The corresponding commands are:
|
||||||
|
|
||||||
ctl> controller update [--restart]
|
ctl> controller update [--all] [--restart]
|
||||||
controller> config set controller.allow_remote_updates true/false
|
controller> config set controller.allow_remote_updates true/false
|
||||||
|
|
||||||
### Mark restart required
|
### Mark restart required
|
||||||
@@ -116,7 +117,7 @@ Edit instance config in a text editor. Once file is saved and editor has been ex
|
|||||||
|
|
||||||
### Assign instance to host
|
### Assign instance to host
|
||||||
|
|
||||||
ctl> instace assign <instance> [host]
|
ctl> instance assign <instance> [host]
|
||||||
|
|
||||||
Assigns instance to host. `instance` is instance to assign, `host` is host to assign to. If `host` is not provided, instance will be unassigned.
|
Assigns instance to host. `instance` is instance to assign, `host` is host to assign to. If `host` is not provided, instance will be unassigned.
|
||||||
|
|
||||||
@@ -230,6 +231,38 @@ Note: If the player joins the cluster again a new account will be made for them
|
|||||||
To be written.
|
To be written.
|
||||||
|
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Free text notes can be attached to hosts, instances, users, roles, mod packs, mods and saves.
|
||||||
|
They are stored on the controller and shown to everyone with access to the resource in the web interface, and can for example be used to record who runs a host or what an instance is meant for.
|
||||||
|
|
||||||
|
The web interface renders notes as Markdown.
|
||||||
|
A mention of the form `@<type>/<id>` becomes a link to that resource showing its name, for example `@instance/778528005` or `@user/cooldude2606`.
|
||||||
|
This works for hosts, instances, users, roles and mod packs, and the id is shown if the name is not available.
|
||||||
|
The note itself stores the mention as written.
|
||||||
|
|
||||||
|
### List notes
|
||||||
|
|
||||||
|
ctl> note list [--type <type>]
|
||||||
|
|
||||||
|
Lists all notes, optionally limited to one resource type.
|
||||||
|
|
||||||
|
### Set note
|
||||||
|
|
||||||
|
ctl> note set <type> <id> <content>
|
||||||
|
|
||||||
|
Sets the note of a resource.
|
||||||
|
The type is one of host, instance, user, role, mod_pack, mod or save.
|
||||||
|
The id is the numeric id for hosts, instances, roles and mod packs, the lower case name for users, `<name>_<version>` for mods and `<instance id>/<file name>` for saves.
|
||||||
|
|
||||||
|
### Delete note
|
||||||
|
|
||||||
|
ctl> note delete <type> <id>
|
||||||
|
|
||||||
|
Removes the note of a resource.
|
||||||
|
Notes are also removed when the resource they are attached to is deleted.
|
||||||
|
|
||||||
|
|
||||||
## Plugins (Local)
|
## Plugins (Local)
|
||||||
|
|
||||||
To be written.
|
To be written.
|
||||||
@@ -250,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
|
||||||
|
|
||||||
@@ -264,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
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# Migrating from 2.0.0-alpha.13 to 2.0.0-alpha.14
|
|
||||||
|
|
||||||
Alpha 14 comes with significant breaking changes for existing clusters, most notably the rename of master and slave to controller and host.
|
|
||||||
Please study the entire migration guide carefully before attempting to update an existing cluster.
|
|
||||||
|
|
||||||
## Before updating
|
|
||||||
|
|
||||||
If you're using mods you need to create mod pack string for the mod configurations you're currently running.
|
|
||||||
You can do this by running the follow RCON command on an instance that is currently running the mod configuration you want to use (you can also run this command in single player and find the string printed in Factorio's [Log file](https://wiki.factorio.com/Log_file)):
|
|
||||||
|
|
||||||
/sc local m={name="pack",description="",factorio_version=game.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}} for n,v in pairs(game.active_mods) do table.insert(m.mods,{name=n,enabled=true,version=v}) end local function set(s,c) for n,v in pairs(s) do m.settings[c][n]=v end end set(settings.startup,"startup") set(settings.global,"runtime-global") if game.player then set(game.player.mod_settings,"runtime-per-user") else set(settings.player,"runtime-per-user") end local e=game.encode_string(game.table_to_json(m)) log(e) rcon.print(e)
|
|
||||||
|
|
||||||
If you for some reason want to run this function in 2.0+, use the following command:
|
|
||||||
|
|
||||||
/sc local a={name="pack",description="",factorio_version=script.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}}for b,c in pairs(script.active_mods)do table.insert(a.mods,{name=b,enabled=true,version=c})end;local function d(e,f)for b,c in pairs(e)do a.settings[f][b]=c end end;d(settings.startup,"startup")d(settings.global,"runtime-global")if game.player then d(game.player.mod_settings,"runtime-per-user")else d(settings.player,"runtime-per-user")end;local g=helpers.encode_string(helpers.table_to_json(a))log(g)rcon.print(g)
|
|
||||||
|
|
||||||
This should produce a [Mod Pack String](https://forums.factorio.com/viewtopic.php?f=96&t=103578) which you need to copy and paste somewhere to keep it for later.
|
|
||||||
Mod Pack strings looks something like this:
|
|
||||||
|
|
||||||
eNqFketuwyAMhd/Fv0OUVpW25lWmqSLESdAIRNh0l4h3n9mlitZt5QdCx/7so8MKXs8ILSzaPEEFPZKJdmEbvIgiDNpwiDaczhjpU93Vu/r+Tmpz6Anah/V7RqcJRUavO4c9tBwTVnAF5upCGJeIsYx3tvsPbQTdb0lKHYlnPJHY0+ONvcdjvW8gP1ZAyGz9KL5XINaR0yLPXEFMnu2ManSh0+6j/GOFitqXG4mjNSUidVm5wlm7JMYG7Qhl3BX8FjyqZ9vztOk+NM2fvRPaceLbzbN+UejQFFOWXzfArvk6v2HWD9ZLv5JfxA1UwsubPBaMKhHGklLO76vAwfQ=
|
|
||||||
|
|
||||||
If you have multiple mod configuration create a mod pack string for each and note them down.
|
|
||||||
|
|
||||||
Double check that you are on a supported version of Node.js by running `node --version`, the version should be v18 or later, if not you will need to update Node.js before continuing.
|
|
||||||
|
|
||||||
|
|
||||||
## Migrate installations
|
|
||||||
|
|
||||||
**Note:** Check that plugins you are using have updates avaialble for alpha 14 before attempting to update, if they do not either wait for the plugin to be updated or uninstall the plugin from all places it is installed on first.
|
|
||||||
|
|
||||||
Shutdown all slaves and the master server and then migrate each installations by running the following command:
|
|
||||||
|
|
||||||
npm exec @clusterio/create -y -- --migrate-rename --log-level=verbose
|
|
||||||
|
|
||||||
This will convert the existing installation's usage of the master and slave names in packages, configs, database and logs to the new controller and host terms and update the installed packages.
|
|
||||||
|
|
||||||
If you're using systemd then copy the generated clusteriocontroller.service and/or clusteriohost.service files from the systemd folder to /etc/systemd/system/ and remove the old clusteriomaster.service and clusterioslave.service files.
|
|
||||||
|
|
||||||
Once the installations have been migrated start up the controller and hosts again.
|
|
||||||
|
|
||||||
|
|
||||||
## Post installation
|
|
||||||
|
|
||||||
Import the Mod Pack strings that was created beforehand on the controller and upload the mods needed by those mod packs to the controller.
|
|
||||||
You can assign a default mod pack in the controller config which instances use by default or set the mod pack on the per instance basis in the instance config.
|
|
||||||
Once you have assigned a mod pack to an instance run a data export on the instance in order to properly populate the mod settings in the mod and get item icons and item names resolved in the web interface.
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Reviewing pull requests
|
||||||
|
|
||||||
|
Review observable behavior across the whole PR, including callers, consumers, and lifecycle transitions.
|
||||||
|
Reading every changed file and passing tests do not establish that those behaviors were covered.
|
||||||
|
Use [contributing](contributing.md) for setup and validation commands.
|
||||||
|
|
||||||
|
|
||||||
|
## Establish the scope
|
||||||
|
|
||||||
|
- Record the PR's base and current head; preserve unrelated work with an isolated checkout when needed.
|
||||||
|
- Read the requirements and relevant documentation at the reviewed revision.
|
||||||
|
Distinguish intended policy from observed behavior when they disagree.
|
||||||
|
- Map each affected behavior from input through authorization, routing, state changes, and its consumers.
|
||||||
|
On a follow-up, verify the fixes and reassess the whole PR's affected behavior; explicitly label a deliberately narrower review.
|
||||||
|
- Compare the PR base, previously reviewed head, and current head when attributing a follow-up finding.
|
||||||
|
Distinguish a new regression, an incomplete fix, and a previously missed defect.
|
||||||
|
Existing producer behavior can become unsafe through a new consumer; reject a finding as pre-existing only after comparing the complete failing path.
|
||||||
|
- Trace the actual entrypoint and earlier guards before declaring a failure reachable.
|
||||||
|
Check supported configurations and compatibility policy; a merged revision or earlier clean review is not proof of correctness.
|
||||||
|
|
||||||
|
|
||||||
|
## Surface overlapping responsibilities
|
||||||
|
|
||||||
|
- For substantial new or replaced functionality, check whether existing code or test helpers already serve the same responsibility, including outside the changed files.
|
||||||
|
Search by behavior, then verify the implementations and their callers; nearby code is not necessarily the preferred shared implementation.
|
||||||
|
- Compare contracts, callers, and constraints, not just names or similar code.
|
||||||
|
Distinguish shared behavior and general correctness requirements from differences that may justify separate implementations.
|
||||||
|
- Surface significant overlap to the developer with source locations for both implementations, the duplicated responsibility, its consequences, and any legitimate differences.
|
||||||
|
Explain maintenance or consistency risks without presenting them as reproduced runtime defects.
|
||||||
|
- Recommend an approach based on the evidence, including alternatives, tradeoffs, and affected callers or tests.
|
||||||
|
Leave the architectural decision to the developer unless already authorized; do not silently consolidate the implementations or dismiss the overlap as intentional.
|
||||||
|
- Recheck after substantial fixes, especially when shared functionality is replaced, bypassed, or overridden.
|
||||||
|
Keep unresolved design questions in the candidate ledger and final report, and record the developer's decision when provided.
|
||||||
|
|
||||||
|
|
||||||
|
## Exercise the boundaries
|
||||||
|
|
||||||
|
- Write a small matrix of relevant states and transitions before probing.
|
||||||
|
Distinguish intended state, eligibility, and observed state, and vary the conditions that can make them differ.
|
||||||
|
Exercise transitions through the components that produce and consume the state; constructing the final state alone does not verify the transition.
|
||||||
|
- Check adverse paths as well as success: failure, retry, removal, restart, reconnect, and overlapping operations where applicable.
|
||||||
|
Select relevant cases from these recurring review gaps:
|
||||||
|
|
||||||
|
| Boundary | Questions to check |
|
||||||
|
| --- | --- |
|
||||||
|
| Data contracts | Does real serializer output satisfy every consumer, including Lua restoration and inspection UI? Check omitted, empty, partial, malformed, reserved, and numeric-limit values before durable mutation. |
|
||||||
|
| State and identity | Do delayed reads and mutation completions still belong to the selected entity? Do disabled components retain their local state? Can an identifier or generation be reused after recovery? |
|
||||||
|
| Persistence and synchronization | Do save/reload, migration, deletion, and resubscription preserve versions and tombstones? Compare full snapshots, deltas, and optimized paths with ordinary transfers. |
|
||||||
|
| Ownership and errors | Can concurrent work remove another operation's lock, handle, or cache? Check synchronous throws, early promise rejection, partial mutation, and cleanup after each failure. |
|
||||||
|
| Protocol and compatibility | What does the receiver do with the message, close code, or acknowledgement? Check early startup, mixed versions, and supported older APIs. |
|
||||||
|
| Distribution | Do generated validators, browser bundles, and installed packages behave like the source checkout? Check actual export maps and plugin discovery. |
|
||||||
|
|
||||||
|
- Use focused reproductions with concrete inputs and expected versus actual results.
|
||||||
|
Keep the logic under review real: stubbing permissions, effects, or state transitions can erase the defect being investigated.
|
||||||
|
Check that the harness reaches the intended path; compare a normal case and the base revision where practical.
|
||||||
|
Identify assumptions introduced by fixtures, caches, shared identities, or execution order, and vary those relevant to the change.
|
||||||
|
- Control concurrency with explicit gates and assertions about ownership and completion, rather than timing alone.
|
||||||
|
Check both that invalid actions are prevented and that valid work eventually makes progress.
|
||||||
|
Vary delays and completion order, including changes to an action's preconditions between initiation and execution.
|
||||||
|
|
||||||
|
|
||||||
|
## Account for every candidate
|
||||||
|
|
||||||
|
- Keep a compact ledger of candidate issues and uncovered scenarios throughout the review.
|
||||||
|
Give each candidate a disposition: reported, rejected with evidence, or unresolved with the next check needed.
|
||||||
|
- Before finishing, compare probe outputs with the final findings.
|
||||||
|
A reproduced anomaly must not disappear just because several other issues were already found.
|
||||||
|
- Carry unresolved candidates and coverage gaps into handoffs and context summaries, alongside published findings.
|
||||||
|
For each finding, identify the trigger, consequence, source location, and supporting evidence.
|
||||||
|
Reconcile the previous ledger at every follow-up; an unconfirmed candidate remains unresolved until evidence rejects it or confirms it.
|
||||||
|
Name remaining material gaps in the final report even when other findings already block merging.
|
||||||
|
|
||||||
|
|
||||||
|
## Report and follow up
|
||||||
|
|
||||||
|
- Separate source inspection, focused probes, full suites, hosted CI, browser checks, and Factorio runtime evidence.
|
||||||
|
Record failures, skips, and unavailable validation; investigate external-service failures before attributing them to the PR.
|
||||||
|
- Reproduce the original failure when checking a fix, including adjacent cases exposed by the change.
|
||||||
|
A fix for one malformed field calls for checking the remaining consumer requirements, not just that example.
|
||||||
|
Limit conclusions to the scenarios exercised, and state which relevant assumptions remain unverified.
|
||||||
|
Resolve a review thread only when its exact concern is addressed.
|
||||||
|
- When authorized to publish, refresh the head and existing comments, avoid duplicates, and anchor feedback to the reviewed code.
|
||||||
|
Read back each posted inline comment to verify its body and location.
|
||||||
+35
-10
@@ -11,10 +11,21 @@ If the script fails due to GitHub's 60/hour public access rate limit, you can se
|
|||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
|
The [Publish workflow](../.github/workflows/publish.yml) publishes npm packages and Lua mods on GitHub's `release: released` event for a stable release.
|
||||||
|
Publishing a prerelease does not trigger it; see [GitHub's release event documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#release).
|
||||||
|
The npm release includes the packages under `packages/` and the six core plugins under `plugins/`.
|
||||||
|
Community plugins hosted in separate repositories have independent versions and releases.
|
||||||
|
For this workflow, first merge the changelog and package version updates into `master`, then create and push the release tag on that merged commit.
|
||||||
|
Create a draft GitHub release for the existing tag and use the GitHub Release Markdown from `scripts/pr_to_changelog.mts` as its notes.
|
||||||
|
Publishing the draft as a stable release triggers the workflow; pushing a tag alone does not.
|
||||||
|
Preparing a release PR, pushing a tag, and publishing a release or packages are separate actions; obtain authorization for the publication steps before executing them on someone else's behalf.
|
||||||
|
The steps below describe publishing npm packages manually instead.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
|
> You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
|
||||||
|
|
||||||
1. Ensure you are on the latest version of master branch with no untracked files
|
1. Inspect `git status` and `git remote -v`, verify which remote is the main repository, and use a clean release worktree on its latest master.
|
||||||
|
The commands below assume that remote is named `upstream`; preserve unrelated work and external plugin checkouts.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git switch master
|
git switch master
|
||||||
@@ -22,31 +33,45 @@ If the script fails due to GitHub's 60/hour public access rate limit, you can se
|
|||||||
git status
|
git status
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Bump the version in the `package.json` files
|
2. Create a release branch and bump the version in the `package.json` files. The commands below use 2.0.1 to 2.0.2 as an example; substitute the versions for the release you are preparing.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sed -i 's/2\.0\.0-alpha\.XX/2\.0\.0-alpha\.YY/' {packages,plugins}/*/package.json
|
git switch -c release/2.0.2
|
||||||
````
|
sed -i 's/"version": "2\.0\.1"/"version": "2.0.2"/' {packages,plugins}/*/package.json
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
```
|
||||||
|
|
||||||
3. Tag and push the release to github
|
3. Review and commit the version and changelog changes, then open and merge a release pull request.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git add -p
|
git add -p
|
||||||
git commit -m v2.0.0-alpha.XX
|
git commit -m "Prepare 2.0.2 release"
|
||||||
git tag v2.0.0-alpha.XX
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Perform a clean install and publish the packages.
|
4. After the release pull request is merged, tag the merged commit and push the tag.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
git switch master
|
||||||
|
git pull --ff-only upstream master
|
||||||
|
git tag v2.0.2
|
||||||
|
git push upstream v2.0.2
|
||||||
|
```
|
||||||
|
|
||||||
|
5. For manual publication, perform a clean install and publish the packages. For automated publication, publish the GitHub release as described above instead.
|
||||||
|
|
||||||
|
The repository does not track `pnpm-lock.yaml`, so allow pnpm to generate or update it during installation.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node clean.js --dry-run
|
||||||
pnpm clean
|
pnpm clean
|
||||||
pnpm -r --filter='./{plugins,packages}/*' publish
|
pnpm install --no-frozen-lockfile
|
||||||
|
pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish
|
||||||
```
|
```
|
||||||
|
|
||||||
## Plugins
|
## Plugins
|
||||||
|
|
||||||
1. Bump the version in the `package.json` file
|
1. Bump the version in the `package.json` file
|
||||||
2. Run `pnpm install` in the repository root
|
2. Run `pnpm install --no-frozen-lockfile` in the repository root
|
||||||
3. Run `pnpm adduser` to authenticate
|
3. Run `pnpm adduser` to authenticate
|
||||||
4. Run `pnpm publish` in the plugin/package directory
|
4. Run `pnpm publish` in the plugin/package directory
|
||||||
|
|
||||||
|
|||||||
+355
-88
@@ -11,6 +11,7 @@ Each entrypoint of a plugin exports a function that attaches handlers to hooks c
|
|||||||
- [Logging Messages](#logging-messages)
|
- [Logging Messages](#logging-messages)
|
||||||
- [Plugin Configuration](#plugin-configuration)
|
- [Plugin Configuration](#plugin-configuration)
|
||||||
- [Plugin Permissions](#plugin-permissions)
|
- [Plugin Permissions](#plugin-permissions)
|
||||||
|
- [Migrating Stored Data](#migrating-stored-data)
|
||||||
- [Communicating with Factorio](#communicating-with-factorio)
|
- [Communicating with Factorio](#communicating-with-factorio)
|
||||||
- [Defining Link Messages](#defining-link-messages)
|
- [Defining Link Messages](#defining-link-messages)
|
||||||
- [Sending Link Messages](#sending-link-messages)
|
- [Sending Link Messages](#sending-link-messages)
|
||||||
@@ -38,6 +39,11 @@ The basic file structure of a plugin is the following.
|
|||||||
Clusterio plugins are Node.js packages that can be published on npm for ease of installation and distribution.
|
Clusterio plugins are Node.js packages that can be published on npm for ease of installation and distribution.
|
||||||
The usual guides for creating such packages apply.
|
The usual guides for creating such packages apply.
|
||||||
At minimum the `package.json` file must contain a version entry.
|
At minimum the `package.json` file must contain a version entry.
|
||||||
|
For automatic discovery, include `"clusterio-plugin"` in the package's `"keywords"` array.
|
||||||
|
Installed npm plugins are discovered from the installation's dependencies when controller, host or ctl loads its configuration, and added to `plugin-list.json`.
|
||||||
|
Discovery starts with the root package's `dependencies`, not `devDependencies`, and follows dependencies of recognized plugins.
|
||||||
|
It does not search arbitrary nested dependency trees for plugins.
|
||||||
|
For tooling that needs the same discovery behavior, reuse [loadPluginList](../packages/lib/src/load_plugin_list.ts) rather than maintaining another package scanner.
|
||||||
|
|
||||||
A possible workflow for developing plugins is to place the plugin in a sub-directory of where clusterio has been installed, and rely on Node.js searching up the folder heirarchy for it to find `@clusterio/lib`.
|
A possible workflow for developing plugins is to place the plugin in a sub-directory of where clusterio has been installed, and rely on Node.js searching up the folder heirarchy for it to find `@clusterio/lib`.
|
||||||
After creating a directory for your plugin, the boilerplate can be generated automaticlly from a set of tempaltes using `npm exec @clusterio/create -y -- --plugin-template` after which you will be asked which areas your plugin will interact with and your plugin's name.
|
After creating a directory for your plugin, the boilerplate can be generated automaticlly from a set of tempaltes using `npm exec @clusterio/create -y -- --plugin-template` after which you will be asked which areas your plugin will interact with and your plugin's name.
|
||||||
@@ -46,26 +52,50 @@ Note that it's important that the path starts with ./ or ../ (use .\ or ..\ on W
|
|||||||
|
|
||||||
For a plugin to be recognised by Clusterio it needs to export an entry named `plugin` from its main entrypoint.
|
For a plugin to be recognised by Clusterio it needs to export an entry named `plugin` from its main entrypoint.
|
||||||
By default the main entrypoint is the `index.js` file, but this may be changed by setting the `"main"` entry to a different file in `package.json`.
|
By default the main entrypoint is the `index.js` file, but this may be changed by setting the `"main"` entry to a different file in `package.json`.
|
||||||
|
Keep this `"main"` entry when using a package `"exports"` map: Clusterio currently loads the plugin implementation from `"main"` (or `index.js`), rather than selecting a conditional export.
|
||||||
|
|
||||||
|
If a plugin published on npm defines an `"exports"` map, it must also expose `"./package.json"`.
|
||||||
|
Both automatic discovery and runtime loading resolve `<package-name>/package.json` to read the plugin's metadata.
|
||||||
|
Packages that hide this file are currently skipped by discovery, and manually adding their package names to `plugin-list.json` does not make them loadable.
|
||||||
|
The generated plugin templates omit `"exports"`, which leaves `package.json` accessible.
|
||||||
|
For example, a plugin that needs an export map can use:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "example-clusterio-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./index.js",
|
||||||
|
"keywords": ["clusterio-plugin"],
|
||||||
|
"exports": {
|
||||||
|
".": "./index.js",
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Here's an example of `index.js`:
|
Here's an example of `index.js`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
import * as messages from "./messages.js";
|
||||||
|
|
||||||
export const plugin = {
|
export const plugin = {
|
||||||
name: "foo_frobber",
|
name: "foo_frobber",
|
||||||
title: "Foo Frobber",
|
title: "Foo Frobber",
|
||||||
description: "Does advanced frobnication",
|
description: "Does advanced frobnication",
|
||||||
instanceEntrypoint: "instance.js",
|
instanceEntrypoint: "instance.js",
|
||||||
controllerEntrypoint: "controller.js",
|
controllerEntrypoint: "controller.js",
|
||||||
messages: {
|
messages: [messages.Frobnicate],
|
||||||
/* See below */
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
The following properties are recognized:
|
The following properties are recognized:
|
||||||
|
|
||||||
**name**:
|
**name**:
|
||||||
Internal name of the plugin.
|
Stable internal identifier of the plugin, used as its key in the plugin list.
|
||||||
Must be the same as the plugin's directory name
|
For local plugins automatically discovered under `plugins/` or `external_plugins/`, it must match the plugin's directory name.
|
||||||
|
It does not have to match the npm package name.
|
||||||
|
For example, the package `@clusterio/plugin-subspace_storage` declares the name `subspace_storage`.
|
||||||
|
|
||||||
**title**:
|
**title**:
|
||||||
Name of the plugin as shown to users.
|
Name of the plugin as shown to users.
|
||||||
@@ -89,23 +119,45 @@ The following properties are recognized:
|
|||||||
This is an optional parameter.
|
This is an optional parameter.
|
||||||
A plugin can be made that only runs on the controller.
|
A plugin can be made that only runs on the controller.
|
||||||
|
|
||||||
**ControllerConfigGroup**:
|
**controllerConfigFields**:
|
||||||
Object mapping controller configuration fields to `FieldDefinitions`for this plugin.
|
Object mapping controller configuration fields to `FieldDefinition` objects for this plugin.
|
||||||
See [Plugin Configuration](#plugin-configuration)
|
See [Plugin Configuration](#plugin-configuration)
|
||||||
|
|
||||||
|
**hostEntrypoint**:
|
||||||
|
Optional path to the Node.js module exporting the host entrypoint function.
|
||||||
|
|
||||||
|
**hostConfigFields**:
|
||||||
|
Object mapping host configuration fields to `FieldDefinition` objects.
|
||||||
|
|
||||||
**ctlEntrypoint**:
|
**ctlEntrypoint**:
|
||||||
Path to a Node.js module relative to the plugin directory which exports the clusterioctl entrypoint function for this plugin.
|
Path to a Node.js module relative to the plugin directory which exports the clusterioctl entrypoint function for this plugin.
|
||||||
This is an optional paramater.
|
This is an optional paramater.
|
||||||
A plugin can be made that only runs on the clusterioctl side.
|
A plugin can be made that only runs on the clusterioctl side.
|
||||||
|
|
||||||
|
**ctlConfigFields**:
|
||||||
|
Object mapping ctl configuration fields to `FieldDefinition` objects.
|
||||||
|
|
||||||
|
**webEntrypoint**:
|
||||||
|
Optional key of the web entrypoint in the plugin's web bundle, this should be `"./web"`.
|
||||||
|
The `clusterioPlugin` Vite plugin exposes `web/index.tsx` (or `.ts`, `.jsx`, `.js`) under this key; it is not a Node.js file path.
|
||||||
|
|
||||||
|
**migrationEntrypoint**:
|
||||||
|
Path to a Node.js module relative to the plugin directory which exports the migration entrypoint function for this plugin.
|
||||||
|
This is an optional parameter.
|
||||||
|
See [Migrating Stored Data](#migrating-stored-data)
|
||||||
|
|
||||||
**features**:
|
**features**:
|
||||||
Array of features the plugin needs from the instances it is loaded on, `"SavePatching"` and/or `"ScriptCommands"`.
|
Array of features the plugin needs from the instances it is loaded on, any of `"SavePatching"`, `"ScriptCommands"` and `"LuaUdp"`.
|
||||||
See [Handling Invalid Configuration](#handling-invalid-configuration)
|
See [Handling Invalid Configuration](#handling-invalid-configuration)
|
||||||
|
|
||||||
**messages**:
|
**messages**:
|
||||||
Object with link messages definitions 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)
|
||||||
@@ -117,7 +169,8 @@ The only restriction imposed on modules embedded into plugins is that they must
|
|||||||
While there is no standard for how to organize a plugin it's recommended to put the controller entrypoint into controller.js and the instance entrypoint into instance.js.
|
While there is no standard for how to organize a plugin it's recommended to put the controller entrypoint into controller.js and the instance entrypoint into instance.js.
|
||||||
You can put them into whatever file you want (even the same one for both).
|
You can put them into whatever file you want (even the same one for both).
|
||||||
|
|
||||||
The entrypoint paths must include the .js extension and use forward slashes for directory separators if any.
|
Node.js entrypoint paths must include the `.js` extension and use forward slashes for directory separators.
|
||||||
|
This rule does not apply to the `webEntrypoint` exposure key.
|
||||||
|
|
||||||
|
|
||||||
## Defining the plugin entrypoint
|
## Defining the plugin entrypoint
|
||||||
@@ -204,7 +257,7 @@ try {
|
|||||||
// Operation that should not throw but may end up throwing
|
// Operation that should not throw but may end up throwing
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Operation failed:\n${err.stack}`);
|
logger.error(`Operation failed:\n${err.stack}`);
|
||||||
)
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For plugin hooks expections thrown are automatically catched and logged, but for event handlers registered on EventEmitters it's critical that exceptions are catched and handled appropriately.
|
For plugin hooks expections thrown are automatically catched and logged, but for event handlers registered on EventEmitters it's critical that exceptions are catched and handled appropriately.
|
||||||
@@ -213,7 +266,7 @@ For plugin hooks expections thrown are automatically catched and logged, but for
|
|||||||
## Plugin Configuration
|
## Plugin Configuration
|
||||||
|
|
||||||
Clusterio provides a configuration system that handles storing, distributing, editing and validating config fields for you.
|
Clusterio provides a configuration system that handles storing, distributing, editing and validating config fields for you.
|
||||||
You can take advantage of it by declaring config fields under either `controllerConfigFields` or `instanceConfigFields` in the `plugin` export.
|
Declare fields under `controllerConfigFields`, `hostConfigFields`, `instanceConfigFields`, or `ctlConfigFields` in the `plugin` export.
|
||||||
For example in index.js:
|
For example in index.js:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -254,7 +307,7 @@ See [Configuration System](config-system.md) for more details on how this system
|
|||||||
|
|
||||||
### Handling Invalid Configuration
|
### Handling Invalid Configuration
|
||||||
|
|
||||||
If the plugin requires save patching or script commands to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
|
If the plugin requires save patching, script commands or Lua UDP to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
export const plugin = {
|
export const plugin = {
|
||||||
@@ -308,6 +361,76 @@ In JavaScript the `declare module` block is not needed.
|
|||||||
|
|
||||||
Permissions declared this way are registered when the plugin is loaded.
|
Permissions declared this way are registered when the plugin is loaded.
|
||||||
The older `lib.definePermission()` function still works, but a plugin should not use both for the same permission name.
|
The older `lib.definePermission()` function still works, but a plugin should not use both for the same permission name.
|
||||||
|
Controller-side permission checks are authoritative; hiding a web UI control does not authorize or protect its underlying request.
|
||||||
|
Declare the [permission on control-origin messages](#static-permission) and test denied requests as well as allowed ones.
|
||||||
|
|
||||||
|
|
||||||
|
## Migrating Stored Data
|
||||||
|
|
||||||
|
Clusterio stores the config fields and permissions of plugins, so a plugin that renames them needs to migrate the stored values or users lose what they had set.
|
||||||
|
This is done from the migration entrypoint, which is loaded on the controller and hosts before any config or role is read, for all plugins in the plugin list including those disabled with `load_plugin`.
|
||||||
|
It is passed a context with `hooks` and attaches handlers for the data it needs to migrate:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
|
export default function(context) {
|
||||||
|
const { hooks, plugin } = context;
|
||||||
|
// Plugin was renamed from frobber in 2.0.0
|
||||||
|
const renameFields = config => lib.renameConfigPrefix(config, "frobber.", "foo_frobber.");
|
||||||
|
hooks.controllerConfig.attach(plugin.name, renameFields);
|
||||||
|
hooks.hostConfig.attach(plugin.name, renameFields);
|
||||||
|
hooks.instanceConfig.attach(plugin.name, renameFields);
|
||||||
|
hooks.role.attach(plugin.name, role => lib.renamePermissionPrefix(role, "frobber.", "foo_frobber."));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The handlers get the serialized config or role after the core migrations and return the migrated version.
|
||||||
|
They run synchronously, a handler that throws stops the controller or host from starting.
|
||||||
|
`lib.renameConfigField`, `lib.renameConfigPrefix`, `lib.renamePermission` and `lib.renamePermissionPrefix` cover the common case of renaming.
|
||||||
|
|
||||||
|
Handlers run every time the data is loaded and must leave data that is already migrated unchanged.
|
||||||
|
For changes that can't be repeated, controller, host, and instance configs have a hidden `<plugin>.version` field with the version of the plugin they were last loaded with, which is updated after the migrations have run:
|
||||||
|
|
||||||
|
```js
|
||||||
|
hooks.instanceConfig.attach(plugin.name, config => {
|
||||||
|
const version = config["foo_frobber.version"];
|
||||||
|
// The default delay went from 1 to 2 in 1.2.0, update configs still on the old default
|
||||||
|
if (
|
||||||
|
version && lib.integerFullVersion(version) < lib.integerFullVersion("1.2.0")
|
||||||
|
&& config["foo_frobber.delay"] === 1
|
||||||
|
) {
|
||||||
|
config["foo_frobber.delay"] = 2;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The version field is missing or empty in configs saved before Clusterio tracked it, and in configs last loaded in recovery mode.
|
||||||
|
|
||||||
|
Roles have no version of their own.
|
||||||
|
They are stored on the controller, so use the version in the controller config, which is loaded before the roles:
|
||||||
|
|
||||||
|
```js
|
||||||
|
let controllerVersion;
|
||||||
|
hooks.controllerConfig.attach(plugin.name, config => {
|
||||||
|
controllerVersion ??= config["foo_frobber.version"] ?? "";
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
hooks.role.attach(plugin.name, role => {
|
||||||
|
// foo_frobber.edit was split out of foo_frobber.view in 1.2.0
|
||||||
|
if (
|
||||||
|
controllerVersion && lib.integerFullVersion(controllerVersion) < lib.integerFullVersion("1.2.0")
|
||||||
|
&& role.permissions.includes("foo_frobber.view")
|
||||||
|
) {
|
||||||
|
role.permissions.push("foo_frobber.edit");
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Data the plugin stores itself, like files in the database directory or values in the save, is up to the plugin to migrate from its other entrypoints.
|
||||||
|
Modules don't need migrating, save patching replaces the files of every module when an instance starts.
|
||||||
|
|
||||||
|
|
||||||
## Communicating with Factorio
|
## Communicating with Factorio
|
||||||
@@ -374,7 +497,7 @@ async function handleFoo(content) {
|
|||||||
**Note:** This only handles IPC events, all other events must use `on`. You should not include the `ipc-` prefix when using this method. Error logging is handled for you, if you want custom error handling use `on`.
|
**Note:** This only handles IPC events, all other events must use `on`. You should not include the `ipc-` prefix when using this method. Error logging is handled for you, if you want custom error handling use `on`.
|
||||||
|
|
||||||
It's recommended to either use the plugin name as the channel name or to prefix the channel name with the name of the plugin if you need multiple channels.
|
It's recommended to either use the plugin name as the channel name or to prefix the channel name with the name of the plugin if you need multiple channels.
|
||||||
It's also important to catch any errors that might occur as they will otherwise be propogated to the instance code and kill the server.
|
With raw `server.on(...)` listeners, catch errors in the handler; `server.handle(...)` already handles error logging for IPC handlers.
|
||||||
|
|
||||||
Data out from Factorio does not have the same limits as data into Factorio, RCON responses can be in 100kB range without causing issues, and payloads to the `send_json` API can be in the 4MB range provided the server has a fast enough storage system.
|
Data out from Factorio does not have the same limits as data into Factorio, RCON responses can be in 100kB range without causing issues, and payloads to the `send_json` API can be in the 4MB range provided the server has a fast enough storage system.
|
||||||
|
|
||||||
@@ -382,6 +505,62 @@ 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
|
||||||
|
|
||||||
|
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.
|
||||||
|
This needs Factorio 2.1.10 or later, earlier versions crash when a packet is received on a headless server.
|
||||||
|
This is disabled by default and enabled by setting `factorio.enable_lua_udp` on the instance.
|
||||||
|
Sending data out of Factorio also needs `factorio.enable_script_commands`, as the port to send to is passed to the module with a script command on startup.
|
||||||
|
Plugins requiring it should declare the `LuaUdp` feature.
|
||||||
|
Note that UDP does not guarantee delivery or ordering, and packets should be kept well below 32 kB.
|
||||||
|
|
||||||
|
Data out from Factorio is sent with the `send_udp` API of the Clusterio module.
|
||||||
|
Unlike `send_json` the payload is an arbitrary string, and it is delivered to plugins as a `udp-channel_name` event with the payload as a Buffer:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
clusterio_api.send_udp("my_plugin_foo", "123")
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default async function(context) {
|
||||||
|
const { instance, logger } = context;
|
||||||
|
instance.server.handleUdp("my_plugin_foo", async data => {
|
||||||
|
logger.info(`got ${data.toString()}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Like `handle` for `send_json`, `handleUdp` logs errors thrown by the handler.
|
||||||
|
Use `instance.server.on("udp-my_plugin_foo", ...)` for custom error handling.
|
||||||
|
|
||||||
|
Data into Factorio is sent with the `sendUdp` method on the instance and delivered in-game as `defines.events.on_udp_packet_received` events with the packet in `event.payload`.
|
||||||
|
The Clusterio module polls for received packets every tick, all modules and mods receive every packet so make sure the payload identifies your plugin.
|
||||||
|
|
||||||
|
```js
|
||||||
|
instance.hooks.start.attach(plugin.name, async () => {
|
||||||
|
await instance.sendUdp("my_plugin_foo:123");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Defining Link Messages
|
## Defining Link Messages
|
||||||
|
|
||||||
@@ -396,59 +575,59 @@ Events are simple one-way notifications that invoke a handler on the target it's
|
|||||||
Requests are pairs of request and response messages where the request is sent to the target and the response is the reply back from the target.
|
Requests are pairs of request and response messages where the request is sent to the target and the response is the reply back from the target.
|
||||||
The requests are similar to HTTP requests, except both parties of a link may initiate one.
|
The requests are similar to HTTP requests, except both parties of a link may initiate one.
|
||||||
|
|
||||||
Messages are defined as items of the `plugin.messages` array exported by `index.js`.
|
Export message classes from `messages.js` and list them in the `plugin.messages` array in `index.js`, as in the [plugin structure example](#plugin-structure).
|
||||||
For example, the following could be defined in `plugin`:
|
For example, `messages.js` can contain:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
messages: [
|
export class Frobnicate {
|
||||||
class Frobnicate {
|
static plugin = "foo_frobber";
|
||||||
static plugin = "foo_frobber"; // Plugin name
|
static type = "request";
|
||||||
static type = "request";
|
static src = "instance";
|
||||||
static src = "controller";
|
static dst = "controller";
|
||||||
static dst = "instance";
|
static jsonSchema = {
|
||||||
static jsonSchema = {
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
verbosity: { type: "integer" },
|
||||||
|
special: { type: "boolean" },
|
||||||
|
},
|
||||||
|
required: ["verbosity"],
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
constructor(json) {
|
||||||
|
this.verbosity = json.verbosity;
|
||||||
|
this.special = json.special;
|
||||||
|
}
|
||||||
|
static fromJSON(json) {
|
||||||
|
return new Frobnicate(json);
|
||||||
|
}
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
verbosity: this.verbosity,
|
||||||
|
special: this.special,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
static Response = {
|
||||||
|
jsonSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
"verbosity": { type: "integer" },
|
report: {
|
||||||
"special": { type: "boolean" },
|
type: "array",
|
||||||
},
|
items: { type: "string" },
|
||||||
required: ["verbosity"],
|
|
||||||
additionalProperties: false,
|
|
||||||
};
|
|
||||||
constructor(json) {
|
|
||||||
this.verbosity = json.verbosity;
|
|
||||||
this.special = json.special;
|
|
||||||
}
|
|
||||||
static fromJSON(json) {
|
|
||||||
return new Frobnicate(json);
|
|
||||||
}
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
verbosity: this.verbosity,
|
|
||||||
special: this.special,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
static Response = {
|
|
||||||
jsonSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
"report": {
|
|
||||||
type: "array",
|
|
||||||
items: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fromJSON(json) {
|
required: ["report"],
|
||||||
return json
|
additionalProperties: false,
|
||||||
}
|
},
|
||||||
}
|
fromJSON(json) {
|
||||||
}
|
return json;
|
||||||
]
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This specifies a request that can be sent from the controller to an instance, being routed through the correct host behind the scenes.
|
This specifies a request sent from an instance to the controller through its host.
|
||||||
The request data must contain the property `verbosity` with an integer number as the value, and it may also contain a boolean `special` property.
|
The request data must contain the property `verbosity` with an integer number as the value, and it may also contain a boolean `special` property.
|
||||||
It also defines that the response sent may contain a `report` property mapping to an array of strings.
|
The response must contain a `report` property containing an array of strings.
|
||||||
|
|
||||||
The following properties are recognized as part of a message class:
|
The following properties are recognized as part of a message class:
|
||||||
|
|
||||||
@@ -477,6 +656,34 @@ If the target is multiple hops away it the message will be forwarded towards its
|
|||||||
For example a message sent from a control connection an instance will be forwarded by the controller to the relevant host and from the host to the correct instance.
|
For example a message sent from a control connection an instance will be forwarded by the controller to the relevant host and from the host to the correct instance.
|
||||||
Messages sent to a different destination will be rejected.
|
Messages sent to a different destination will be rejected.
|
||||||
|
|
||||||
|
#### static permission
|
||||||
|
|
||||||
|
Required for request and event classes whose `src` includes `"control"`.
|
||||||
|
Use a permission name, a synchronous permission-checking function, or `null` when every authenticated control client is deliberately allowed to send the message.
|
||||||
|
Registration rejects control-origin messages with no permission declaration.
|
||||||
|
|
||||||
|
A callback receives `(user, message)`, where `message` is the raw wire envelope.
|
||||||
|
It runs on the controller before destination payload validation and deserialization, so `message.data` is untrusted and may be missing or malformed.
|
||||||
|
Check any required payload shape before inspecting fields, and use `user.checkPermission(...)` or throw a `ClientError` such as `PermissionError` for a normal denial.
|
||||||
|
For example, this checks the payload shape needed to choose a permission; the destination schema still validates the full payload:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { ValidationError } from "@clusterio/lib";
|
||||||
|
|
||||||
|
function checkFrobnicatePermission(user, message) {
|
||||||
|
const data = message.data;
|
||||||
|
if (!data || typeof data !== "object" || typeof data.special !== "boolean") {
|
||||||
|
throw new ValidationError("Expected a boolean special field");
|
||||||
|
}
|
||||||
|
user.checkPermission(data.special ? "foo_frobber.special" : "foo_frobber.frobnicate");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To use this callback, declare both permissions, require `special` in the message schema, and set `static permission = checkFrobnicatePermission` on a class that accepts `"control"` sources.
|
||||||
|
The `Frobnicate` example above only accepts instances and therefore does not need a permission declaration.
|
||||||
|
Do not make permission callbacks async: their result is not awaited, and returning `false` does not deny access.
|
||||||
|
Unexpected exceptions are contained and logged as errors; they are not a substitute for intentional permission denials.
|
||||||
|
|
||||||
#### static jsonSchema
|
#### static jsonSchema
|
||||||
|
|
||||||
A JSON schema that specifies what's valid to send in the message.
|
A JSON schema that specifies what's valid to send in the message.
|
||||||
@@ -488,7 +695,8 @@ This should return an object that's valid according to `jsonSchema` and accepted
|
|||||||
|
|
||||||
#### static fromJSON
|
#### static fromJSON
|
||||||
|
|
||||||
Required function to restore the class from a JSON object.
|
Function to restore the class from a JSON object; it must be provided together with `jsonSchema` for messages carrying data.
|
||||||
|
Messages without data can omit both and are constructed without arguments.
|
||||||
This function is called when the message reaches its destination and is expected to return an identical instance of the class.
|
This function is called when the message reaches its destination and is expected to return an identical instance of the class.
|
||||||
I.e. the result of `fromJSON(JSON.parse(JSON.stringify(instance)))` should be a complete copy of the message.
|
I.e. the result of `fromJSON(JSON.parse(JSON.stringify(instance)))` should be a complete copy of the message.
|
||||||
|
|
||||||
@@ -497,56 +705,112 @@ I.e. the result of `fromJSON(JSON.parse(JSON.stringify(instance)))` should be a
|
|||||||
Optional [JSON sterilisable class](/docs/devs/json-serialisable-classes.md) that specifies the response to the request.
|
Optional [JSON sterilisable class](/docs/devs/json-serialisable-classes.md) that specifies the response to the request.
|
||||||
Required properties are `jsonSchema` and `fromJSON`.
|
Required properties are `jsonSchema` and `fromJSON`.
|
||||||
|
|
||||||
|
Test JSON round trips, invalid payloads, allowed source/destination types, and authorization failures when changing messages.
|
||||||
|
Keep registration, schemas, serialization, permissions, and handlers consistent.
|
||||||
|
|
||||||
|
|
||||||
## Sending Link Messages
|
## Sending Link Messages
|
||||||
|
|
||||||
Link messages are sent by calling the `.sendTo()` method on a connection object with the destination and the data you want to send, where the data is an instance of one of the defined message classes in plugin.messages.
|
Link messages are sent by calling the `.sendTo()` method on a connection object with the destination and the data you want to send, where the data is an instance of one of the defined message classes in plugin.messages.
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
import * as messages from "./messages.js";
|
||||||
|
|
||||||
// In an instance entrypoint
|
// In an instance entrypoint
|
||||||
async function frobnicate(instance) {
|
async function frobnicate(instance) {
|
||||||
const response = await instance.sendTo("controller", new messages.Frobnicate({
|
const response = await instance.sendTo("controller", new messages.Frobnicate({
|
||||||
verbosity: 2,
|
verbosity: 2,
|
||||||
special: false,
|
special: false,
|
||||||
}));
|
}));
|
||||||
console.log(response); // { report: [...] }
|
console.log(response); // { report: [...] }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
Because the send and handle methods rely on this, you should always call them directly on the connection object.
|
Register the corresponding handler on the controller:
|
||||||
|
|
||||||
If you extract them into a variable, destructure them, or pass them as unbound callbacks, they lose their this context. The method will eventually crash with an error like Cannot read properties of undefined (reading 'sendRequest').
|
|
||||||
|
|
||||||
While it's fine to type cast a direct method invocation, casting and then storing the result causes that same dangerous loss of context.
|
|
||||||
|
|
||||||
Here is what that looks like in practice:
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// ❌ BAD: The extracted method loses its `this` binding and crashes when called.
|
import * as messages from "./messages.js";
|
||||||
const send = instance.sendTo;
|
|
||||||
send("controller", new messages.Frobnicate({ foo: "bar" }));
|
|
||||||
|
|
||||||
// ✅ GOOD: Call it directly on the connection object. (Cast args/results here if needed).
|
export default async function({ controller }) {
|
||||||
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
controller.handle(messages.Frobnicate, async request => ({
|
||||||
|
report: [`Frobnication verbosity: ${request.verbosity}`],
|
||||||
// ✅ GOOD: Explicitly bind the receiver if you need to pass a method as a value (like a handler).
|
}));
|
||||||
instance.handle(messages.Frobnicate, plugin.handleFrobnicate.bind(plugin));
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The send and handle methods depend on `this`.
|
||||||
|
Call them on their connection object, or explicitly bind them when passing a method as a value:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const send = instance.sendTo.bind(instance);
|
||||||
|
const response = await send("controller", new messages.Frobnicate({ verbosity: 2, special: false }));
|
||||||
|
```
|
||||||
|
|
||||||
|
Extracting an unbound method loses its receiver; storing a returned response does not.
|
||||||
For classes with `static type = "request"` the send method is async and returns a promise that resolves to the response data received from the target it was sent to, or rejects with an error if the request failed.
|
For classes with `static type = "request"` the send method is async and returns a promise that resolves to the response data received from the target it was sent to, or rejects with an error if the request failed.
|
||||||
The destination specification can either be the ID of a particular control, host or instance, or one of the keywords used to send to multiple targets at once.
|
The destination can identify a particular control, host, or instance.
|
||||||
|
The `allHosts`, `allControls`, and `allInstances` broadcast destinations are for events, not requests.
|
||||||
|
|
||||||
Example destinations:
|
Example destinations:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
sendTo("controller", message);
|
connection.sendTo("controller", message);
|
||||||
sendTo("allHosts", message);
|
connection.sendTo("allHosts", event);
|
||||||
sendTo("allControls", message);
|
connection.sendTo("allControls", event);
|
||||||
sendTo("allInstances", message);
|
connection.sendTo("allInstances", event);
|
||||||
sendTo({ hostId: 123 }, message);
|
connection.sendTo({ hostId: 123 }, message);
|
||||||
sendTo({ instanceId: 123 }, message);
|
connection.sendTo({ instanceId: 123 }, message);
|
||||||
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.
|
||||||
@@ -556,20 +820,23 @@ This is signaled to controller plugins via the `prepareHostDisconnect` hook and
|
|||||||
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
|
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
|
||||||
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
|
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
|
||||||
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `HostConnection` class and `Host` class on the controller and host respectively.
|
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `HostConnection` class and `Host` class on the controller and host respectively.
|
||||||
For example the sending of an event from an instance plugin can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
|
For example, an instance plugin can avoid starting a request while the host is disconnected or preparing to disconnect:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
if (host.connected) {
|
if (host.connected) {
|
||||||
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
await instance.sendTo("controller", new messages.Frobnicate({ verbosity: 2, special: false }));
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If the event or request needs to be sent to the controller it can be put into a queue and sent out when the connection is established again.
|
If the event or request needs to be sent to the controller it can be put into a queue and sent out when the connection is established again.
|
||||||
The re-establishement of the connection is notified to plugins via the `connect` event to the `controllerConnectionEvent` and `hostConnectionEvent` hooks.
|
The connection hooks report `connect` for a new session and `resume` for a retained session.
|
||||||
|
Keep application queues separate from messages already accepted into the connector buffer; blindly sending both again can duplicate an operation.
|
||||||
|
|
||||||
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `controllerConnectionEvent` for instance plugins and through `hostConnectionEvent` for controller plugins.
|
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `controllerConnectionEvent` for instance plugins and through `hostConnectionEvent` for controller plugins.
|
||||||
This is raised when the connection between the controller and host in question is lost, most likely due to networking issues.
|
This is raised when the connection between the controller and host in question is lost, most likely due to networking issues.
|
||||||
When in the dropped state the host will keep trying to reconnect to the controller in order to re-establish it, and if successful no events or requests will be lost.
|
When in the dropped state the host retries the connection and attempts to resume the retained session.
|
||||||
|
Successful resumption replays unacknowledged buffered messages; expiry, invalidation, or buffer overflow can instead end the session.
|
||||||
|
This is not an application-level exactly-once guarantee; stateful plugins need their own recovery and duplicate-handling strategy.
|
||||||
However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out.
|
However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out.
|
||||||
This means that if your plugin sends a lot of events or requests, they can end up being queued up in a buffer and sent out all at once the connection is re-estabished.
|
This means that if your plugin sends a lot of events or requests, they can end up being queued up in a buffer and sent out all at once the connection is re-estabished.
|
||||||
To avoid this you should be throtteling and/or stopping your requests/events after `drop` has been raised, and continue back as normal when `resume` is raised.
|
To avoid this you should be throtteling and/or stopping your requests/events after `drop` has been raised, and continue back as normal when `resume` is raised.
|
||||||
@@ -599,7 +866,7 @@ It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_nam
|
|||||||
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
|
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { Counter } from "@clusterio/lib";
|
import { Gauge } from "@clusterio/lib";
|
||||||
|
|
||||||
const barMetric = new Gauge(
|
const barMetric = new Gauge(
|
||||||
"clusterio_foo_frobber_bar_metric", "Bar instance level",
|
"clusterio_foo_frobber_bar_metric", "Bar instance level",
|
||||||
|
|||||||
@@ -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/"])]);
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;"><g transform="translate(0.84,24.491)"><path d="M48.48,19.309l-9.917,0l-1.68,-1.68l0,-10.293l2.64,-2.64l3.118,0l0.002,9.827l2.718,0l0.002,-8.867l0.96,-0.96l3.117,0l1.68,1.68l0,10.293l-2.64,2.64ZM32.828,19.309l-9.918,0l-1.68,-1.68l0,-10.293l2.64,-2.64l3.118,0l0.002,9.827l8.478,0.009l0,2.137l-2.64,2.64ZM17.116,19.309l-9.996,0l-1.68,-1.68l0,-10.293l2.64,-2.64c2.029,0 7.281,0 9.996,0l1.68,1.68c0,1.028 0,2.147 0,2.147l-0.96,0.942l0,0.018l-7.596,0l0,5.04l8.556,0l0,2.146l-2.64,2.64ZM64.301,19.309l-9.997,0l-1.68,-1.68l0,-2.142l0.96,-0.96l7.597,-0.002l0,-0.993l-8.557,0l0,-6.196l1.68,-1.68l0,0l0.96,-0.96c2.029,0 7.282,0 9.997,0l1.68,1.68c0,1.028 0,2.147 0,2.147l-0.96,0.96l-7.597,0.002l0,0.957l8.557,0l0,6.227l-0.96,0.96l0,0l-1.68,1.68ZM76.745,19.309l-3.12,0l-1.68,-1.68l0,-6.216l0.96,-0.96l6.48,0l0,6.216l-2.64,2.64ZM78.185,11.653l-6,0l0,5.877l1.54,1.539l2.921,0l1.539,-1.539l0,-5.877ZM17.017,5.896l-9.798,0l-1.539,1.54l0,10.094l1.539,1.539l9.798,0l1.539,-1.539l0,-1.807l-8.556,0l0,-6.48l8.556,0l0,-1.807l-1.539,-1.54ZM25.788,5.896l-2.778,0l-1.54,1.54l0,10.094l1.54,1.539l9.719,0l1.539,-1.539l0,-1.799l-8.478,-0.008l-0.002,-9.827ZM41.441,5.896l-2.779,0l-1.539,1.54l0,10.094l1.539,1.539l9.719,0l1.539,-1.539l0,-10.094l-1.539,-1.54l-2.778,0l-0.003,9.827l-4.157,0l-0.002,-9.827ZM64.201,5.896l-9.797,0l-0.82,0.82l-0.72,0.72l0,5.856l8.557,0l0,2.432l-8.557,0.003l0,1.803l1.54,1.539l9.797,0l1.54,-1.539l0,-5.888l-8.557,0l0,-2.396l8.557,-0.003l0,-1.807l-1.54,-1.54ZM81.921,9.497l-13.471,0l0,-2.161l2.64,-2.64l10.111,0l1.68,1.68c0,1.034 0,2.161 0,2.161l-0.96,0.96ZM80.141,5.896l-9.912,0l-1.539,1.54l0,1.821l12.991,0l0,-1.821l-1.54,-1.54Z" style="fill:#141414;"/><path d="M73.625,19.309l3.12,0l-3.12,0ZM76.646,19.069l-2.921,0l-1.54,-1.539l0,-5.877l6,0l0,5.877l-1.539,1.539ZM17.017,19.069l-9.798,0l-1.539,-1.539l0,-10.094l1.539,-1.54l9.798,0l1.539,1.54l0,1.807l-8.556,0l0,6.48l8.556,0l0,1.807l-1.539,1.539ZM32.729,19.069l-9.719,0l-1.54,-1.539l0,-10.094l1.54,-1.54l2.778,0l0.002,9.827l8.478,0.008l0,1.799l-1.539,1.539ZM48.381,19.069l-9.719,0l-1.539,-1.539l0,-10.094l1.539,-1.54l2.779,0l0.002,9.827l4.157,0l0.003,-9.827l2.778,0l1.539,1.54l0,10.094l-1.539,1.539ZM64.201,19.069l-9.797,0l-1.54,-1.539l0,-1.803l8.557,-0.003l0,-2.432l-8.557,0l0,-5.856l1.54,-1.54l9.797,0l1.54,1.54l0,1.807l-8.557,0.003l0,2.396l8.557,0l0,5.888l-1.54,1.539ZM78.425,11.413l0,6.216l0,-6.216ZM68.45,7.336l1.68,-1.68l-1.68,1.68c0,1.034 0,2.161 0,2.161l0,-2.161ZM81.681,9.257l-12.991,0l0,-1.821l1.539,-1.54l9.912,0l1.54,1.54l0,1.821Z" style="fill:#d58b36;"/></g><g transform="translate(-71.277,41.509)"><path d="M105.009,19.309l-3.12,0l-1.68,-1.68l0,-10.293l2.64,-2.64c2.029,0 7.281,0 9.996,0l1.68,1.68l0,6.2l-0.96,0.96l-7.596,-0.003l0,4.816l-0.96,0.953l0,0.007ZM111.885,19.309l-3.12,0l0,-3.944l0.96,-0.96l4.8,0l0,2.212l-2.64,2.692ZM96.07,19.309l-9.996,0l-1.68,-1.68l0,-10.293l2.64,-2.64c2.029,0 7.281,0 9.996,0l1.68,1.68c0,1.028 0,2.147 0,2.147l-1.086,0.96l-7.47,0l0,0.959l5.841,0l0,2.13l-0.96,0.96l-4.881,0l0,0.991l8.556,0l0,2.146l-2.64,2.64ZM116.135,11.413l0.96,-0.96l6.481,0l0,5.953l-2.64,2.903l-3.121,0l-1.68,-1.943l0,-5.953ZM137.546,19.309l-2.872,0l0,-1.178c-1.382,-0.123 -2.749,-0.431 -4.028,-0.977l-0.056,-0.024l-1.307,0.804l-2.03,-1.25l1.277,-0.786c-0.81,-0.666 -1.487,-1.522 -1.761,-2.534l-1.749,0l0,-1.768l0.96,-0.96l1.141,0c0.332,-0.616 0.809,-1.156 1.35,-1.61l-1.198,-0.78l0.94,-0.93l2.03,-1.25l1.227,0.755c0.949,-0.424 2.033,-0.741 3.204,-0.922l0,-0.248l0.013,0l0.947,-0.96l2.872,0l0,1.116c1.505,0.156 2.889,0.535 4.059,1.079l1.332,-0.82l2.03,1.25l-1.12,1.059c0.701,0.671 1.181,1.437 1.377,2.261l1.976,0l0,1.768l-0.96,0.962l-0.002,-0.002l-1.327,0c-0.228,0.624 -0.547,1.236 -1.053,1.681l1.109,0.679l-0.968,0.978l-0.015,-0.004l-2.007,1.236l-1.411,-0.868c-0.958,0.432 -1.978,0.728 -3.02,0.908l0,0.375l-0.96,0.93l0,0.03ZM122.376,11.653l-6.001,0l0,5.624l1.55,1.792l2.901,0l1.55,-1.792l0,-5.624ZM113.325,15.605l-4.32,0l0,3.464l2.778,0l1.542,-1.59l0,-1.874ZM95.971,5.896l-9.798,0l-1.539,1.54l0,10.094l1.539,1.539l9.798,0l1.539,-1.539l0,-1.807l-8.556,0l0,-2.431l5.841,0l0,-1.65l-5.841,0l0,-2.399l8.556,0l0,-1.807l-1.539,-1.54ZM111.786,5.896l-9.798,0l-1.539,1.54l0,10.094l1.539,1.539l2.781,0l0,-5.777l8.556,0.004l0,-5.86l-1.539,-1.54ZM140.11,13.349c0.327,-0.363 0.421,-0.95 0.212,-1.53c-0.459,-1.279 -2.339,-2.021 -4.278,-2.122c-0.058,0 -0.058,0 -0.115,0c-2.792,0.201 -3.386,1.31 -3.433,1.781c-0.151,1.505 1.994,2.74 4.45,2.74c1.269,0 2.7,-0.357 3.164,-0.869ZM109.009,11.642l0,-2.398l-4.24,0.002l0,2.396l4.24,0ZM105.969,9.485l0,0.957l2.8,0l0,-0.958l-2.8,0.001ZM122.527,9.449l-6.392,0l0,-2.113Line truncated
|
||||||
|
After Width: | Height: | Size: 6.5 KiB |
+6
-14
@@ -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,24 +42,17 @@
|
|||||||
"@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.70.0",
|
|
||||||
"@typescript-eslint/parser": "^8.70.0",
|
|
||||||
"eslint": "^10.10.0",
|
|
||||||
"eslint-plugin-node": "^11.1.0",
|
|
||||||
"express": "catalog:",
|
"express": "catalog:",
|
||||||
"form-data": "^4.0.6",
|
"form-data": "^4.0.6",
|
||||||
"globals": "^17.12.0",
|
|
||||||
"jsonwebtoken": "catalog:",
|
"jsonwebtoken": "catalog:",
|
||||||
"jszip": "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:",
|
||||||
|
"yazl": "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,10 +416,13 @@ 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);
|
||||||
|
if (!args.recovery) {
|
||||||
|
await lib.loadPluginMigrations(pluginInfos);
|
||||||
|
}
|
||||||
|
|
||||||
const [controllerConfig, controllerConfigLock] = await lib.loadConfigFromArgs(
|
const [controllerConfig, controllerConfigLock] = await lib.loadConfigFromArgs(
|
||||||
args,
|
args,
|
||||||
@@ -471,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
|
||||||
@@ -500,14 +503,6 @@ async function startup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bootstrap() {
|
export function bootstrap() {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(`
|
|
||||||
+==========================================================+
|
|
||||||
I WARNING: This is the development branch for the 2.0 I
|
|
||||||
I version of clusterio. Expect things to break. I
|
|
||||||
+==========================================================+
|
|
||||||
`
|
|
||||||
);
|
|
||||||
startup().catch(err => {
|
startup().catch(err => {
|
||||||
if (err instanceof lib.StartupError || err.code === "InstallationError") {
|
if (err instanceof lib.StartupError || err.code === "InstallationError") {
|
||||||
logger.fatal(`
|
logger.fatal(`
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clusterio/controller",
|
"name": "@clusterio/controller",
|
||||||
"version": "2.0.0-alpha.27",
|
"version": "2.0.3",
|
||||||
"description": "Implementation of Clusterio controller",
|
"description": "Implementation of Clusterio controller",
|
||||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||||
"author": "Hornwitser <github@hornwitser.no>",
|
"author": "Hornwitser <github@hornwitser.no>",
|
||||||
@@ -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": {
|
||||||
@@ -43,7 +43,6 @@
|
|||||||
"express": "catalog:",
|
"express": "catalog:",
|
||||||
"finalhandler": "^2.1.1",
|
"finalhandler": "^2.1.1",
|
||||||
"jsonwebtoken": "catalog:",
|
"jsonwebtoken": "catalog:",
|
||||||
"jszip": "catalog:",
|
|
||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
"winston": "catalog:",
|
"winston": "catalog:",
|
||||||
"winston-daily-rotate-file": "catalog:",
|
"winston-daily-rotate-file": "catalog:",
|
||||||
@@ -52,8 +51,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clusterio/web_ui": "workspace:^",
|
"@clusterio/web_ui": "workspace:^",
|
||||||
"@swc/core": "catalog:",
|
"@types/busboy": "^1.5.5",
|
||||||
"@types/busboy": "^1.5.4",
|
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/express": "catalog:",
|
"@types/express": "catalog:",
|
||||||
"@types/finalhandler": "catalog:",
|
"@types/finalhandler": "catalog:",
|
||||||
@@ -62,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": "^6.1.3",
|
|
||||||
"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
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
this.handle(lib.RoleUpdateRequest, this.handleRoleUpdateRequest.bind(this));
|
this.handle(lib.RoleUpdateRequest, this.handleRoleUpdateRequest.bind(this));
|
||||||
this.handle(lib.RoleGrantDefaultPermissionsRequest, this.handleRoleGrantDefaultPermissionsRequest.bind(this));
|
this.handle(lib.RoleGrantDefaultPermissionsRequest, this.handleRoleGrantDefaultPermissionsRequest.bind(this));
|
||||||
this.handle(lib.RoleDeleteRequest, this.handleRoleDeleteRequest.bind(this));
|
this.handle(lib.RoleDeleteRequest, this.handleRoleDeleteRequest.bind(this));
|
||||||
|
this.handle(lib.NoteListRequest, this.handleNoteListRequest.bind(this));
|
||||||
|
this.handle(lib.NoteSetRequest, this.handleNoteSetRequest.bind(this));
|
||||||
this.handle(lib.UserGetRequest, this.handleUserGetRequest.bind(this));
|
this.handle(lib.UserGetRequest, this.handleUserGetRequest.bind(this));
|
||||||
this.handle(lib.UserListRequest, this.handleUserListRequest.bind(this));
|
this.handle(lib.UserListRequest, this.handleUserListRequest.bind(this));
|
||||||
this.handle(lib.UserCreateRequest, this.handleUserCreateRequest.bind(this));
|
this.handle(lib.UserCreateRequest, this.handleUserCreateRequest.bind(this));
|
||||||
@@ -133,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));
|
||||||
}
|
}
|
||||||
@@ -151,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;
|
||||||
}
|
}
|
||||||
@@ -193,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."
|
||||||
@@ -296,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
|
||||||
@@ -313,6 +320,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
|
|
||||||
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
|
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
|
||||||
await this._controller.instances.deleteInstance(request.instanceId);
|
await this._controller.instances.deleteInstance(request.instanceId);
|
||||||
|
this._controller.deleteNote("instance", request.instanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
|
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
|
||||||
@@ -322,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,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(() => {});
|
||||||
@@ -402,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.
|
||||||
@@ -423,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
|
||||||
@@ -437,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.
|
||||||
@@ -477,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);
|
||||||
}
|
}
|
||||||
@@ -488,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);
|
||||||
}
|
}
|
||||||
@@ -497,9 +505,10 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleModGetRequest(request: lib.ModGetRequest) {
|
async handleModGetRequest(request: lib.ModGetRequest) {
|
||||||
@@ -578,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;
|
||||||
@@ -625,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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,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 {
|
||||||
@@ -652,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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,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);
|
||||||
}
|
}
|
||||||
@@ -707,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;
|
||||||
}
|
}
|
||||||
@@ -863,6 +870,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
|
|
||||||
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
|
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
|
||||||
await this._controller.modStore.deleteMod(request.name, request.version);
|
await this._controller.modStore.deleteMod(request.name, request.version);
|
||||||
|
this._controller.deleteNote("mod", `${request.name}_${request.version}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleLogSetSubscriptionsRequest(request: lib.LogSetSubscriptionsRequest) {
|
async handleLogSetSubscriptionsRequest(request: lib.LogSetSubscriptionsRequest) {
|
||||||
@@ -946,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;
|
||||||
@@ -958,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();
|
||||||
@@ -970,9 +978,10 @@ 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);
|
||||||
|
|
||||||
for (const user of this._controller.users.values()) {
|
for (const user of this._controller.users.values()) {
|
||||||
if (user.removeRole(id)) {
|
if (user.removeRole(id)) {
|
||||||
@@ -981,11 +990,23 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleNoteListRequest() {
|
||||||
|
return [...this._controller.notes.values()].filter(note => !note.isDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleNoteSetRequest(request: lib.NoteSetRequest) {
|
||||||
|
const { resourceType, resourceId, content } = request;
|
||||||
|
if (!resourceType || !resourceId) {
|
||||||
|
throw new lib.ValidationError("resourceType and resourceId must not be empty");
|
||||||
|
}
|
||||||
|
this._controller.setNote(resourceType, resourceId, content, this.user.name);
|
||||||
|
}
|
||||||
|
|
||||||
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.UserDetails> {
|
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.UserDetails> {
|
||||||
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();
|
||||||
@@ -1002,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");
|
||||||
@@ -1019,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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1074,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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,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);
|
||||||
@@ -1095,6 +1116,7 @@ export default class ControlConnection extends BaseConnection {
|
|||||||
controlConnection.connector.terminate();
|
controlConnection.connector.terminate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this._controller.deleteNote("user", user.id);
|
||||||
|
|
||||||
if (user.isAdmin) {
|
if (user.isAdmin) {
|
||||||
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
|
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
|
||||||
@@ -1316,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,46 +169,57 @@ 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
|
||||||
|
for (const instance of instances.values()) {
|
||||||
|
if (instance.config.dirty) {
|
||||||
|
instances.set(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||||
|
path.join(databaseDirectory, "notes.json"),
|
||||||
|
lib.Note.fromJSON.bind(lib.Note),
|
||||||
|
));
|
||||||
|
|
||||||
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 });
|
||||||
@@ -212,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,
|
||||||
@@ -221,7 +248,8 @@ export default class Controller {
|
|||||||
modStore,
|
modStore,
|
||||||
roles,
|
roles,
|
||||||
users,
|
users,
|
||||||
] as const;
|
notes,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -254,11 +282,12 @@ export default class Controller {
|
|||||||
public roles = new lib.SubscribableDatastore<lib.Role>(),
|
public roles = new lib.SubscribableDatastore<lib.Role>(),
|
||||||
/** Mapping of user id to user record */
|
/** Mapping of user id to user record */
|
||||||
users = new lib.SubscribableDatastore<UserRecord>(),
|
users = new lib.SubscribableDatastore<UserRecord>(),
|
||||||
|
/** Mapping of note id to note attached to a resource */
|
||||||
|
public notes = new lib.SubscribableDatastore<lib.Note>(),
|
||||||
) {
|
) {
|
||||||
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();
|
||||||
@@ -285,6 +314,7 @@ export default class Controller {
|
|||||||
this.subscriptions.handle(lib.ModUpdatesEvent, this.handleModSubscription.bind(this));
|
this.subscriptions.handle(lib.ModUpdatesEvent, this.handleModSubscription.bind(this));
|
||||||
this.subscriptions.handle(lib.UserUpdatesEvent, this.handleUserSubscription.bind(this));
|
this.subscriptions.handle(lib.UserUpdatesEvent, this.handleUserSubscription.bind(this));
|
||||||
this.subscriptions.handle(lib.RoleUpdatesEvent, this.handleRoleSubscription.bind(this));
|
this.subscriptions.handle(lib.RoleUpdatesEvent, this.handleRoleSubscription.bind(this));
|
||||||
|
this.subscriptions.handle(lib.NoteUpdatesEvent, this.handleNoteSubscription.bind(this));
|
||||||
|
|
||||||
// Handle updates for datastores
|
// Handle updates for datastores
|
||||||
this.systems.on("update", this.systemsUpdated.bind(this));
|
this.systems.on("update", this.systemsUpdated.bind(this));
|
||||||
@@ -294,6 +324,7 @@ export default class Controller {
|
|||||||
this.modPacks.on("update", this.modPacksUpdated.bind(this));
|
this.modPacks.on("update", this.modPacksUpdated.bind(this));
|
||||||
this.roles.on("update", this.rolesUpdated.bind(this));
|
this.roles.on("update", this.rolesUpdated.bind(this));
|
||||||
this.users.records.on("update", this.usersUpdated.bind(this));
|
this.users.records.on("update", this.usersUpdated.bind(this));
|
||||||
|
this.notes.on("update", this.notesUpdated.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
get authSecret() {
|
get authSecret() {
|
||||||
@@ -348,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);
|
||||||
}
|
}
|
||||||
@@ -386,33 +417,51 @@ 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 => {
|
||||||
|
const url = lib.redactUrl(req.originalUrl);
|
||||||
logger.log({
|
logger.log({
|
||||||
level: "error",
|
level: "error",
|
||||||
message: "Error while handling HTTP" +
|
message: "Error while handling HTTP" +
|
||||||
`${req.httpVersion} ${req.method} ${req.originalUrl}:\n${err.stack}`,
|
`${req.httpVersion} ${req.method} ${url}:\n${err.stack}`,
|
||||||
meta: {
|
meta: {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
url: req.originalUrl,
|
url,
|
||||||
httpVersion: req.httpVersion,
|
httpVersion: req.httpVersion,
|
||||||
headers: Controller.loggableHeaders(req.headers),
|
headers: Controller.loggableHeaders(req.headers),
|
||||||
query: req.query,
|
query: lib.redactQuery(req.query),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -461,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -542,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 = [];
|
||||||
@@ -617,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);
|
||||||
@@ -627,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;
|
||||||
@@ -737,6 +809,7 @@ export default class Controller {
|
|||||||
this.roles.save(),
|
this.roles.save(),
|
||||||
this.users.records.save(),
|
this.users.records.save(),
|
||||||
this.hooks.save.invoke(),
|
this.hooks.save.invoke(),
|
||||||
|
this.notes.save(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,7 +894,7 @@ export default class Controller {
|
|||||||
}
|
}
|
||||||
return permission;
|
return permission;
|
||||||
});
|
});
|
||||||
return json;
|
return lib.Role.pluginMigrations.reduce(json);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,18 +962,19 @@ export default class Controller {
|
|||||||
const endNs = process.hrtime.bigint();
|
const endNs = process.hrtime.bigint();
|
||||||
const durationMs = (Number(endNs - startNs) / 1e6);
|
const durationMs = (Number(endNs - startNs) / 1e6);
|
||||||
endpointDurationSummary.labels(routePath).observe(durationMs / 1e3);
|
endpointDurationSummary.labels(routePath).observe(durationMs / 1e3);
|
||||||
|
const url = lib.redactUrl(req.originalUrl);
|
||||||
logger.log({
|
logger.log({
|
||||||
level: "http",
|
level: "http",
|
||||||
message: `HTTP${req.httpVersion} ${req.method} ${req.originalUrl}` +
|
message: `HTTP${req.httpVersion} ${req.method} ${url}` +
|
||||||
` (Status ${res.statusCode} in ${durationMs}ms)`,
|
` (Status ${res.statusCode} in ${durationMs}ms)`,
|
||||||
meta: {
|
meta: {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
url: req.originalUrl,
|
url,
|
||||||
statusCode: res.statusCode,
|
statusCode: res.statusCode,
|
||||||
responseTime: durationMs,
|
responseTime: durationMs,
|
||||||
httpVersion: req.httpVersion,
|
httpVersion: req.httpVersion,
|
||||||
headers: Controller.loggableHeaders(req.headers),
|
headers: Controller.loggableHeaders(req.headers),
|
||||||
query: req.query,
|
query: lib.redactQuery(req.query),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -970,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.
|
||||||
@@ -1129,6 +1291,60 @@ export default class Controller {
|
|||||||
return roles.length ? new lib.RoleUpdatesEvent(roles) : null;
|
return roles.length ? new lib.RoleUpdatesEvent(roles) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notesUpdated(notes: lib.Note[]) {
|
||||||
|
this.subscriptions.broadcast(new lib.NoteUpdatesEvent(notes));
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleNoteSubscription(request: lib.SubscriptionRequest) {
|
||||||
|
if (request.action === "unsubscribe") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Deleted notes are kept as tombstones so resubscribing clients learn of the deletion
|
||||||
|
const notes = [...this.notes.values()].filter(
|
||||||
|
note => note.updatedAtMs > request.lastRequestTimeMs && (request.lastRequestTimeMs > 0 || !note.isDeleted),
|
||||||
|
);
|
||||||
|
return notes.length ? new lib.NoteUpdatesEvent(notes) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or remove the note attached to a resource
|
||||||
|
* @param resourceType - Type of resource the note belongs to.
|
||||||
|
* @param resourceId - Id of the resource within its type.
|
||||||
|
* @param content - New text of the note, empty removes the note.
|
||||||
|
* @param updatedBy - Name of the user making the change.
|
||||||
|
*/
|
||||||
|
setNote(resourceType: string, resourceId: string, content: string, updatedBy = "") {
|
||||||
|
const existing = this.notes.getMutable(lib.Note.idFor(resourceType, resourceId));
|
||||||
|
if (content === "") {
|
||||||
|
this.deleteNote(resourceType, resourceId, updatedBy);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
existing.content = content;
|
||||||
|
existing.updatedBy = updatedBy;
|
||||||
|
existing.isDeleted = false;
|
||||||
|
this.notes.set(existing);
|
||||||
|
} else {
|
||||||
|
this.notes.set(new lib.Note(resourceType, resourceId, content, updatedBy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the note attached to a resource, if any
|
||||||
|
* @param resourceType - Type of resource the note belongs to.
|
||||||
|
* @param resourceId - Id of the resource within its type.
|
||||||
|
* @param updatedBy - Name of the user making the change.
|
||||||
|
*/
|
||||||
|
deleteNote(resourceType: string, resourceId: string | number, updatedBy = "") {
|
||||||
|
const note = this.notes.getMutable(lib.Note.idFor(resourceType, resourceId));
|
||||||
|
if (note && !note.isDeleted) {
|
||||||
|
note.content = "";
|
||||||
|
note.updatedBy = updatedBy;
|
||||||
|
note.isDeleted = true;
|
||||||
|
this.notes.set(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadPlugins() {
|
async loadPlugins() {
|
||||||
const context = { metrics, logger, controller: this };
|
const context = { metrics, logger, controller: this };
|
||||||
for (let pluginInfo of this.pluginInfos) {
|
for (let pluginInfo of this.pluginInfos) {
|
||||||
@@ -1259,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);
|
||||||
});
|
});
|
||||||
@@ -1367,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 {
|
||||||
@@ -1468,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,13 @@ export default class WsServer {
|
|||||||
handleConnection(socket: lib.WebSocketClusterio, req: IncomingMessage) {
|
handleConnection(socket: lib.WebSocketClusterio, req: IncomingMessage) {
|
||||||
logger.verbose(`WsServer | new connection from ${this.remoteAddr(req)}`);
|
logger.verbose(`WsServer | new connection from ${this.remoteAddr(req)}`);
|
||||||
|
|
||||||
|
// ws emits errors like invalid frames on the socket, and an error with
|
||||||
|
// no listener crashes the controller. The connector adds its own
|
||||||
|
// listener, but only once the handshake has completed.
|
||||||
|
socket.on("error", (err: Error) => {
|
||||||
|
logger.verbose(`WsServer | error on connection from ${this.remoteAddr(req)}: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
// Track statistics
|
// Track statistics
|
||||||
wsConnectionsCounter.inc();
|
wsConnectionsCounter.inc();
|
||||||
socket.on("message", (message: WebSocket.RawData) => {
|
socket.on("message", (message: WebSocket.RawData) => {
|
||||||
|
|||||||
@@ -144,11 +144,6 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this._socket.on("error", (err: any) => {
|
|
||||||
// It's assumed that close is always called by ws
|
|
||||||
logger.verbose("Connector | Error:", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
this._socket.on("open", () => {
|
this._socket.on("open", () => {
|
||||||
logger.verbose("Connector | Open");
|
logger.verbose("Connector | Open");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import busboy from "busboy";
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import events from "events";
|
import events from "events";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import JSZip from "jszip";
|
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import nodeStream from "stream";
|
import nodeStream from "stream";
|
||||||
@@ -21,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>,
|
||||||
}
|
}
|
||||||
@@ -125,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) {
|
||||||
@@ -141,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);
|
||||||
}
|
}
|
||||||
@@ -266,9 +262,7 @@ async function uploadExport(req: Request, res: Response) {
|
|||||||
for await (let chunk of req) {
|
for await (let chunk of req) {
|
||||||
data.push(chunk);
|
data.push(chunk);
|
||||||
}
|
}
|
||||||
let buffer: Buffer | null = Buffer.concat(data);
|
let zip = await lib.ZipArchive.fromBuffer(Buffer.concat(data));
|
||||||
let zip = await JSZip.loadAsync(buffer);
|
|
||||||
buffer = null;
|
|
||||||
|
|
||||||
// This is hardcoded to prevent path expansion attacks
|
// This is hardcoded to prevent path expansion attacks
|
||||||
let exportFiles = [
|
let exportFiles = [
|
||||||
@@ -288,14 +282,15 @@ async function uploadExport(req: Request, res: Response) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let content = await zip.readEntry(file);
|
||||||
if (filePath === "export/settings.json") {
|
if (filePath === "export/settings.json") {
|
||||||
settingPrototypes = JSON.parse(await file.async("text"));
|
settingPrototypes = JSON.parse(content.toString("utf8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let { name, ext } = path.posix.parse(filePath);
|
let { name, ext } = path.posix.parse(filePath);
|
||||||
let hash = await lib.hashStream(file.nodeStream());
|
let hash = await lib.hashStream(await zip.openStream(file));
|
||||||
assets[name] = `${name}.${hash}${ext}`;
|
assets[name] = `${name}.${hash}${ext}`;
|
||||||
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
|
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), content);
|
||||||
}
|
}
|
||||||
|
|
||||||
modPack.exportManifest = new lib.ExportManifest(assets, new Date().toISOString());
|
modPack.exportManifest = new lib.ExportManifest(assets, new Date().toISOString());
|
||||||
@@ -310,7 +305,7 @@ type ProxyStreamEvents = {
|
|||||||
"close": [],
|
"close": [],
|
||||||
"timeout": [],
|
"timeout": [],
|
||||||
"source": [],
|
"source": [],
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface ProxyStream {
|
export interface ProxyStream {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -550,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 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -2,4 +2,5 @@
|
|||||||
|
|
||||||
Installer for Clusterio.
|
Installer for Clusterio.
|
||||||
|
|
||||||
See [the Clusterio README.md](https://github.com/clusterio/clusterio#readme) the for installation guide.
|
Install the latest [Node.js LTS release](https://nodejs.org/en/download) before running the installer.
|
||||||
|
See [the Clusterio installation guide](https://github.com/clusterio/clusterio#installation) for platform-specific instructions.
|
||||||
@@ -30,15 +30,12 @@ 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: "Inventory Sync", value: "@clusterio/plugin-inventory_sync" },
|
{ 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: "Research Sync", value: "@clusterio/plugin-research_sync" },
|
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
|
||||||
{ name: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" },
|
{ name: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" },
|
||||||
{ name: "Subspace Storage", value: "@clusterio/plugin-subspace_storage" },
|
{ name: "Subspace Storage", value: "@clusterio/plugin-subspace_storage" },
|
||||||
|
|
||||||
// Comunity plugins
|
|
||||||
{ name: "Discord Bridge", value: "@hornwitser/discord_bridge" },
|
|
||||||
{ name: "Server Select", value: "@hornwitser/server_select" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const factorioLocations = {
|
const factorioLocations = {
|
||||||
@@ -392,31 +389,7 @@ async function installClusterio(mode, plugins) {
|
|||||||
throw new InstallError(`Failed to install: ${err.message}`);
|
throw new InstallError(`Failed to install: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plugins.length) {
|
// Controller, host and ctl discover installed plugins when loading their configuration.
|
||||||
logger.info("Setting up plugins");
|
|
||||||
let pluginList;
|
|
||||||
try {
|
|
||||||
pluginList = new Map(JSON.parse(await fs.readFile("plugin-list.json")));
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code === "ENOENT") {
|
|
||||||
pluginList = new Map();
|
|
||||||
} else {
|
|
||||||
throw new InstallError(`Error loading plugin-list.json: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let plugin of plugins) {
|
|
||||||
if (!pluginList.has(plugin)) {
|
|
||||||
|
|
||||||
let pluginInfo = (await import(plugin)).plugin;
|
|
||||||
pluginList.set(pluginInfo.name, plugin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, "\t"));
|
|
||||||
} catch (err) {
|
|
||||||
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function groupIdToName(gid) {
|
async function groupIdToName(gid) {
|
||||||
@@ -905,6 +878,7 @@ async function main() {
|
|||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch(err => {
|
main().catch(err => {
|
||||||
|
process.exitCode = 1;
|
||||||
if (err instanceof InstallError) {
|
if (err instanceof InstallError) {
|
||||||
logger.error(err.message);
|
logger.error(err.message);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clusterio/create",
|
"name": "@clusterio/create",
|
||||||
"version": "2.0.0-alpha.27",
|
"version": "2.0.3",
|
||||||
"description": "Installer for clusterio",
|
"description": "Installer for clusterio",
|
||||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||||
"author": "Hornwitser <github@hornwitser.no>",
|
"author": "Hornwitser <github@hornwitser.no>",
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "catalog:",
|
"chalk": "catalog:",
|
||||||
"inquirer": "^8.2.4",
|
"inquirer": "^8.2.7",
|
||||||
"yargs": "catalog:"
|
"yargs": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
-14
@@ -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 {
|
||||||
@@ -327,14 +327,6 @@ async function startCtl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bootstrap() {
|
export function bootstrap() {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(`
|
|
||||||
+==========================================================+
|
|
||||||
I WARNING: This is the development branch for the 2.0 I
|
|
||||||
I version of clusterio. Expect things to break. I
|
|
||||||
+==========================================================+
|
|
||||||
`
|
|
||||||
);
|
|
||||||
startCtl().catch(err => {
|
startCtl().catch(err => {
|
||||||
if (err.errors) {
|
if (err.errors) {
|
||||||
logger.fatal(JSON.stringify(err.errors, null, "\t"));
|
logger.fatal(JSON.stringify(err.errors, null, "\t"));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clusterio/ctl",
|
"name": "@clusterio/ctl",
|
||||||
"version": "2.0.0-alpha.27",
|
"version": "2.0.3",
|
||||||
"description": "Clusterio command line management tool",
|
"description": "Clusterio command line management tool",
|
||||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||||
"author": "Hornwitser <github@hornwitser.no>",
|
"author": "Hornwitser <github@hornwitser.no>",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { modPackCommands } from "./commands_mod_pack.js";
|
|||||||
import { modCommands } from "./commands_mod.js";
|
import { modCommands } from "./commands_mod.js";
|
||||||
import { permissionCommands } from "./commands_permission.js";
|
import { permissionCommands } from "./commands_permission.js";
|
||||||
import { roleCommands } from "./commands_role.js";
|
import { roleCommands } from "./commands_role.js";
|
||||||
|
import { noteCommands } from "./commands_note.js";
|
||||||
import { userCommands } from "./commands_user.js";
|
import { userCommands } from "./commands_user.js";
|
||||||
import { logCommands } from "./commands_log.js";
|
import { logCommands } from "./commands_log.js";
|
||||||
import { debugCommands } from "./commands_debug.js";
|
import { debugCommands } from "./commands_debug.js";
|
||||||
@@ -22,6 +23,7 @@ export async function registerCommands(ctlHooks: CtlHooks, yargs: Argv) {
|
|||||||
rootCommands.add(modCommands);
|
rootCommands.add(modCommands);
|
||||||
rootCommands.add(permissionCommands);
|
rootCommands.add(permissionCommands);
|
||||||
rootCommands.add(roleCommands);
|
rootCommands.add(roleCommands);
|
||||||
|
rootCommands.add(noteCommands);
|
||||||
rootCommands.add(userCommands);
|
rootCommands.add(userCommands);
|
||||||
rootCommands.add(logCommands);
|
rootCommands.add(logCommands);
|
||||||
rootCommands.add(debugCommands);
|
rootCommands.add(debugCommands);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import asTableModule from "as-table";
|
||||||
|
|
||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
import type { Ctl } from "../ctl.js";
|
||||||
|
import { print } from "./command_ops.js";
|
||||||
|
|
||||||
|
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||||
|
|
||||||
|
export const noteCommands = new lib.CommandTree({
|
||||||
|
name: "note", description: "Notes attached to hosts, instances, users and other resources",
|
||||||
|
});
|
||||||
|
noteCommands.add(new lib.Command({
|
||||||
|
definition: [["list", "l"], "List notes in the cluster", (yargs) => {
|
||||||
|
yargs.options({
|
||||||
|
"type": { describe: "Only list notes for this resource type", nargs: 1, type: "string" },
|
||||||
|
});
|
||||||
|
}],
|
||||||
|
handler: async function(args: { type?: string }, ctl: Ctl) {
|
||||||
|
let notes = await ctl.send(new lib.NoteListRequest());
|
||||||
|
if (args.type !== undefined) {
|
||||||
|
notes = notes.filter(note => note.resourceType === args.type);
|
||||||
|
}
|
||||||
|
print(asTable(notes.map(note => ({
|
||||||
|
type: note.resourceType,
|
||||||
|
id: note.resourceId,
|
||||||
|
updatedBy: note.updatedBy,
|
||||||
|
content: note.content,
|
||||||
|
}))));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
noteCommands.add(new lib.Command({
|
||||||
|
definition: ["get <type> <id>", "Show the note of a resource", (yargs) => {
|
||||||
|
yargs.positional("type", {
|
||||||
|
describe: "Resource type, one of host, instance, user, role, mod_pack, mod, save", type: "string",
|
||||||
|
});
|
||||||
|
yargs.positional("id", { describe: "Id of the resource", type: "string" });
|
||||||
|
}],
|
||||||
|
handler: async function(args: { type: string, id: string }, ctl: Ctl) {
|
||||||
|
const notes = await ctl.send(new lib.NoteListRequest());
|
||||||
|
const note = notes.find(n => n.resourceType === args.type && n.resourceId === args.id);
|
||||||
|
if (!note) {
|
||||||
|
throw new lib.CommandError(`No note for ${args.type} ${args.id}`);
|
||||||
|
}
|
||||||
|
print(note.content);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
noteCommands.add(new lib.Command({
|
||||||
|
definition: ["set <type> <id> <content>", "Set the note of a resource", (yargs) => {
|
||||||
|
yargs.positional("type", {
|
||||||
|
describe: "Resource type, one of host, instance, user, role, mod_pack, mod, save", type: "string",
|
||||||
|
});
|
||||||
|
yargs.positional("id", { describe: "Id of the resource", type: "string" });
|
||||||
|
yargs.positional("content", { describe: "Text of the note", type: "string" });
|
||||||
|
}],
|
||||||
|
handler: async function(args: { type: string, id: string, content: string }, ctl: Ctl) {
|
||||||
|
await ctl.send(new lib.NoteSetRequest(args.type, args.id, args.content));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
noteCommands.add(new lib.Command({
|
||||||
|
definition: ["delete <type> <id>", "Remove the note of a resource", (yargs) => {
|
||||||
|
yargs.positional("type", {
|
||||||
|
describe: "Resource type, one of host, instance, user, role, mod_pack, mod, save", type: "string",
|
||||||
|
});
|
||||||
|
yargs.positional("id", { describe: "Id of the resource", type: "string" });
|
||||||
|
}],
|
||||||
|
handler: async function(args: { type: string, id: string }, ctl: Ctl) {
|
||||||
|
await ctl.send(new lib.NoteSetRequest(args.type, args.id, ""));
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,9 +119,9 @@ By default the freeplay scenario from the latest Factorio install in `host.facto
|
|||||||
* `--zip` Write the scenario as a zip file instead of a directory.
|
* `--zip` Write the scenario as a zip file instead of a directory.
|
||||||
Factorio does not load zipped scenarios, extract it before use.
|
Factorio does not load zipped scenarios, extract it before use.
|
||||||
|
|
||||||
For example, creating a scenario with only the inventory_sync module:
|
For example, creating a scenario with only the player_sync module:
|
||||||
|
|
||||||
npx clusteriohost create-scenario ~/.factorio/scenarios/inventory_sync --plugins inventory_sync
|
npx clusteriohost create-scenario ~/.factorio/scenarios/player_sync --plugins player_sync
|
||||||
|
|
||||||
|
|
||||||
#### `config list`
|
#### `config list`
|
||||||
|
|||||||
+8
-13
@@ -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,10 +174,13 @@ 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);
|
||||||
|
if (!args.recovery) {
|
||||||
|
await lib.loadPluginMigrations(pluginInfos);
|
||||||
|
}
|
||||||
|
|
||||||
const [hostConfig, hostConfigLock] = await lib.loadConfigFromArgs(
|
const [hostConfig, hostConfigLock] = await lib.loadConfigFromArgs(
|
||||||
args,
|
args,
|
||||||
@@ -230,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
|
||||||
@@ -264,14 +267,6 @@ async function startHost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bootstrap() {
|
export function bootstrap() {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(`
|
|
||||||
+==========================================================+
|
|
||||||
I WARNING: This is the development branch for the 2.0 I
|
|
||||||
I version of clusterio. Expect things to break. I
|
|
||||||
+==========================================================+
|
|
||||||
`
|
|
||||||
);
|
|
||||||
startHost().catch(err => {
|
startHost().catch(err => {
|
||||||
if (err instanceof lib.WebSocketError) {
|
if (err instanceof lib.WebSocketError) {
|
||||||
// Already handled by connector.on("error") within Host
|
// Already handled by connector.on("error") within Host
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -38,17 +38,20 @@ function api.get_instance_id()
|
|||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local function escape_channel(channel)
|
||||||
|
-- Escape bad characters. The question mark is used for separating the
|
||||||
|
-- channel name from the payload.
|
||||||
|
return channel:gsub("([\x00-\x1f?\\])", function(match)
|
||||||
|
return "\\x" .. string.format("%02x", match:byte())
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
function api.send_json(channel, data)
|
function api.send_json(channel, data)
|
||||||
if not remote.interfaces.clusterio_api then
|
if not remote.interfaces.clusterio_api then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Escape bad characters. The question mark is used for separating the
|
channel = escape_channel(channel)
|
||||||
-- channel name from the payload.
|
|
||||||
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
|
|
||||||
return "\\x" .. string.format("%02x", match:byte())
|
|
||||||
end)
|
|
||||||
|
|
||||||
data = compat.table_to_json(data)
|
data = compat.table_to_json(data)
|
||||||
|
|
||||||
-- If there's more than about 4kB of data users running with the Windows
|
-- If there's more than about 4kB of data users running with the Windows
|
||||||
@@ -64,5 +67,14 @@ function api.send_json(channel, data)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function api.send_udp(channel, data)
|
||||||
|
local port = call_api("get_host_udp_port")
|
||||||
|
if not port then
|
||||||
|
return -- Lua UDP is not enabled for this instance
|
||||||
|
end
|
||||||
|
|
||||||
|
helpers.send_udp(port, escape_channel(channel) .. "?" .. data, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
return api
|
return api
|
||||||
@@ -1,4 +1,29 @@
|
|||||||
---------------------------------------------------------------------------------------------------
|
---------------------------------------------------------------------------------------------------
|
||||||
|
Version: 2.1.21
|
||||||
|
Date: 2026-10-14
|
||||||
|
Changes:
|
||||||
|
- Removed the obsolete alpha label and development warning from the mod title and description.
|
||||||
|
---------------------------------------------------------------------------------------------------
|
||||||
|
Version: 2.1.20
|
||||||
|
Date: 2026-10-14
|
||||||
|
Changes:
|
||||||
|
- Removed the obsolete alpha label and development warning from the mod title and description.
|
||||||
|
---------------------------------------------------------------------------------------------------
|
||||||
|
Version: 2.1.18
|
||||||
|
Date: 2026-10-14
|
||||||
|
Changes:
|
||||||
|
- Removed the obsolete alpha label and development warning from the mod title and description.
|
||||||
|
---------------------------------------------------------------------------------------------------
|
||||||
|
Version: 2.1.17
|
||||||
|
Date: 2026-10-14
|
||||||
|
Changes:
|
||||||
|
- Removed the obsolete alpha label and development warning from the mod title and description.
|
||||||
|
---------------------------------------------------------------------------------------------------
|
||||||
|
Version: 2.1.11
|
||||||
|
Date: 2026-10-14
|
||||||
|
Changes:
|
||||||
|
- Removed the obsolete alpha label and development warning from the mod title and description.
|
||||||
|
---------------------------------------------------------------------------------------------------
|
||||||
Version: 2.0.20
|
Version: 2.0.20
|
||||||
Date: 25. 02. 2025
|
Date: 25. 02. 2025
|
||||||
Features:
|
Features:
|
||||||
|
|||||||
@@ -2,30 +2,30 @@
|
|||||||
"name": "clusterio_lib",
|
"name": "clusterio_lib",
|
||||||
"variants": [
|
"variants": [
|
||||||
{
|
{
|
||||||
"version": "2.0.17",
|
"version": "2.1.17",
|
||||||
"factorio_version": "0.17"
|
"factorio_version": "0.17"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "2.0.18",
|
"version": "2.1.18",
|
||||||
"factorio_version": "0.18"
|
"factorio_version": "0.18"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "2.0.11",
|
"version": "2.1.11",
|
||||||
"factorio_version": "1.1"
|
"factorio_version": "1.1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "2.0.20",
|
"version": "2.1.20",
|
||||||
"factorio_version": "2.0"
|
"factorio_version": "2.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "2.0.21",
|
"version": "2.1.21",
|
||||||
"factorio_version": "2.1"
|
"factorio_version": "2.1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Clusterio Library (Alpha)",
|
"title": "Clusterio Library",
|
||||||
"author": "Clusterio Team",
|
"author": "Clusterio Team",
|
||||||
"homepage": "https://github.com/clusterio/clusterio",
|
"homepage": "https://github.com/clusterio/clusterio",
|
||||||
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
|
"description": "Library for interfacing with Clusterio.",
|
||||||
"additional_files": {
|
"additional_files": {
|
||||||
"compat.lua": ["..", "..", "modules", "clusterio", "compat.lua"],
|
"compat.lua": ["..", "..", "modules", "clusterio", "compat.lua"],
|
||||||
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"],
|
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"],
|
||||||
|
|||||||
@@ -17,14 +17,16 @@ function api.get_instance_id()
|
|||||||
return compat.script_data.clusterio.instance_id
|
return compat.script_data.clusterio.instance_id
|
||||||
end
|
end
|
||||||
|
|
||||||
function api.send_json(channel, data)
|
local function escape_channel(channel)
|
||||||
|
|
||||||
-- Escape bad characters. The question mark is used for separating the
|
-- Escape bad characters. The question mark is used for separating the
|
||||||
-- channel name from the payload.
|
-- channel name from the payload.
|
||||||
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
|
return channel:gsub("([\x00-\x1f?\\])", function(match)
|
||||||
return "\\x" .. string.format("%02x", match:byte())
|
return "\\x" .. string.format("%02x", match:byte())
|
||||||
end)
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function api.send_json(channel, data)
|
||||||
|
channel = escape_channel(channel)
|
||||||
data = compat.table_to_json(data)
|
data = compat.table_to_json(data)
|
||||||
|
|
||||||
-- If there's more than about 4kB of data users running with the Windows
|
-- If there's more than about 4kB of data users running with the Windows
|
||||||
@@ -41,5 +43,15 @@ function api.send_json(channel, data)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function api.send_udp(channel, data)
|
||||||
|
local clusterio = compat.script_data.clusterio
|
||||||
|
local port = clusterio and clusterio.host_udp_port
|
||||||
|
if not port then
|
||||||
|
return -- Lua UDP is not enabled for this instance
|
||||||
|
end
|
||||||
|
|
||||||
|
helpers.send_udp(port, escape_channel(channel) .. "?" .. data, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
return api
|
return api
|
||||||
@@ -12,16 +12,27 @@ local function check_patch()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Added in Factorio 2.0.59, indexing a missing key on a LuaObject errors
|
||||||
|
local has_recv_udp = helpers ~= nil and pcall(function() return helpers.recv_udp end)
|
||||||
|
|
||||||
local impl = {}
|
local impl = {}
|
||||||
impl.events = {}
|
impl.events = {}
|
||||||
|
|
||||||
impl.events[defines.events.on_tick] = check_patch
|
impl.events[defines.events.on_tick] = function()
|
||||||
|
check_patch()
|
||||||
|
-- Dispatch on_udp_packet_received for packets sent by the host, this is
|
||||||
|
-- a no-op when the server was started without --enable-lua-udp
|
||||||
|
if has_recv_udp then
|
||||||
|
helpers.recv_udp(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
impl.events[api.events.on_server_startup] = function()
|
impl.events[api.events.on_server_startup] = function()
|
||||||
if not compat.script_data.clusterio then
|
if not compat.script_data.clusterio then
|
||||||
compat.script_data.clusterio = {
|
compat.script_data.clusterio = {
|
||||||
instance_id = nil,
|
instance_id = nil,
|
||||||
instance_name = nil,
|
instance_name = nil,
|
||||||
|
host_udp_port = nil,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -74,11 +85,12 @@ end
|
|||||||
|
|
||||||
-- Internal API
|
-- Internal API
|
||||||
clusterio_private = {}
|
clusterio_private = {}
|
||||||
function clusterio_private.update_instance(new_id, new_name)
|
function clusterio_private.update_instance(new_id, new_name, host_udp_port)
|
||||||
check_patch()
|
check_patch()
|
||||||
local script_data = compat.script_data
|
local script_data = compat.script_data
|
||||||
script_data.clusterio.instance_id = new_id
|
script_data.clusterio.instance_id = new_id
|
||||||
script_data.clusterio.instance_name = new_name
|
script_data.clusterio.instance_name = new_name
|
||||||
|
script_data.clusterio.host_udp_port = host_udp_port
|
||||||
script.raise_event(api.events.on_instance_updated, {
|
script.raise_event(api.events.on_instance_updated, {
|
||||||
instance_id = new_id,
|
instance_id = new_id,
|
||||||
instance_name = new_name,
|
instance_name = new_name,
|
||||||
@@ -105,6 +117,12 @@ remote.add_interface('clusterio_api', {
|
|||||||
return compat.script_data.clusterio.instance_name
|
return compat.script_data.clusterio.instance_name
|
||||||
end,
|
end,
|
||||||
|
|
||||||
|
get_host_udp_port = function()
|
||||||
|
-- Not initialised until the first tick after the save is patched
|
||||||
|
local clusterio = compat.script_data.clusterio
|
||||||
|
return clusterio and clusterio.host_udp_port
|
||||||
|
end,
|
||||||
|
|
||||||
get_file_no = function()
|
get_file_no = function()
|
||||||
local script_data = compat.script_data
|
local script_data = compat.script_data
|
||||||
script_data.clusterio_file_no = (script_data.clusterio_file_no or 0) + 1
|
script_data.clusterio_file_no = (script_data.clusterio_file_no or 0) + 1
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ end
|
|||||||
|
|
||||||
local has_create_grid = compat.version_ge("1.1.7")
|
local has_create_grid = compat.version_ge("1.1.7")
|
||||||
local has_quality = compat.version_ge("2.0.0")
|
local has_quality = compat.version_ge("2.0.0")
|
||||||
|
local has_spoilage = compat.version_ge("2.0.0")
|
||||||
|
|
||||||
-- Equipment Grids are serialized into an array of equipment entries
|
-- Equipment Grids are serialized into an array of equipment entries
|
||||||
-- where ench entry is a table with the following fields:
|
-- where ench entry is a table with the following fields:
|
||||||
@@ -106,6 +107,7 @@ end
|
|||||||
-- c: count
|
-- c: count
|
||||||
-- q: quality (optional)
|
-- q: quality (optional)
|
||||||
-- h: health (optional)
|
-- h: health (optional)
|
||||||
|
-- p: spoil percent (optional)
|
||||||
-- d: durability (optional)
|
-- d: durability (optional)
|
||||||
-- a: ammo count (optional)
|
-- a: ammo count (optional)
|
||||||
-- l: label (optional)
|
-- l: label (optional)
|
||||||
@@ -139,6 +141,7 @@ function serialize.serialize_item_stack(slot, entry)
|
|||||||
entry.c = slot.count
|
entry.c = slot.count
|
||||||
if has_quality and slot.quality.level ~= 0 then entry.q = slot.quality.name end
|
if has_quality and slot.quality.level ~= 0 then entry.q = slot.quality.name end
|
||||||
if slot.health < 1 then entry.h = slot.health end
|
if slot.health < 1 then entry.h = slot.health end
|
||||||
|
if has_spoilage and slot.spoil_percent > 0 then entry.p = slot.spoil_percent end
|
||||||
if slot.type == "tool" and slot.durability then entry.d = slot.durability end
|
if slot.type == "tool" and slot.durability then entry.d = slot.durability end
|
||||||
if slot.type == "ammo" then entry.a = slot.ammo end
|
if slot.type == "ammo" then entry.a = slot.ammo end
|
||||||
if slot.is_item_with_label then
|
if slot.is_item_with_label then
|
||||||
@@ -177,6 +180,7 @@ function serialize.deserialize_item_stack(slot, entry)
|
|||||||
}
|
}
|
||||||
if entry.q then item_stack.quality = entry.q end
|
if entry.q then item_stack.quality = entry.q end
|
||||||
if entry.h then item_stack.health = entry.h end
|
if entry.h then item_stack.health = entry.h end
|
||||||
|
if entry.p then item_stack.spoil_percent = entry.p end
|
||||||
if entry.d then item_stack.durability = entry.d end
|
if entry.d then item_stack.durability = entry.d end
|
||||||
if entry.a then item_stack.ammo = entry.a end
|
if entry.a then item_stack.ammo = entry.a end
|
||||||
|
|
||||||
@@ -288,10 +292,10 @@ function serialize.deserialize_inventory(inventory, serialized)
|
|||||||
if entry.f then
|
if entry.f then
|
||||||
local call_success, call_return = pcall(inventory.set_filter, index, entry.f)
|
local call_success, call_return = pcall(inventory.set_filter, index, entry.f)
|
||||||
if not call_success then
|
if not call_success then
|
||||||
print("Error: '" .. call_return .. "' thrown setting filter " .. entry.f)
|
print("Error: '" .. call_return .. "' thrown setting filter " .. serpent.line(entry.f))
|
||||||
|
|
||||||
elseif not call_return then
|
elseif not call_return then
|
||||||
print("Error: Failed to set filter " .. entry.f)
|
print("Error: Failed to set filter " .. serpent.line(entry.f))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clusterio/host",
|
"name": "@clusterio/host",
|
||||||
"version": "2.0.0-alpha.27",
|
"version": "2.0.3",
|
||||||
"description": "Implementation of Clusterio host server",
|
"description": "Implementation of Clusterio host server",
|
||||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||||
"author": "Hornwitser <github@hornwitser.no>",
|
"author": "Hornwitser <github@hornwitser.no>",
|
||||||
@@ -40,14 +40,14 @@
|
|||||||
"@clusterio/lib": "workspace:^",
|
"@clusterio/lib": "workspace:^",
|
||||||
"@sinclair/typebox": "catalog:",
|
"@sinclair/typebox": "catalog:",
|
||||||
"jimp": "^1.6.1",
|
"jimp": "^1.6.1",
|
||||||
"jszip": "catalog:",
|
"pidusage": "^4.1.0",
|
||||||
"pidusage": "^4.0.1",
|
|
||||||
"rcon-client": "^4.2.5",
|
"rcon-client": "^4.2.5",
|
||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
"set-blocking": "catalog:",
|
"set-blocking": "catalog:",
|
||||||
"winston": "catalog:",
|
"winston": "catalog:",
|
||||||
"winston-daily-rotate-file": "catalog:",
|
"winston-daily-rotate-file": "catalog:",
|
||||||
"yargs": "catalog:"
|
"yargs": "catalog:",
|
||||||
|
"yazl": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
"@types/set-blocking": "catalog:",
|
"@types/set-blocking": "catalog:",
|
||||||
"@types/yargs": "catalog:",
|
"@types/yargs": "catalog:",
|
||||||
|
"@types/yazl": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
@@ -259,6 +290,22 @@ export class BaseInstancePlugin {
|
|||||||
return await this.instance.sendRcon(message, expectEmpty, this.info.name);
|
return await this.instance.sendRcon(message, expectEmpty, this.info.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send UDP message to instance
|
||||||
|
*
|
||||||
|
* Send a UDP packet to the Factorio server, which is delivered in-game
|
||||||
|
* as an on_udp_packet_received event. Delivery is not guaranteed and
|
||||||
|
* the payload should be kept well below 64 kB. Requires the LuaUdp
|
||||||
|
* plugin feature.
|
||||||
|
*
|
||||||
|
* This should not be called before onStart or after onStop.
|
||||||
|
*
|
||||||
|
* @param message - payload of the packet to send.
|
||||||
|
*/
|
||||||
|
async sendUdp(message: string | Buffer): Promise<void> {
|
||||||
|
await this.instance.sendUdp(message);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send serially ordered RCON message to instance
|
* Send serially ordered RCON message to instance
|
||||||
*
|
*
|
||||||
|
|||||||
+220
-77
@@ -1,3 +1,4 @@
|
|||||||
|
import dgram from "node:dgram";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import pidusage from "pidusage";
|
import pidusage from "pidusage";
|
||||||
@@ -14,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";
|
||||||
|
|
||||||
|
|
||||||
@@ -21,10 +23,70 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that no socket is bound to the given UDP port, on IPv4 or IPv6
|
||||||
|
*
|
||||||
|
* Factorio binds the game port on both 0.0.0.0 and [::].
|
||||||
|
*
|
||||||
|
* @param port - UDP port to check.
|
||||||
|
* @returns true if the port can be bound.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
async function udpPortFree(port: number) {
|
||||||
|
for (const type of ["udp4", "udp6"] as const) {
|
||||||
|
const socket = dgram.createSocket({ type, ipv6Only: type === "udp6" });
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.once("error", reject);
|
||||||
|
socket.bind(port, resolve);
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
// Other errors, such as no IPv6 support on the host, don't stop Factorio from binding the port.
|
||||||
|
if (err.code === "EADDRINUSE") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Searches for instances in the provided directory
|
* Searches for instances in the provided directory
|
||||||
*
|
*
|
||||||
@@ -142,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;
|
||||||
@@ -204,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;
|
||||||
}
|
}
|
||||||
@@ -218,10 +280,8 @@ export class HostRouter {
|
|||||||
this.host._connectInstance(dst.id).then(instanceConnection => {
|
this.host._connectInstance(dst.id).then(instanceConnection => {
|
||||||
instanceConnection.connector.forward(message);
|
instanceConnection.connector.forward(message);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
logger.error(`Error starting instance:\n${err.stack}`, 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
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,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) {
|
||||||
@@ -292,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();
|
||||||
|
|
||||||
@@ -299,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(
|
||||||
@@ -397,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));
|
||||||
@@ -468,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) {
|
||||||
@@ -480,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>) {
|
||||||
@@ -498,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.`
|
||||||
);
|
);
|
||||||
@@ -524,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,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
|
||||||
@@ -769,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;
|
||||||
}
|
}
|
||||||
@@ -791,8 +860,11 @@ export default class Host extends lib.Link {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
assignGamePort(instanceId: number) {
|
assignGamePort(instanceId: number, inUse = new Set<number>()) {
|
||||||
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
|
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
|
||||||
|
for (const port of inUse) {
|
||||||
|
availablePorts.delete(port);
|
||||||
|
}
|
||||||
for (const [id, instance] of this.assignedInstances) {
|
for (const [id, instance] of this.assignedInstances) {
|
||||||
if (id === instanceId) {
|
if (id === instanceId) {
|
||||||
continue;
|
continue;
|
||||||
@@ -811,13 +883,41 @@ export default class Host extends lib.Link {
|
|||||||
return assignedPort;
|
return assignedPort;
|
||||||
}
|
}
|
||||||
if (!availablePorts.size) {
|
if (!availablePorts.size) {
|
||||||
throw new lib.RequestError("No available port left to assign from host.factorio_port_range");
|
const others = inUse.size ? ` (${inUse.size} in use by other processes)` : "";
|
||||||
|
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);
|
||||||
return newPort;
|
return newPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign a game port that no other process is bound to
|
||||||
|
*
|
||||||
|
* Replaces the stored port if it is taken, so a port another process
|
||||||
|
* holds is not reused on every start.
|
||||||
|
*
|
||||||
|
* @param instanceId - ID of the instance to assign a game port to.
|
||||||
|
* @returns the assigned port.
|
||||||
|
*/
|
||||||
|
async assignFreeGamePort(instanceId: number) {
|
||||||
|
const inUse = new Set<number>();
|
||||||
|
for (;;) {
|
||||||
|
// Picking and storing the port before checking it keeps concurrent assignments from picking the same one.
|
||||||
|
const port = this.assignGamePort(instanceId, inUse);
|
||||||
|
if (await udpPortFree(port)) {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
logger.warn(`Game port ${port} is in use by another process`, this.instanceLogMeta(instanceId));
|
||||||
|
inUse.add(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
*
|
*
|
||||||
@@ -826,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() {
|
||||||
@@ -915,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()) {
|
||||||
@@ -969,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;
|
||||||
}
|
}
|
||||||
@@ -1001,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);
|
||||||
@@ -1015,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"));
|
||||||
|
|
||||||
@@ -1067,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;
|
||||||
}
|
}
|
||||||
@@ -1098,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;
|
||||||
}
|
}
|
||||||
@@ -1117,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);
|
||||||
@@ -1132,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() {
|
||||||
@@ -1197,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,
|
||||||
));
|
));
|
||||||
|
|||||||
+138
-57
@@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import pidusage from "pidusage";
|
import pidusage from "pidusage";
|
||||||
|
import stream from "node:stream";
|
||||||
import util from "util";
|
import util from "util";
|
||||||
import type { Static } from "@sinclair/typebox";
|
import type { Static } from "@sinclair/typebox";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
@@ -9,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";
|
||||||
@@ -20,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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,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",
|
||||||
@@ -182,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)[] = [];
|
||||||
@@ -190,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;
|
||||||
@@ -201,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);
|
||||||
|
|
||||||
@@ -224,26 +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,
|
|
||||||
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 });
|
||||||
@@ -402,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);
|
||||||
@@ -425,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,6 +507,23 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send UDP message to the Factorio server
|
||||||
|
*
|
||||||
|
* Delivered in-game as an on_udp_packet_received event. Requires
|
||||||
|
* factorio.enable_lua_udp to be enabled on the instance.
|
||||||
|
*
|
||||||
|
* @param message - payload of the packet to send.
|
||||||
|
*/
|
||||||
|
async sendUdp(message: string | Buffer) {
|
||||||
|
if (!this.config.get("factorio.enable_lua_udp")) {
|
||||||
|
throw new Error(
|
||||||
|
"Attempted to send UDP message while Lua UDP is disabled. See config factorio.enable_lua_udp."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.server.sendUdp(message);
|
||||||
|
}
|
||||||
|
|
||||||
static async listSaves(instanceId: number, savesDir: string, loadedSave: string | null) {
|
static async listSaves(instanceId: number, savesDir: string, loadedSave: string | null) {
|
||||||
let defaultSave = null;
|
let defaultSave = null;
|
||||||
if (loadedSave === null) {
|
if (loadedSave === null) {
|
||||||
@@ -625,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");
|
||||||
|
|
||||||
@@ -650,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() {
|
||||||
@@ -674,18 +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 {
|
||||||
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
|
const factorioVersions = await host.sendTo("controller", new lib.FactorioVersionsRequest());
|
||||||
await this._loadStats();
|
stats = await Instance._readStats(path.join(dir, "instance-stats.json"));
|
||||||
// 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(
|
||||||
@@ -693,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) {
|
||||||
@@ -805,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -900,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`
|
||||||
);
|
);
|
||||||
@@ -919,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") {
|
||||||
@@ -973,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")
|
||||||
@@ -1086,7 +1167,8 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
*/
|
*/
|
||||||
async updateInstanceData() {
|
async updateInstanceData() {
|
||||||
let name = lib.escapeString(this.name);
|
let name = lib.escapeString(this.name);
|
||||||
await this.sendRcon(`/sc clusterio_private.update_instance(${this.id}, "${name}")`, true);
|
let hostUdpPort = this.server.hostUdpPort ?? "nil";
|
||||||
|
await this.sendRcon(`/sc clusterio_private.update_instance(${this.id}, "${name}", ${hostUdpPort})`, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFactorioSettings(current: Record<string, unknown>, previous: Record<string, unknown>) {
|
async updateFactorioSettings(current: Record<string, unknown>, previous: Record<string, unknown>) {
|
||||||
@@ -1307,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 {
|
||||||
@@ -1369,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) => {
|
||||||
@@ -1379,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);
|
||||||
@@ -1398,13 +1480,12 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
await this.syncMods();
|
await this.syncMods();
|
||||||
let zip = await exportData(this.server);
|
let zip = await exportData(this.server);
|
||||||
|
|
||||||
let content = await zip.generateAsync({ type: "nodebuffer" });
|
|
||||||
let url = new URL(this._host.config.get("host.controller_url"));
|
let url = new URL(this._host.config.get("host.controller_url"));
|
||||||
url.pathname += "api/upload-export";
|
url.pathname += "api/upload-export";
|
||||||
url.searchParams.set("mod_pack_id", String(this.activeModPack.id));
|
url.searchParams.set("mod_pack_id", String(this.activeModPack.id));
|
||||||
let response = await fetch(url, {
|
let response = await fetch(url, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: content,
|
body: stream.Readable.toWeb(lib.zipOutputStream(zip)),
|
||||||
duplex: "half",
|
duplex: "half",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Implementation of the create-scenario command
|
// Implementation of the create-scenario command
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import JSZip from "jszip";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import yazl from "yazl";
|
||||||
|
|
||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
import { logger } from "@clusterio/lib";
|
import { logger } from "@clusterio/lib";
|
||||||
@@ -61,33 +62,50 @@ export async function createScenario(
|
|||||||
|
|
||||||
async function zipDirectory(dirPath: string, zipPath: string) {
|
async function zipDirectory(dirPath: string, zipPath: string) {
|
||||||
const root = path.basename(path.resolve(dirPath));
|
const root = path.basename(path.resolve(dirPath));
|
||||||
const zip = new JSZip();
|
const zip = new yazl.ZipFile();
|
||||||
let dirs: [string, string][] = [[dirPath, root]];
|
const output = lib.zipOutputStream(zip);
|
||||||
while (dirs.length) {
|
const fd = await fs.open(zipPath, "w");
|
||||||
let [dir, relativeDir] = dirs.pop()!;
|
// addFile() can fail before the traversal is done, so start writing first.
|
||||||
for (let entry of await fs.readdir(dir, { withFileTypes: true })) {
|
const writing = pipeline(output, fd.createWriteStream());
|
||||||
let fsPath = path.join(dir, entry.name);
|
writing.catch(() => {});
|
||||||
let relativePath = path.posix.join(relativeDir, entry.name);
|
try {
|
||||||
if (entry.isFile()) {
|
let dirs: [string, string][] = [[dirPath, root]];
|
||||||
zip.file(relativePath, await fs.readFile(fsPath));
|
while (dirs.length) {
|
||||||
} else if (entry.isDirectory()) {
|
let [dir, relativeDir] = dirs.pop()!;
|
||||||
dirs.push([fsPath, relativePath]);
|
for (let entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||||
|
let fsPath = path.join(dir, entry.name);
|
||||||
|
let relativePath = path.posix.join(relativeDir, entry.name);
|
||||||
|
if (entry.isFile()) {
|
||||||
|
zip.addFile(fsPath, relativePath);
|
||||||
|
} else if (entry.isDirectory()) {
|
||||||
|
dirs.push([fsPath, relativePath]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
zip.end();
|
||||||
|
} catch (err) {
|
||||||
|
output.destroy();
|
||||||
|
await writing.catch(() => {});
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
await fs.writeFile(zipPath, await zip.generateAsync({ type: "nodebuffer" }));
|
await writing;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractZip(zipPath: string, dirPath: string) {
|
async function extractZip(zipPath: string, dirPath: string) {
|
||||||
const zip = await JSZip.loadAsync(await fs.readFile(zipPath));
|
const zip = await lib.ZipArchive.fromFile(zipPath);
|
||||||
const root = lib.findRoot(zip);
|
try {
|
||||||
for (let file of Object.values(zip.files)) {
|
const root = lib.findRoot(zip);
|
||||||
if (file.dir || !file.name.startsWith(`${root}/`)) {
|
for (let entry of zip.entries.values()) {
|
||||||
continue;
|
if (entry.fileName.endsWith("/") || !entry.fileName.startsWith(`${root}/`)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fsPath = path.join(dirPath, entry.fileName.slice(root.length + 1));
|
||||||
|
await fs.mkdir(path.dirname(fsPath), { recursive: true });
|
||||||
|
const fd = await fs.open(fsPath, "w");
|
||||||
|
await pipeline(await zip.openStream(entry), fd.createWriteStream());
|
||||||
}
|
}
|
||||||
const fsPath = path.join(dirPath, file.name.slice(root.length + 1));
|
} finally {
|
||||||
await fs.mkdir(path.dirname(fsPath), { recursive: true });
|
zip.close();
|
||||||
await fs.writeFile(fsPath, await file.async("nodebuffer"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+80
-40
@@ -2,7 +2,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { Jimp, JimpMime, type JimpInstance } from "jimp";
|
import { Jimp, JimpMime, type JimpInstance } from "jimp";
|
||||||
import JSZip from "jszip";
|
import yazl from "yazl";
|
||||||
|
|
||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
import * as libBuildMod from "@clusterio/lib/dist/node/build_mod.js";
|
import * as libBuildMod from "@clusterio/lib/dist/node/build_mod.js";
|
||||||
@@ -64,21 +64,38 @@ async function generateExportMod(server: FactorioServer) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let zipCache = new Map<string, JSZip>();
|
interface ModZip {
|
||||||
async function loadZip(server: FactorioServer, modVersions: Map<string, string>, mod: string) {
|
zip: lib.ZipArchive;
|
||||||
|
root: string;
|
||||||
|
}
|
||||||
|
type ZipCache = Map<string, ModZip>;
|
||||||
|
async function loadZip(server: FactorioServer, modVersions: Map<string, string>, zipCache: ZipCache, mod: string) {
|
||||||
let modVersion = modVersions.get(mod);
|
let modVersion = modVersions.get(mod);
|
||||||
if (!modVersion) {
|
if (!modVersion) {
|
||||||
throw new Error(`Got path for unknown mod ${mod}`);
|
throw new Error(`Got path for unknown mod ${mod}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let zipPath = server.writePath("mods", `${mod}_${modVersion}.zip`);
|
let zipPath = server.writePath("mods", `${mod}_${modVersion}.zip`);
|
||||||
let zip = zipCache.get(zipPath);
|
let modZip = zipCache.get(zipPath);
|
||||||
if (!zip) {
|
if (!modZip) {
|
||||||
zip = await JSZip.loadAsync(await fs.readFile(zipPath));
|
const zip = await lib.ZipArchive.fromFile(zipPath);
|
||||||
zipCache.set(zipPath, zip);
|
try {
|
||||||
|
modZip = { zip, root: lib.findRoot(zip) };
|
||||||
|
} catch (err) {
|
||||||
|
zip.close();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
zipCache.set(zipPath, modZip);
|
||||||
}
|
}
|
||||||
|
|
||||||
return zip.folder(lib.findRoot(zip))!;
|
return modZip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeZipCache(zipCache: ZipCache) {
|
||||||
|
for (const { zip } of zipCache.values()) {
|
||||||
|
zip.close();
|
||||||
|
}
|
||||||
|
zipCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,11 +103,17 @@ async function loadZip(server: FactorioServer, modVersions: Map<string, string>,
|
|||||||
*
|
*
|
||||||
* @param server - The server to load the file from.
|
* @param server - The server to load the file from.
|
||||||
* @param modVersions - Mapping of mod to version used.
|
* @param modVersions - Mapping of mod to version used.
|
||||||
|
* @param zipCache - Mod zips opened by this export.
|
||||||
* @param modPath - Factorio style path to the file to load.
|
* @param modPath - Factorio style path to the file to load.
|
||||||
* @returns The content of the file or null if not found.
|
* @returns The content of the file or null if not found.
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
async function loadFile(server: FactorioServer, modVersions: Map<string, string>, modPath: string) {
|
async function loadFile(
|
||||||
|
server: FactorioServer,
|
||||||
|
modVersions: Map<string, string>,
|
||||||
|
zipCache: ZipCache,
|
||||||
|
modPath: string,
|
||||||
|
) {
|
||||||
let match = /^__([^\/]+)__\/(.*)$/.exec(modPath);
|
let match = /^__([^\/]+)__\/(.*)$/.exec(modPath);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
server._logger.warn(`Skipping icon with bad mod path: ${JSON.stringify(modPath)}`);
|
server._logger.warn(`Skipping icon with bad mod path: ${JSON.stringify(modPath)}`);
|
||||||
@@ -111,9 +134,9 @@ async function loadFile(server: FactorioServer, modVersions: Map<string, string>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let zip;
|
let modZip;
|
||||||
try {
|
try {
|
||||||
zip = await loadZip(server, modVersions, mod);
|
modZip = await loadZip(server, modVersions, zipCache, mod);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
return null;
|
return null;
|
||||||
@@ -121,12 +144,7 @@ async function loadFile(server: FactorioServer, modVersions: Map<string, string>
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
let file = zip.file(filePath);
|
return await modZip.zip.readFile(path.posix.join(modZip.root, filePath));
|
||||||
if (!file) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await file.async("nodebuffer");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Image = JimpInstance;
|
type Image = JimpInstance;
|
||||||
@@ -134,6 +152,7 @@ type IconCache = Map<string, Image | null>;
|
|||||||
async function loadIcon(
|
async function loadIcon(
|
||||||
server: FactorioServer,
|
server: FactorioServer,
|
||||||
modVersions: Map<string, string>,
|
modVersions: Map<string, string>,
|
||||||
|
zipCache: ZipCache,
|
||||||
iconPath: string,
|
iconPath: string,
|
||||||
iconSize: number,
|
iconSize: number,
|
||||||
iconMipmaps: number,
|
iconMipmaps: number,
|
||||||
@@ -141,7 +160,7 @@ async function loadIcon(
|
|||||||
) {
|
) {
|
||||||
let icon = iconCache.get(iconPath);
|
let icon = iconCache.get(iconPath);
|
||||||
if (icon === undefined) {
|
if (icon === undefined) {
|
||||||
let fileContent = await loadFile(server, modVersions, iconPath);
|
let fileContent = await loadFile(server, modVersions, zipCache, iconPath);
|
||||||
if (fileContent) {
|
if (fileContent) {
|
||||||
icon = await Jimp.fromBuffer(fileContent) as Image;
|
icon = await Jimp.fromBuffer(fileContent) as Image;
|
||||||
icon.crop({ x: 0, y: 0, w: iconSize, h: iconSize });
|
icon.crop({ x: 0, y: 0, w: iconSize, h: iconSize });
|
||||||
@@ -157,11 +176,12 @@ async function loadIcon(
|
|||||||
async function loadSimpleIcon(
|
async function loadSimpleIcon(
|
||||||
server: FactorioServer,
|
server: FactorioServer,
|
||||||
modVersions: Map<string, string>,
|
modVersions: Map<string, string>,
|
||||||
|
zipCache: ZipCache,
|
||||||
item: SimpleIconSpecification,
|
item: SimpleIconSpecification,
|
||||||
size: number,
|
size: number,
|
||||||
iconCache: IconCache,
|
iconCache: IconCache,
|
||||||
): Promise<Image | null> {
|
): Promise<Image | null> {
|
||||||
let icon = await loadIcon(server, modVersions, item.icon, item.icon_size ?? 64, 0, iconCache);
|
let icon = await loadIcon(server, modVersions, zipCache, item.icon, item.icon_size ?? 64, 0, iconCache);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
let iconScale = size / (item.icon_size ?? 64);
|
let iconScale = size / (item.icon_size ?? 64);
|
||||||
if (iconScale !== 1) {
|
if (iconScale !== 1) {
|
||||||
@@ -175,6 +195,7 @@ async function loadSimpleIcon(
|
|||||||
async function loadLayeredIcon(
|
async function loadLayeredIcon(
|
||||||
server: FactorioServer,
|
server: FactorioServer,
|
||||||
modVersions: Map<string, string>,
|
modVersions: Map<string, string>,
|
||||||
|
zipCache: ZipCache,
|
||||||
item: LayeredIconSpecification,
|
item: LayeredIconSpecification,
|
||||||
size: number,
|
size: number,
|
||||||
iconCache: IconCache,
|
iconCache: IconCache,
|
||||||
@@ -190,7 +211,9 @@ async function loadLayeredIcon(
|
|||||||
|
|
||||||
for (let layer of item.icons) {
|
for (let layer of item.icons) {
|
||||||
let layerSize = (layer.icon_size || item.icon_size) ?? 64;
|
let layerSize = (layer.icon_size || item.icon_size) ?? 64;
|
||||||
let iconLayer = await loadIcon(server, modVersions, layer.icon, layerSize, layer.icon_mipmaps || 0, iconCache);
|
let iconLayer = await loadIcon(
|
||||||
|
server, modVersions, zipCache, layer.icon, layerSize, layer.icon_mipmaps || 0, iconCache,
|
||||||
|
);
|
||||||
|
|
||||||
if (!iconLayer) {
|
if (!iconLayer) {
|
||||||
continue;
|
continue;
|
||||||
@@ -308,6 +331,7 @@ async function growSheet(state: SheetState) {
|
|||||||
async function exportIcon(
|
async function exportIcon(
|
||||||
server: FactorioServer,
|
server: FactorioServer,
|
||||||
modVersions: Map<string, string>,
|
modVersions: Map<string, string>,
|
||||||
|
zipCache: ZipCache,
|
||||||
state: SheetState,
|
state: SheetState,
|
||||||
prototype: PrototypeWithIcon,
|
prototype: PrototypeWithIcon,
|
||||||
) {
|
) {
|
||||||
@@ -318,7 +342,7 @@ async function exportIcon(
|
|||||||
|
|
||||||
if (prototype.icons) {
|
if (prototype.icons) {
|
||||||
icon = await loadLayeredIcon(
|
icon = await loadLayeredIcon(
|
||||||
server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache,
|
server, modVersions, zipCache, prototype, SHEET_ICON_SIZE, state.iconCache,
|
||||||
);
|
);
|
||||||
iconPos = state.pos;
|
iconPos = state.pos;
|
||||||
iconPath = prototype.icons[0].icon;
|
iconPath = prototype.icons[0].icon;
|
||||||
@@ -328,7 +352,7 @@ async function exportIcon(
|
|||||||
iconPath = prototype.icon;
|
iconPath = prototype.icon;
|
||||||
if (iconPos === undefined) {
|
if (iconPos === undefined) {
|
||||||
icon = await loadSimpleIcon(
|
icon = await loadSimpleIcon(
|
||||||
server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache,
|
server, modVersions, zipCache, prototype, SHEET_ICON_SIZE, state.iconCache,
|
||||||
);
|
);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
iconPos = state.pos;
|
iconPos = state.pos;
|
||||||
@@ -364,6 +388,7 @@ async function exportIcon(
|
|||||||
*
|
*
|
||||||
* @param server - The server to load icons from.
|
* @param server - The server to load icons from.
|
||||||
* @param modVersions - Mapping of mod name to version.
|
* @param modVersions - Mapping of mod name to version.
|
||||||
|
* @param zipCache - Mod zips opened by this export.
|
||||||
* @param prototypes - Array of prototypes with icon to export for this base type.
|
* @param prototypes - Array of prototypes with icon to export for this base type.
|
||||||
* @param baseName - name of base type (used in metadata).
|
* @param baseName - name of base type (used in metadata).
|
||||||
* @param state - Shared spritesheet state (mutated in place).
|
* @param state - Shared spritesheet state (mutated in place).
|
||||||
@@ -373,6 +398,7 @@ async function exportIcon(
|
|||||||
async function exportIcons(
|
async function exportIcons(
|
||||||
server: FactorioServer,
|
server: FactorioServer,
|
||||||
modVersions: Map<string, string>,
|
modVersions: Map<string, string>,
|
||||||
|
zipCache: ZipCache,
|
||||||
prototypes: PrototypeWithIcon[],
|
prototypes: PrototypeWithIcon[],
|
||||||
baseName: string,
|
baseName: string,
|
||||||
state: SheetState,
|
state: SheetState,
|
||||||
@@ -381,7 +407,7 @@ async function exportIcons(
|
|||||||
state.metadata[baseName] ??= metadata;
|
state.metadata[baseName] ??= metadata;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (let prototype of prototypes) {
|
for (let prototype of prototypes) {
|
||||||
const icon = await exportIcon(server, modVersions, state, prototype);
|
const icon = await exportIcon(server, modVersions, zipCache, state, prototype);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
@@ -404,6 +430,7 @@ async function exportIcons(
|
|||||||
*
|
*
|
||||||
* @param server - The server to export the locale from.
|
* @param server - The server to export the locale from.
|
||||||
* @param modVersions - Mapping of mod name to version to export locale from.
|
* @param modVersions - Mapping of mod name to version to export locale from.
|
||||||
|
* @param zipCache - Mod zips opened by this export.
|
||||||
* @param modOrder - Load order of the mods.
|
* @param modOrder - Load order of the mods.
|
||||||
* @param languageCode - Language to export locale for.
|
* @param languageCode - Language to export locale for.
|
||||||
* @returns merged locale information
|
* @returns merged locale information
|
||||||
@@ -412,6 +439,7 @@ async function exportIcons(
|
|||||||
async function exportLocale(
|
async function exportLocale(
|
||||||
server: FactorioServer,
|
server: FactorioServer,
|
||||||
modVersions: Map<string, string>,
|
modVersions: Map<string, string>,
|
||||||
|
zipCache: ZipCache,
|
||||||
modOrder: string[],
|
modOrder: string[],
|
||||||
languageCode: string
|
languageCode: string
|
||||||
) {
|
) {
|
||||||
@@ -445,18 +473,21 @@ async function exportLocale(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let zip;
|
let modZip;
|
||||||
try {
|
try {
|
||||||
zip = await loadZip(server, modVersions, mod);
|
modZip = await loadZip(server, modVersions, zipCache, mod);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) {
|
const localePath = `${path.posix.join(modZip.root, "locale", languageCode)}/`;
|
||||||
let content = await file.async("nodebuffer");
|
for (const entry of modZip.zip.entries.values()) {
|
||||||
mergeLocale(lib.parse(content.toString("utf8")));
|
if (entry.fileName.startsWith(localePath) && entry.fileName.endsWith(".cfg")) {
|
||||||
|
let content = await modZip.zip.readEntry(entry);
|
||||||
|
mergeLocale(lib.parse(content.toString("utf8")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +501,16 @@ async function exportLocale(
|
|||||||
* @returns zip file with exported data.
|
* @returns zip file with exported data.
|
||||||
*/
|
*/
|
||||||
export async function exportData(server: FactorioServer) {
|
export async function exportData(server: FactorioServer) {
|
||||||
|
const zipCache: ZipCache = new Map();
|
||||||
|
try {
|
||||||
|
return await buildExport(server, zipCache);
|
||||||
|
} finally {
|
||||||
|
// Free up the zip files loaded during the export.
|
||||||
|
closeZipCache(zipCache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildExport(server: FactorioServer, zipCache: ZipCache) {
|
||||||
await generateExportMod(server);
|
await generateExportMod(server);
|
||||||
|
|
||||||
let settings: lib.ExportSettings = {};
|
let settings: lib.ExportSettings = {};
|
||||||
@@ -549,16 +590,14 @@ export async function exportData(server: FactorioServer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let locale = await exportLocale(server, modVersions, modOrder, "en");
|
let locale = await exportLocale(server, modVersions, zipCache, modOrder, "en");
|
||||||
|
|
||||||
// Free up the memory used by zip files loaded during the export.
|
let zip = new yazl.ZipFile();
|
||||||
zipCache.clear();
|
const addJson = (name: string, value: unknown) => zip.addBuffer(Buffer.from(JSON.stringify(value)), name);
|
||||||
|
addJson("export/settings.json", settings satisfies lib.ExportSettings);
|
||||||
let zip = new JSZip();
|
addJson("export/prototypes.json", prototypes satisfies lib.ExportPrototypes);
|
||||||
zip.file("export/settings.json", JSON.stringify(settings satisfies lib.ExportSettings));
|
addJson("export/locale.json", [...locale.entries()] satisfies lib.ExportLocale);
|
||||||
zip.file("export/prototypes.json", JSON.stringify(prototypes satisfies lib.ExportPrototypes));
|
addJson("export/defines.json", defines satisfies lib.ExportDefines);
|
||||||
zip.file("export/locale.json", JSON.stringify([...locale.entries()] satisfies lib.ExportLocale));
|
|
||||||
zip.file("export/defines.json", JSON.stringify(defines satisfies lib.ExportDefines));
|
|
||||||
|
|
||||||
// Build a single unified spritesheet across all prototypes with icons.
|
// Build a single unified spritesheet across all prototypes with icons.
|
||||||
const state = await createSheetState();
|
const state = await createSheetState();
|
||||||
@@ -572,15 +611,16 @@ export async function exportData(server: FactorioServer) {
|
|||||||
if (prototypesWithIcons.length === 0) {
|
if (prototypesWithIcons.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const count = await exportIcons(server, modVersions, prototypesWithIcons, baseName, state);
|
const count = await exportIcons(server, modVersions, zipCache, prototypesWithIcons, baseName, state);
|
||||||
server._logger.info(`Exported ${count} ${baseName} icons`);
|
server._logger.info(`Exported ${count} ${baseName} icons`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crop sheet to actual used height and write single spritesheet + metadata.
|
// Crop sheet to actual used height and write single spritesheet + metadata.
|
||||||
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
|
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
|
||||||
state.sheet.crop({ x: 0, y: 0, w: SHEET_WIDTH, h: Math.max(usedRows * SHEET_ICON_SIZE, 1) });
|
state.sheet.crop({ x: 0, y: 0, w: SHEET_WIDTH, h: Math.max(usedRows * SHEET_ICON_SIZE, 1) });
|
||||||
zip.file("export/spritesheet.png", await state.sheet.getBuffer(JimpMime.png));
|
zip.addBuffer(await state.sheet.getBuffer(JimpMime.png), "export/spritesheet.png");
|
||||||
zip.file("export/metadata.json", JSON.stringify(state.metadata satisfies lib.ExportMetadata));
|
addJson("export/metadata.json", state.metadata satisfies lib.ExportMetadata);
|
||||||
|
zip.end();
|
||||||
|
|
||||||
server._logger.info(`Export complete: ${state.pos} icons on ${usedRows} row(s)`);
|
server._logger.info(`Export complete: ${state.pos} icons on ${usedRows} row(s)`);
|
||||||
|
|
||||||
|
|||||||
+155
-77
@@ -1,10 +1,9 @@
|
|||||||
// Library for patching Factorio saves with scenario code.
|
// Library for patching Factorio saves with scenario code.
|
||||||
|
|
||||||
import events from "events";
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import JSZip from "jszip";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
import { Type, Static } from "@sinclair/typebox";
|
import { Type, Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
@@ -72,18 +71,16 @@ export class SaveModule {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async fromSave(json: Static<typeof SaveModule.jsonSchema>, root: JSZip) {
|
static async fromSave(json: Static<typeof SaveModule.jsonSchema>, zip: lib.ZipArchive, root: string) {
|
||||||
const module = new this(lib.ModuleInfo.fromJSON(json));
|
const module = new this(lib.ModuleInfo.fromJSON(json));
|
||||||
module.files = new Map(await Promise.all(json.files
|
for (const filename of json.files) {
|
||||||
.map(filename => ({filename, file: root.file(filename)}))
|
const content = await zip.readFile(path.posix.join(root, filename));
|
||||||
.filter(({filename, file}) => {
|
if (content === null) {
|
||||||
if (file === null) {
|
lib.logger.warn(`Missing file ${filename} in save`);
|
||||||
lib.logger.warn(`Missing file ${filename} in save`);
|
continue;
|
||||||
}
|
}
|
||||||
return file !== null;
|
module.files.set(filename, content);
|
||||||
})
|
}
|
||||||
.map(async ({filename, file}) => [filename, await file!.async("nodebuffer")] as const)
|
|
||||||
));
|
|
||||||
return module;
|
return module;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +202,7 @@ export class PatchInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async fromSave(json: Static<typeof PatchInfo.jsonSchema>, root: JSZip) {
|
static async fromSave(json: Static<typeof PatchInfo.jsonSchema>, zip: lib.ZipArchive, root: string) {
|
||||||
if (json.version === undefined) {
|
if (json.version === undefined) {
|
||||||
interface ScenarioInfoV0 {
|
interface ScenarioInfoV0 {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -229,7 +226,7 @@ export class PatchInfo {
|
|||||||
return new this(
|
return new this(
|
||||||
json.patch_number,
|
json.patch_number,
|
||||||
lib.ModuleInfo.fromJSON(json.scenario),
|
lib.ModuleInfo.fromJSON(json.scenario),
|
||||||
await Promise.all(json.modules.map(m => SaveModule.fromSave(m, root))),
|
await Promise.all(json.modules.map(m => SaveModule.fromSave(m, zip, root))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,80 +393,161 @@ const knownScenarios: Record<string, lib.ModuleInfo> = {
|
|||||||
* @param modules - Description of the modules to patch.
|
* @param modules - Description of the modules to patch.
|
||||||
*/
|
*/
|
||||||
export async function patch(savePath: string, modules: SaveModule[]) {
|
export async function patch(savePath: string, modules: SaveModule[]) {
|
||||||
let zip = await JSZip.loadAsync(await fs.readFile(savePath));
|
const zip = await lib.ZipArchive.fromFile(savePath);
|
||||||
let root = zip.folder(lib.findRoot(zip))!;
|
let tempSavePath: string;
|
||||||
|
try {
|
||||||
|
const root = lib.findRoot(zip);
|
||||||
|
const rootPath = (relativePath: string) => path.posix.join(root, relativePath);
|
||||||
|
|
||||||
let patchInfoFile = root.file("clusterio.json");
|
// Files to add or replace in the save, relative to the root
|
||||||
let patchInfo: PatchInfo;
|
const files = new Map<string, Buffer>();
|
||||||
if (patchInfoFile !== null) {
|
let patchInfo: PatchInfo;
|
||||||
let content = await patchInfoFile.async("string");
|
const patchInfoContent = await zip.readFile(rootPath("clusterio.json"));
|
||||||
patchInfo = await PatchInfo.fromSave(JSON.parse(content), root);
|
if (patchInfoContent !== null) {
|
||||||
if (patchInfo.version > PatchInfo.currentVersion) {
|
patchInfo = await PatchInfo.fromSave(JSON.parse(patchInfoContent.toString("utf8")), zip, root);
|
||||||
throw new Error(
|
if (patchInfo.version > PatchInfo.currentVersion) {
|
||||||
`Save patch version ${patchInfo.version} is newer than the patch version this ` +
|
throw new Error(
|
||||||
`version of Clusterio can load (${PatchInfo.currentVersion})`
|
`Save patch version ${patchInfo.version} is newer than the patch version this ` +
|
||||||
);
|
`version of Clusterio can load (${PatchInfo.currentVersion})`
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// No info file present, try to detect if it's a known compatible scenario.
|
// No info file present, try to detect if it's a known compatible scenario.
|
||||||
} else {
|
|
||||||
let controlFile = root.file("control.lua");
|
|
||||||
if (!controlFile) {
|
|
||||||
throw new Error("Unable to patch save, missing control.lua file.");
|
|
||||||
}
|
|
||||||
let controlStream = controlFile.nodeStream("nodebuffer");
|
|
||||||
let controlHash = await lib.hashStream(controlStream);
|
|
||||||
|
|
||||||
if (controlHash in knownScenarios) {
|
|
||||||
patchInfo = new PatchInfo(0, knownScenarios[controlHash], []);
|
|
||||||
root.file("scenario.lua", controlFile.nodeStream("nodebuffer"));
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unable to patch save, unknown scenario (${controlHash})`);
|
const controlFile = zip.file(rootPath("control.lua"));
|
||||||
}
|
if (!controlFile) {
|
||||||
}
|
throw new Error("Unable to patch save, missing control.lua file.");
|
||||||
|
}
|
||||||
|
const controlHash = await lib.hashStream(await zip.openStream(controlFile));
|
||||||
|
|
||||||
// Increment patch number
|
if (controlHash in knownScenarios) {
|
||||||
patchInfo.patchNumber = patchInfo.patchNumber + 1;
|
patchInfo = new PatchInfo(0, knownScenarios[controlHash], []);
|
||||||
|
files.set("scenario.lua", await zip.readEntry(controlFile));
|
||||||
// Remove any existing modules from the save
|
} else {
|
||||||
if (patchInfo.version === 0) {
|
throw new Error(`Unable to patch save, unknown scenario (${controlHash})`);
|
||||||
for (let file of root.file(/^modules\//)) {
|
|
||||||
zip.remove(file.name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let module of patchInfo.modules) {
|
|
||||||
for (let filepath of module.files.keys()) {
|
|
||||||
const file = root.file(filepath);
|
|
||||||
if (file !== null) {
|
|
||||||
zip.remove(file.name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
patchInfo.modules = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
reorderDependencies(modules);
|
// Increment patch number
|
||||||
|
patchInfo.patchNumber = patchInfo.patchNumber + 1;
|
||||||
|
|
||||||
// Add the modules to the save.
|
// Remove any existing modules from the save
|
||||||
for (let module of modules) {
|
const removed = new Set<string>();
|
||||||
for (let [relativePath, contents] of module.files) {
|
if (patchInfo.version === 0) {
|
||||||
root.file(relativePath, contents);
|
const modulesPath = rootPath("modules/");
|
||||||
|
for (const name of zip.entries.keys()) {
|
||||||
|
if (name.startsWith(modulesPath)) {
|
||||||
|
removed.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const module of patchInfo.modules) {
|
||||||
|
for (const filepath of module.files.keys()) {
|
||||||
|
removed.add(rootPath(filepath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patchInfo.modules = [];
|
||||||
}
|
}
|
||||||
patchInfo.modules.push(module);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add loading code and patch info
|
reorderDependencies(modules);
|
||||||
root.file("control.lua", generateLoader(patchInfo));
|
|
||||||
root.file("clusterio.json", JSON.stringify(patchInfo, null, "\t"));
|
|
||||||
|
|
||||||
// Write back the save
|
// Add the modules to the save.
|
||||||
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
|
for (const module of modules) {
|
||||||
const [tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
|
for (const [relativePath, contents] of module.files) {
|
||||||
try {
|
files.set(relativePath, contents);
|
||||||
let pipe = stream.pipe(writeStream);
|
}
|
||||||
await events.once(pipe, "finish");
|
patchInfo.modules.push(module);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add loading code and patch info
|
||||||
|
files.set("control.lua", Buffer.from(generateLoader(patchInfo), "utf8"));
|
||||||
|
files.set("clusterio.json", Buffer.from(JSON.stringify(patchInfo, null, "\t"), "utf8"));
|
||||||
|
|
||||||
|
// Write back the save, keeping the order of the entries in the original
|
||||||
|
const added = new Map([...files].map(([relativePath, contents]) => [rootPath(relativePath), contents]));
|
||||||
|
tempSavePath = await writeSaveCopy(zip, savePath, added, removed);
|
||||||
} finally {
|
} finally {
|
||||||
writeStream.destroy();
|
zip.close();
|
||||||
|
}
|
||||||
|
await fs.rename(tempSavePath, savePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a copy of a save with entries replaced, added and removed
|
||||||
|
*
|
||||||
|
* The copy is written next to the save, and must be renamed over it after
|
||||||
|
* the save has been closed.
|
||||||
|
*
|
||||||
|
* @param zip - Opened save to copy.
|
||||||
|
* @param savePath - Path the save was opened from.
|
||||||
|
* @param added - Entries by full path in the zip to add or replace.
|
||||||
|
* @param removed - Full paths of entries to leave out.
|
||||||
|
* @returns path to the copy written.
|
||||||
|
*/
|
||||||
|
async function writeSaveCopy(
|
||||||
|
zip: lib.ZipArchive,
|
||||||
|
savePath: string,
|
||||||
|
added: Map<string, Buffer>,
|
||||||
|
removed = new Set<string>(),
|
||||||
|
) {
|
||||||
|
const output = new lib.ZipWriter();
|
||||||
|
const [tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
|
||||||
|
const writing = pipeline(output.outputStream, writeStream);
|
||||||
|
// Awaited below, but a write error can reject it before then.
|
||||||
|
writing.catch(() => {});
|
||||||
|
try {
|
||||||
|
for (const entry of zip.entries.values()) {
|
||||||
|
const name = entry.fileName;
|
||||||
|
if (removed.has(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const contents = added.get(name);
|
||||||
|
if (contents !== undefined) {
|
||||||
|
await output.addBuffer(contents, name);
|
||||||
|
added.delete(name);
|
||||||
|
} else {
|
||||||
|
await output.addEntry(zip, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, contents] of added) {
|
||||||
|
await output.addBuffer(contents, name);
|
||||||
|
}
|
||||||
|
await output.end();
|
||||||
|
await writing;
|
||||||
|
} catch (err) {
|
||||||
|
output.outputStream.destroy();
|
||||||
|
await writing.catch(() => {});
|
||||||
|
await fs.rm(tempSavePath, { force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return tempSavePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the scenario code the save patcher keeps loading in a save
|
||||||
|
*
|
||||||
|
* Saves created by Factorio run the freeplay scenario, which the patcher
|
||||||
|
* keeps when patching them. This replaces it with the given scenario info,
|
||||||
|
* for example a scenario that loads no modules to get a save without
|
||||||
|
* freeplay. The save must not have been patched before.
|
||||||
|
*
|
||||||
|
* @param savePath - Path to the save to change.
|
||||||
|
* @param scenario - Scenario modules to load in place of the original scenario.
|
||||||
|
*/
|
||||||
|
export async function setSaveScenario(savePath: string, scenario: lib.ModuleInfo) {
|
||||||
|
const zip = await lib.ZipArchive.fromFile(savePath);
|
||||||
|
let tempSavePath: string;
|
||||||
|
try {
|
||||||
|
const root = lib.findRoot(zip);
|
||||||
|
if (await zip.readFile(path.posix.join(root, "clusterio.json")) !== null) {
|
||||||
|
throw new Error("Unable to set scenario of a save that has already been patched");
|
||||||
|
}
|
||||||
|
const patchInfo = new PatchInfo(0, scenario, []);
|
||||||
|
tempSavePath = await writeSaveCopy(zip, savePath, new Map([[
|
||||||
|
path.posix.join(root, "clusterio.json"), Buffer.from(JSON.stringify(patchInfo, null, " "), "utf8"),
|
||||||
|
]]));
|
||||||
|
} finally {
|
||||||
|
zip.close();
|
||||||
}
|
}
|
||||||
await fs.rename(tempSavePath, savePath);
|
await fs.rename(tempSavePath, savePath);
|
||||||
}
|
}
|
||||||
|
|||||||
+273
-114
@@ -1,9 +1,12 @@
|
|||||||
// Factorio Server interface
|
// Factorio Server interface
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { type FSWatcher, watch as fsWatch, writeFileSync } from "node:fs";
|
import { type FSWatcher, createWriteStream, watch as fsWatch, writeFileSync } from "node:fs";
|
||||||
import child_process from "child_process";
|
import child_process from "child_process";
|
||||||
|
import dgram from "dgram";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import JSZip from "jszip";
|
import stream from "node:stream";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import type { ReadableStream } from "node:stream/web";
|
||||||
import events from "events";
|
import events from "events";
|
||||||
import util from "util";
|
import util from "util";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
@@ -76,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);
|
||||||
@@ -112,29 +115,38 @@ async function downloadAndExtractZip(url: string, targetDir: string) {
|
|||||||
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
|
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the ZIP with JSZip
|
if (!res.body) {
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
throw new Error(`Failed to fetch ${url}: empty response`);
|
||||||
const zip = await JSZip.loadAsync(buffer);
|
}
|
||||||
|
|
||||||
// Extract each file/folder
|
// Write the download to a temp file, zip files need random access to read
|
||||||
await fs.mkdir(targetDir, { recursive: true });
|
const tmpFilePath = `${targetDir}.tmp`;
|
||||||
await Promise.all(
|
await fs.writeFile(tmpFilePath, stream.Readable.fromWeb(res.body as ReadableStream));
|
||||||
Object.values(zip.files).map(async (entry) => {
|
try {
|
||||||
const parts = entry.name.split("/").filter(Boolean);
|
const zip = await lib.ZipArchive.fromFile(tmpFilePath);
|
||||||
const strippedPath = parts.slice(1).join("/");
|
try {
|
||||||
if (!strippedPath) {
|
await fs.mkdir(targetDir, { recursive: true });
|
||||||
return; // This copies the behaviour of tar --strip-components 1
|
for (const entry of zip.entries.values()) {
|
||||||
}
|
const parts = entry.fileName.split("/").filter(Boolean);
|
||||||
|
const strippedPath = parts.slice(1).join("/");
|
||||||
|
if (!strippedPath) {
|
||||||
|
continue; // This copies the behaviour of tar --strip-components 1
|
||||||
|
}
|
||||||
|
|
||||||
const entryPath = path.join(targetDir, strippedPath);
|
const entryPath = path.join(targetDir, strippedPath);
|
||||||
if (entry.dir) {
|
if (entry.fileName.endsWith("/")) {
|
||||||
await fs.mkdir(entryPath, { recursive: true });
|
await fs.mkdir(entryPath, { recursive: true });
|
||||||
} else {
|
} else {
|
||||||
await fs.mkdir(path.dirname(entryPath), { recursive: true });
|
await fs.mkdir(path.dirname(entryPath), { recursive: true });
|
||||||
await fs.writeFile(entryPath, await entry.async("nodebuffer"));
|
await pipeline(await zip.openStream(entry), createWriteStream(entryPath));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
} finally {
|
||||||
);
|
zip.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpFilePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,6 +182,76 @@ async function downloadAndExtractTar(url: string, targetDir: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Factorio downloads in progress by target directory, shared by every server in the process
|
||||||
|
const downloadsInProgress = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the newest Factorio version matching the target if not installed
|
||||||
|
*
|
||||||
|
* Call before {@link FactorioServer.fromOptions} for it to find the new version.
|
||||||
|
*
|
||||||
|
* @param factorioDir - Path to Factorio installation dirs.
|
||||||
|
* @param targetVersion - Version to look for, or "latest".
|
||||||
|
* @param availableVersions - Versions available for download.
|
||||||
|
* @param logger - Logger to report the download on.
|
||||||
|
*/
|
||||||
|
export async function checkForUpdates(
|
||||||
|
factorioDir: string,
|
||||||
|
targetVersion: lib.TargetVersion,
|
||||||
|
availableVersions: lib.ExternalFactorioVersion[],
|
||||||
|
logger: lib.Logger = lib.logger,
|
||||||
|
) {
|
||||||
|
|
||||||
|
const latestVersion = availableVersions
|
||||||
|
.filter(v => (
|
||||||
|
targetVersion === "latest" || v.version === targetVersion || v.version.startsWith(targetVersion)
|
||||||
|
))
|
||||||
|
.sort((v1, v2) => versionOrder(v1.version, v2.version))
|
||||||
|
[0];
|
||||||
|
|
||||||
|
if (!latestVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A version that is still being extracted can already look installed
|
||||||
|
const dst = path.join(factorioDir, latestVersion.version);
|
||||||
|
const installedVersions = await listFactorioVersions(factorioDir);
|
||||||
|
if (!installedVersions.versions.has(latestVersion.version) || downloadsInProgress.has(dst)) {
|
||||||
|
const v = latestVersion.version;
|
||||||
|
// Can't download if not linux or this is a direct install
|
||||||
|
if (installedVersions.direct) {
|
||||||
|
logger.info(
|
||||||
|
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
|
||||||
|
`because ${factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
|
||||||
|
`layout, where ${factorioDir} contains a subdirectory per version, to enable automatic ` +
|
||||||
|
"downloads."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform !== "linux") {
|
||||||
|
logger.info(
|
||||||
|
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
|
||||||
|
"(automatic downloads are only supported on Linux)."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the newer version, or wait for another server already downloading it
|
||||||
|
let download = downloadsInProgress.get(dst);
|
||||||
|
if (download) {
|
||||||
|
logger.info(`A newer version of factorio is available (${v}), waiting for download...`);
|
||||||
|
} else {
|
||||||
|
logger.info(`A newer version of factorio is available (${v}), starting download...`);
|
||||||
|
download = downloadAndExtractTar(latestVersion.headlessUrl, dst).finally(() => {
|
||||||
|
downloadsInProgress.delete(dst);
|
||||||
|
});
|
||||||
|
downloadsInProgress.set(dst, download);
|
||||||
|
}
|
||||||
|
await download;
|
||||||
|
logger.info(`Downloaded factorio version ${v}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find factorio data directory of the given version
|
* Find factorio data directory of the given version
|
||||||
*
|
*
|
||||||
@@ -289,6 +371,15 @@ async function generatePassword(length: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unescapeChannel(channel: Buffer) {
|
||||||
|
return channel
|
||||||
|
.toString("utf-8")
|
||||||
|
.replace(/\\x([0-9a-f]{2})/g, (match, p1) => (
|
||||||
|
String.fromCharCode(parseInt(p1, 16))
|
||||||
|
))
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interpret lines of output from Factorio
|
* Interpret lines of output from Factorio
|
||||||
*
|
*
|
||||||
@@ -411,6 +502,9 @@ const outputHeuristics: Heuristic[] = [
|
|||||||
filter: { type: "log", message: /^Starting RCON interface/ },
|
filter: { type: "log", message: /^Starting RCON interface/ },
|
||||||
action: function() {
|
action: function() {
|
||||||
this._startRcon().catch((err) => { this.emit("error", err); });
|
this._startRcon().catch((err) => { this.emit("error", err); });
|
||||||
|
if (this.enableLuaUdp) {
|
||||||
|
this._startUdp().catch((err) => { this.emit("error", err); });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -542,6 +636,10 @@ export interface FactorioServerOptions {
|
|||||||
rconPort?: number,
|
rconPort?: number,
|
||||||
/** Password use for RCON. */
|
/** Password use for RCON. */
|
||||||
rconPassword?: string,
|
rconPassword?: string,
|
||||||
|
/** Enable UDP messaging with the Factorio server. */
|
||||||
|
enableLuaUdp?: boolean,
|
||||||
|
/** UDP port the Factorio server receives Lua UDP messages on. */
|
||||||
|
luaUdpPort?: number,
|
||||||
/** Turn on whitelisting. */
|
/** Turn on whitelisting. */
|
||||||
enableWhitelist?: boolean,
|
enableWhitelist?: boolean,
|
||||||
/** Enable Factorio.com based multiplayer bans. */
|
/** Enable Factorio.com based multiplayer bans. */
|
||||||
@@ -586,8 +684,9 @@ type FactorioServerEvents = {
|
|||||||
"_saving": [],
|
"_saving": [],
|
||||||
"_saved": [],
|
"_saved": [],
|
||||||
|
|
||||||
// IPS events
|
// IPC events
|
||||||
[ipcEvent: `ipc-${string}`]: [ event: any ],
|
[ipcEvent: `ipc-${string}`]: [ event: any ],
|
||||||
|
[udpEvent: `udp-${string}`]: [ data: Buffer ],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -607,6 +706,8 @@ type FactorioServerEvents = {
|
|||||||
* - autosave-fnished - invoked when the autosave finished
|
* - autosave-fnished - invoked when the autosave finished
|
||||||
* - save-finished - invoked when the server has finished a manual save
|
* - save-finished - invoked when the server has finished a manual save
|
||||||
* - exit - invoked when the sterver has exited
|
* - exit - invoked when the sterver has exited
|
||||||
|
* - ipc-<channel> - invoked with data sent with send_json in-game
|
||||||
|
* - udp-<channel> - invoked with data sent with send_udp in-game
|
||||||
* @extends events.EventEmitter
|
* @extends events.EventEmitter
|
||||||
*/
|
*/
|
||||||
export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||||
@@ -618,6 +719,10 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
rconPort: number;
|
rconPort: number;
|
||||||
/** Password used for RCON on the Factorio game server */
|
/** Password used for RCON on the Factorio game server */
|
||||||
rconPassword: string;
|
rconPassword: string;
|
||||||
|
/** Enable UDP messaging with the Factorio game server */
|
||||||
|
enableLuaUdp: boolean;
|
||||||
|
/** UDP port the Factorio game server receives Lua UDP messages on */
|
||||||
|
luaUdpPort: number;
|
||||||
/** Enable player whitelist */
|
/** Enable player whitelist */
|
||||||
enableWhitelist: boolean;
|
enableWhitelist: boolean;
|
||||||
/** Enable Factorio.com based multiplayer bans **/
|
/** Enable Factorio.com based multiplayer bans **/
|
||||||
@@ -632,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
|
||||||
@@ -642,11 +746,11 @@ 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;
|
||||||
|
_udpSocket: dgram.Socket | null = null;
|
||||||
_gameReady = false;
|
_gameReady = false;
|
||||||
_stripRegExp?: RegExp;
|
_stripRegExp?: RegExp;
|
||||||
_maxConcurrentCommands = 5;
|
_maxConcurrentCommands = 5;
|
||||||
@@ -658,27 +762,40 @@ 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 */
|
||||||
|
this.enableLuaUdp = options.enableLuaUdp || false;
|
||||||
|
/** UDP port the Factorio game server receives Lua UDP messages on */
|
||||||
|
this.luaUdpPort = options.luaUdpPort || randomDynamicPort();
|
||||||
/** Enable player whitelist */
|
/** Enable player whitelist */
|
||||||
this.enableWhitelist = options.enableWhitelist || false;
|
this.enableWhitelist = options.enableWhitelist || false;
|
||||||
/** Enable Factorio.com based multiplayer bans **/
|
/** Enable Factorio.com based multiplayer bans **/
|
||||||
@@ -741,19 +858,24 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleUdp(channel: string, handler: (data: Buffer) => Promise<void>) {
|
||||||
|
// Runs from the socket callback, so synchronous throws must be caught here too
|
||||||
|
this.on(`udp-${channel}`, async (data) => {
|
||||||
|
try {
|
||||||
|
await handler(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
this._logger.error(`Error handling udp event:\n${err.stack ?? err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async _handleIpc(line: Buffer) {
|
async _handleIpc(line: Buffer) {
|
||||||
let channelEnd = line.indexOf("?");
|
let channelEnd = line.indexOf("?");
|
||||||
if (channelEnd === -1) {
|
if (channelEnd === -1) {
|
||||||
throw new Error(`Malformed IPC line "${line.toString()}"`);
|
throw new Error(`Malformed IPC line "${line.toString()}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let channel = line
|
let channel = unescapeChannel(line.subarray(6, channelEnd));
|
||||||
.subarray(6, channelEnd)
|
|
||||||
.toString("utf-8")
|
|
||||||
.replace(/\\x([0-9a-f]{2})/g, (match, p1) => (
|
|
||||||
String.fromCharCode(parseInt(p1, 16))
|
|
||||||
))
|
|
||||||
;
|
|
||||||
|
|
||||||
let type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8");
|
let type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8");
|
||||||
let content;
|
let content;
|
||||||
@@ -791,6 +913,74 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_handleUdpMessage(message: Buffer, rinfo: dgram.RemoteInfo) {
|
||||||
|
// Factorio sends from the socket bound to the Lua UDP port, anything
|
||||||
|
// else is some other local process.
|
||||||
|
if (rinfo.port !== this.luaUdpPort) {
|
||||||
|
this._logger.warn(`Ignoring UDP packet from unexpected source ${rinfo.address}:${rinfo.port}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let channelEnd = message.indexOf("?");
|
||||||
|
if (channelEnd === -1) {
|
||||||
|
this._logger.warn(`Ignoring malformed UDP packet "${message.subarray(0, 100).toString("utf-8")}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = unescapeChannel(message.subarray(0, channelEnd));
|
||||||
|
let data = message.subarray(channelEnd + 1);
|
||||||
|
if (!this.emit(`udp-${channel}`, data)) {
|
||||||
|
this._logger.warn(`Warning: Unhandled udp-${channel}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _startUdp() {
|
||||||
|
if (this._udpSocket !== null) {
|
||||||
|
throw Error("UDP socket is already open");
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = dgram.createSocket("udp4");
|
||||||
|
socket.on("message", (message, rinfo) => this._handleUdpMessage(message, rinfo));
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.once("error", reject);
|
||||||
|
socket.bind(0, "127.0.0.1", () => {
|
||||||
|
socket.off("error", reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
this._logger.error(`Failed to open UDP socket:\n${err?.stack ?? err?.message ?? err}`);
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.on("error", err => this.emit("error", err));
|
||||||
|
this._udpSocket = socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factorio before 2.1.10 crashes when a packet is received on a headless server
|
||||||
|
_checkLuaUdpVersion() {
|
||||||
|
if (this.enableLuaUdp && lib.integerFullVersion(this._version) < lib.integerFullVersion("2.1.10")) {
|
||||||
|
throw new Error("Lua UDP requires Factorio 2.1.10 or later");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopUdp() {
|
||||||
|
if (this._udpSocket) {
|
||||||
|
this._udpSocket.close();
|
||||||
|
this._udpSocket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UDP port on localhost the host receives Lua UDP messages on
|
||||||
|
*
|
||||||
|
* Only available while the server is running with Lua UDP enabled.
|
||||||
|
*/
|
||||||
|
get hostUdpPort(): number | undefined {
|
||||||
|
return this._udpSocket?.address().port;
|
||||||
|
}
|
||||||
|
|
||||||
_handleOutput(rawLine: Buffer, source: "stdout" | "stderr") {
|
_handleOutput(rawLine: Buffer, source: "stdout" | "stderr") {
|
||||||
if (rawLine.subarray(0, 6).equals(Buffer.from("\f$ipc:"))) {
|
if (rawLine.subarray(0, 6).equals(Buffer.from("\f$ipc:"))) {
|
||||||
this._handleIpc(rawLine).catch(err => this.emit("error", err));
|
this._handleIpc(rawLine).catch(err => this.emit("error", err));
|
||||||
@@ -959,84 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const installedVersions = await listFactorioVersions(this._factorioDir);
|
|
||||||
if (!installedVersions.versions.has(latestVersion.version)) {
|
|
||||||
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
|
|
||||||
this._logger.info(`A newer version of factorio is available (${v}), starting download...`);
|
|
||||||
const dst = path.join(this._factorioDir, latestVersion.version);
|
|
||||||
await downloadAndExtractTar(latestVersion.headlessUrl, dst);
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1063,6 +1194,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
this._server = null;
|
this._server = null;
|
||||||
this._rconClient = null;
|
this._rconClient = null;
|
||||||
this._rconReady = false;
|
this._rconReady = false;
|
||||||
|
this._udpSocket = null;
|
||||||
this._gameReady = false;
|
this._gameReady = false;
|
||||||
this._unexpected = [];
|
this._unexpected = [];
|
||||||
this._killed = false;
|
this._killed = false;
|
||||||
@@ -1108,6 +1240,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
if (this._rconClient) {
|
if (this._rconClient) {
|
||||||
this._rconClient.end().catch(() => {});
|
this._rconClient.end().catch(() => {});
|
||||||
}
|
}
|
||||||
|
this._stopUdp();
|
||||||
|
|
||||||
if (this._whitelistWatcher) {
|
if (this._whitelistWatcher) {
|
||||||
this._whitelistWatcher.close();
|
this._whitelistWatcher.close();
|
||||||
@@ -1126,6 +1259,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
if (this._rconClient) {
|
if (this._rconClient) {
|
||||||
this._rconClient.end().catch(() => {});
|
this._rconClient.end().catch(() => {});
|
||||||
}
|
}
|
||||||
|
this._stopUdp();
|
||||||
|
|
||||||
if (this._whitelistWatcher) {
|
if (this._whitelistWatcher) {
|
||||||
this._whitelistWatcher.close();
|
this._whitelistWatcher.close();
|
||||||
@@ -1224,6 +1358,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
*/
|
*/
|
||||||
async start(save: string) {
|
async start(save: string) {
|
||||||
this._check(["init"]);
|
this._check(["init"]);
|
||||||
|
this._checkLuaUdpVersion();
|
||||||
this._state = "running";
|
this._state = "running";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1239,6 +1374,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
"--port", String(this.gamePort),
|
"--port", String(this.gamePort),
|
||||||
"--rcon-port", String(this.rconPort),
|
"--rcon-port", String(this.rconPort),
|
||||||
"--rcon-password", this.rconPassword,
|
"--rcon-password", this.rconPassword,
|
||||||
|
...(this.enableLuaUdp ? ["--enable-lua-udp", String(this.luaUdpPort)] : []),
|
||||||
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
||||||
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
|
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
|
||||||
...(this.verboseLogging ? ["--verbose"] : []),
|
...(this.verboseLogging ? ["--verbose"] : []),
|
||||||
@@ -1276,6 +1412,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
*/
|
*/
|
||||||
async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) {
|
async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) {
|
||||||
this._check(["init"]);
|
this._check(["init"]);
|
||||||
|
this._checkLuaUdpVersion();
|
||||||
this._state = "running";
|
this._state = "running";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1297,6 +1434,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
"--port", String(this.gamePort),
|
"--port", String(this.gamePort),
|
||||||
"--rcon-port", String(this.rconPort),
|
"--rcon-port", String(this.rconPort),
|
||||||
"--rcon-password", this.rconPassword,
|
"--rcon-password", this.rconPassword,
|
||||||
|
...(this.enableLuaUdp ? ["--enable-lua-udp", String(this.luaUdpPort)] : []),
|
||||||
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
|
||||||
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
|
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
|
||||||
...(this.verboseLogging ? ["--verbose"] : []),
|
...(this.verboseLogging ? ["--verbose"] : []),
|
||||||
@@ -1377,6 +1515,27 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send UDP message to the server
|
||||||
|
*
|
||||||
|
* Sends a UDP packet to the Lua UDP port of the Factorio server, which
|
||||||
|
* is delivered in-game as an on_udp_packet_received event. Delivery is
|
||||||
|
* not guaranteed. Requires Lua UDP to be enabled.
|
||||||
|
*
|
||||||
|
* @param message - payload of the packet to send.
|
||||||
|
*/
|
||||||
|
async sendUdp(message: string | Buffer) {
|
||||||
|
this._check(["running", "stopping"]);
|
||||||
|
if (!this._udpSocket) {
|
||||||
|
throw new Error("UDP socket is not open");
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = this._udpSocket;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.send(message, this.luaUdpPort, "127.0.0.1", err => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timeout in ms to wait after a shutdown is requested before killing the
|
* Timeout in ms to wait after a shutdown is requested before killing the
|
||||||
* process. Defaults to 0 meaning no timeout
|
* process. Defaults to 0 meaning no timeout
|
||||||
@@ -1412,6 +1571,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
if (this._rconClient) {
|
if (this._rconClient) {
|
||||||
this._rconClient.end().catch(() => {});
|
this._rconClient.end().catch(() => {});
|
||||||
}
|
}
|
||||||
|
this._stopUdp();
|
||||||
if (this._whitelistWatcher) {
|
if (this._whitelistWatcher) {
|
||||||
this._whitelistWatcher.close();
|
this._whitelistWatcher.close();
|
||||||
}
|
}
|
||||||
@@ -1526,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,5 +1,7 @@
|
|||||||
|
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/config";
|
export * from "./src/config";
|
||||||
export * from "./src/data";
|
export * from "./src/data";
|
||||||
export * from "./src/errors";
|
export * from "./src/errors";
|
||||||
|
|||||||
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>;
|
||||||
+47
-39
@@ -1,13 +1,40 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import JSZip from "jszip";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import stream from "node:stream";
|
import { pipeline } from "node:stream/promises";
|
||||||
import util from "node:util";
|
|
||||||
import yargs from "yargs";
|
import yargs from "yargs";
|
||||||
|
import yazl from "yazl";
|
||||||
|
|
||||||
const finished = util.promisify(stream.finished);
|
|
||||||
|
|
||||||
|
async function addModFiles(args, info, modName, zip) {
|
||||||
|
for await (const dirent of await fs.opendir(args.sourceDir, { recursive: true })) {
|
||||||
|
if (dirent.isFile()) {
|
||||||
|
const itemPath = path.join(dirent.parentPath, dirent.name);
|
||||||
|
// On Windows the path created uses backslashes as the directory sepparator
|
||||||
|
// but the zip file needs to use forward slashes. We can't use the posix
|
||||||
|
// version of relative here as it doesn't work with Windows style paths.
|
||||||
|
let basePath = path.relative(args.sourceDir, itemPath).replace(/\\/g, "/");
|
||||||
|
if (basePath === "info.json") {
|
||||||
|
// The info.json file is overridden later.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
zip.addFile(itemPath, path.posix.join(modName, basePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let [fileName, pathParts] of Object.entries(info.additional_files || {})) {
|
||||||
|
let filePath = path.join(args.sourceDir, ...pathParts);
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Error reading additional file ${filePath}`, { cause: err });
|
||||||
|
}
|
||||||
|
zip.addFile(filePath, path.posix.join(modName, fileName));
|
||||||
|
}
|
||||||
|
delete info.additional_files;
|
||||||
|
|
||||||
|
zip.addBuffer(Buffer.from(JSON.stringify(info, null, "\t")), path.posix.join(modName, "info.json"));
|
||||||
|
}
|
||||||
|
|
||||||
async function buildMod(args, info, modName) {
|
async function buildMod(args, info, modName) {
|
||||||
if (args.build) {
|
if (args.build) {
|
||||||
@@ -15,43 +42,24 @@ async function buildMod(args, info, modName) {
|
|||||||
modName = args.modName ?? `${info.name}_${info.version}`;
|
modName = args.modName ?? `${info.name}_${info.version}`;
|
||||||
|
|
||||||
if (args.pack) {
|
if (args.pack) {
|
||||||
let zip = new JSZip();
|
|
||||||
for await (const dirent of await fs.opendir(args.sourceDir, { recursive: true })) {
|
|
||||||
if (dirent.isFile()) {
|
|
||||||
const itemPath = path.join(dirent.parentPath, dirent.name);
|
|
||||||
// On Windows the path created uses backslashes as the directory sepparator
|
|
||||||
// but the zip file needs to use forward slashes. We can't use the posix
|
|
||||||
// version of relative here as it doesn't work with Windows style paths.
|
|
||||||
let basePath = path.relative(args.sourceDir, itemPath).replace(/\\/g, "/");
|
|
||||||
// eslint-disable-next-line max-depth
|
|
||||||
if (basePath === "info.json") {
|
|
||||||
// The info.json file is overridden later.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
zip.file(path.posix.join(modName, basePath), (await fs.open(itemPath)).createReadStream());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let [fileName, pathParts] of Object.entries(info.additional_files || {})) {
|
|
||||||
let filePath = path.join(args.sourceDir, ...pathParts);
|
|
||||||
let fileStream;
|
|
||||||
try {
|
|
||||||
fileStream = (await fs.open(filePath)).createReadStream();
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`Error reading additional file ${filePath}`, { cause: err });
|
|
||||||
}
|
|
||||||
zip.file(path.posix.join(modName, fileName), fileStream);
|
|
||||||
}
|
|
||||||
delete info.additional_files;
|
|
||||||
|
|
||||||
zip.file(path.posix.join(modName, "info.json"), JSON.stringify(info, null, "\t"));
|
|
||||||
|
|
||||||
let modPath = path.join(args.outputDir, `${modName}.zip`);
|
let modPath = path.join(args.outputDir, `${modName}.zip`);
|
||||||
console.log(`Writing ${modPath}`);
|
console.log(`Writing ${modPath}`);
|
||||||
let writeStream = zip.generateNodeStream().pipe(
|
let zip = new yazl.ZipFile();
|
||||||
(await fs.open(modPath, "w")).createWriteStream()
|
// yazl reports errors on the zip object, not the output stream.
|
||||||
);
|
zip.on("error", err => zip.outputStream.destroy(err));
|
||||||
await finished(writeStream);
|
// addFile() can fail before the traversal is done, so start writing first.
|
||||||
|
const writing = pipeline(zip.outputStream, (await fs.open(modPath, "w")).createWriteStream());
|
||||||
|
writing.catch(() => {});
|
||||||
|
try {
|
||||||
|
await addModFiles(args, info, modName, zip);
|
||||||
|
zip.end();
|
||||||
|
await writing;
|
||||||
|
} catch (err) {
|
||||||
|
zip.outputStream.destroy();
|
||||||
|
await writing.catch(() => {});
|
||||||
|
await fs.rm(modPath, { force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
let modDir = path.join(args.outputDir, modName);
|
let modDir = path.join(args.outputDir, modName);
|
||||||
|
|||||||
@@ -35,8 +35,11 @@ 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/migrations.js";
|
||||||
|
|
||||||
export { default as ExponentialBackoff } from "./src/ExponentialBackoff.js";
|
export { default as ExponentialBackoff } from "./src/ExponentialBackoff.js";
|
||||||
export { default as ModStore } from "./src/ModStore.js";
|
export { default as ModStore } from "./src/ModStore.js";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clusterio/lib",
|
"name": "@clusterio/lib",
|
||||||
"version": "2.0.0-alpha.27",
|
"version": "2.0.3",
|
||||||
"description": "Shared library for Clusterio",
|
"description": "Shared library for Clusterio",
|
||||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||||
"author": "Hornwitser <github@hornwitser.no>",
|
"author": "Hornwitser <github@hornwitser.no>",
|
||||||
@@ -51,16 +51,19 @@
|
|||||||
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "catalog:",
|
"@sinclair/typebox": "catalog:",
|
||||||
|
"@types/yauzl": "catalog:",
|
||||||
|
"@types/yazl": "catalog:",
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"chalk": "catalog:",
|
"chalk": "catalog:",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"jszip": "catalog:",
|
|
||||||
"set-blocking": "catalog:",
|
"set-blocking": "catalog:",
|
||||||
"triple-beam": "^1.4.1",
|
"triple-beam": "^1.4.1",
|
||||||
"winston": "catalog:",
|
"winston": "catalog:",
|
||||||
"winston-transport": "catalog:",
|
"winston-transport": "catalog:",
|
||||||
"ws": "catalog:",
|
"ws": "catalog:",
|
||||||
"yargs": "catalog:"
|
"yargs": "catalog:",
|
||||||
|
"yauzl": "catalog:",
|
||||||
|
"yazl": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { PluginError } from "./errors.js";
|
||||||
|
|
||||||
|
export type SyncHookHandler<Value> = (value: Value) => Value;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that passes a value through each attached handler in turn.
|
||||||
|
*
|
||||||
|
* Handlers run synchronously in the order they were attached, for places
|
||||||
|
* that can't wait on a promise such as the Config constructor.
|
||||||
|
*/
|
||||||
|
export class SyncHook<Value> {
|
||||||
|
private _handlers = new Map<string, SyncHookHandler<Value>>();
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return this._handlers.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get attached() {
|
||||||
|
return this._handlers.keys();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a handler to this hook.
|
||||||
|
*
|
||||||
|
* An error is thrown if another handler with the same name is already attached.
|
||||||
|
*/
|
||||||
|
attach(name: string, handler: SyncHookHandler<Value>) {
|
||||||
|
if (this._handlers.has(name)) {
|
||||||
|
throw new Error(`Handler with name ${name} is already attached`);
|
||||||
|
}
|
||||||
|
this._handlers.set(name, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a previously attached handler.
|
||||||
|
*
|
||||||
|
* If no handler exists, then no action is performed.
|
||||||
|
*/
|
||||||
|
detach(name: string) {
|
||||||
|
this._handlers.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all attached handlers.
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this._handlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass a value through all attached handlers.
|
||||||
|
*
|
||||||
|
* Each handler gets the value returned by the one before it. Errors are
|
||||||
|
* rethrown as a PluginError named after the handler that threw.
|
||||||
|
*
|
||||||
|
* @param value - Value given to the first handler.
|
||||||
|
* @returns Value returned by the last handler.
|
||||||
|
*/
|
||||||
|
reduce(value: Value) {
|
||||||
|
for (const [name, handler] of this._handlers) {
|
||||||
|
try {
|
||||||
|
value = handler(value);
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new PluginError(name, err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,42 +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";
|
||||||
|
|
||||||
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()),
|
||||||
@@ -54,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(),
|
||||||
@@ -68,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,
|
||||||
@@ -108,8 +78,8 @@ export interface FieldDefinition<
|
|||||||
*/
|
*/
|
||||||
autoComplete?: string;
|
autoComplete?: string;
|
||||||
/**
|
/**
|
||||||
* Web UI component to use for rending this input. Can be extended by
|
* Web UI component to use for rendering this input. Plugins register components
|
||||||
* @{link "@clusterio/web_ui".BaseWebPlugin.inputComponents}
|
* through the inputComponents hook on control.hooks in their web entrypoint.
|
||||||
*/
|
*/
|
||||||
inputComponent?: string;
|
inputComponent?: string;
|
||||||
/**
|
/**
|
||||||
@@ -118,7 +88,8 @@ export interface FieldDefinition<
|
|||||||
optional?: Type extends null ? true : false;
|
optional?: Type extends null ? true : false;
|
||||||
/**
|
/**
|
||||||
* Value this config field should take in a newly initialized config.
|
* Value this config field should take in a newly initialized config.
|
||||||
* This can also be an function returning the value to use.
|
* This can also be a synchronous function returning the value to use.
|
||||||
|
* Promise results are not awaited.
|
||||||
*/
|
*/
|
||||||
initialValue?: unknown | (() => unknown);
|
initialValue?: unknown | (() => unknown);
|
||||||
/**
|
/**
|
||||||
@@ -163,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) {
|
||||||
@@ -177,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,
|
||||||
@@ -224,6 +195,18 @@ export class Config<
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrations attached by plugins, run after {@link Config.migrations}.
|
||||||
|
* Declared by each subclass that plugins can add fields to.
|
||||||
|
*/
|
||||||
|
declare static pluginMigrations?: SyncHook<ConfigSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versions of the plugins whose migrations are attached, keyed by
|
||||||
|
* plugin name. Written to `<plugin>.version` after the migrations run.
|
||||||
|
*/
|
||||||
|
static pluginVersions = new Map<string, string>();
|
||||||
|
|
||||||
/** Defines the default access locations for the config class */
|
/** Defines the default access locations for the config class */
|
||||||
static defaultAccess: ConfigLocation[] = ["controller", "host", "control"];
|
static defaultAccess: ConfigLocation[] = ["controller", "host", "control"];
|
||||||
|
|
||||||
@@ -234,10 +217,15 @@ export class Config<
|
|||||||
/** Set to true during validators to prevent edits */
|
/** Set to true during validators to prevent edits */
|
||||||
private _readonly = false; // TODO replace with "using config.asReadonly()"
|
private _readonly = false; // TODO replace with "using config.asReadonly()"
|
||||||
/** Set to true when a field in the config is changed. */
|
/** Set to true when a field in the config is changed. */
|
||||||
private dirty = false;
|
private _dirty = false;
|
||||||
/** Set to true when a 'restart required' field in the config is changed. */
|
/** Set to true when a 'restart required' field in the config is changed. */
|
||||||
restartRequired = false;
|
restartRequired = false;
|
||||||
|
|
||||||
|
/** True if the config has changes that have not been saved. */
|
||||||
|
get dirty() {
|
||||||
|
return this._dirty;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new instance of the given config
|
* Create a new instance of the given config
|
||||||
*
|
*
|
||||||
@@ -269,7 +257,21 @@ export class Config<
|
|||||||
) as Fields;
|
) as Fields;
|
||||||
|
|
||||||
if (fields) {
|
if (fields) {
|
||||||
this.update(this.constructor.migrations(fields), false, location);
|
let migrated = this.constructor.migrations(fields);
|
||||||
|
if (this.constructor.pluginMigrations) {
|
||||||
|
migrated = this.constructor.pluginMigrations.reduce(migrated);
|
||||||
|
}
|
||||||
|
this.update(migrated, false, location);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [pluginName, version] of this.constructor.pluginVersions) {
|
||||||
|
const name = `${pluginName}.version` as keyof Fields & string;
|
||||||
|
if (name in this.fields && this.fields[name] !== version) {
|
||||||
|
this.fields[name] = version as Fields[typeof name];
|
||||||
|
if (fields) {
|
||||||
|
this._dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +280,7 @@ export class Config<
|
|||||||
}
|
}
|
||||||
|
|
||||||
set filepath(filepath: string | undefined) {
|
set filepath(filepath: string | undefined) {
|
||||||
this.dirty = true;
|
this._dirty = true;
|
||||||
this._filepath = filepath;
|
this._filepath = filepath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +291,9 @@ export class Config<
|
|||||||
// TODO replace with "using config.startStating()"
|
// TODO replace with "using config.startStating()"
|
||||||
get staging(): Config<Fields> {
|
get staging(): Config<Fields> {
|
||||||
if (this._staging) { return this._staging.config; }
|
if (this._staging) { return this._staging.config; }
|
||||||
const config = new this.constructor(this.location, this.fields);
|
// Copied with update so migrations don't run again on the live fields
|
||||||
|
const config = new this.constructor(this.location);
|
||||||
|
config.update(this.fields, false);
|
||||||
const changes = new Set<string>();
|
const changes = new Set<string>();
|
||||||
config.on("fieldChanged", name => changes.add(name));
|
config.on("fieldChanged", name => changes.add(name));
|
||||||
this._staging = { config, changes };
|
this._staging = { config, changes };
|
||||||
@@ -333,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
|
||||||
*/
|
*/
|
||||||
@@ -345,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
|
||||||
@@ -367,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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,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"]>;
|
||||||
@@ -394,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
|
||||||
@@ -413,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;
|
||||||
}
|
}
|
||||||
@@ -443,7 +447,7 @@ export class Config<
|
|||||||
if (def.restartRequired) {
|
if (def.restartRequired) {
|
||||||
this.restartRequired = true;
|
this.restartRequired = true;
|
||||||
}
|
}
|
||||||
this.dirty = true;
|
this._dirty = true;
|
||||||
this.emit("fieldChanged", name, value, prev);
|
this.emit("fieldChanged", name, value, prev);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -465,14 +469,14 @@ export class Config<
|
|||||||
* @param force - Set to true to save the file even if no changes have been made.
|
* @param force - Set to true to save the file even if no changes have been made.
|
||||||
*/
|
*/
|
||||||
async save(force = false) {
|
async save(force = false) {
|
||||||
if (!force && !this.dirty) {
|
if (!force && !this._dirty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.filepath) {
|
if (!this.filepath) {
|
||||||
throw new Error("Cannot save config which has no filepath");
|
throw new Error("Cannot save config which has no filepath");
|
||||||
}
|
}
|
||||||
await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t"), { mode: 0o600 });
|
await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t"), { mode: 0o600 });
|
||||||
this.dirty = false;
|
this._dirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static jsonSchema = ConfigSchema;
|
static jsonSchema = ConfigSchema;
|
||||||
@@ -613,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);
|
||||||
}
|
}
|
||||||
@@ -628,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) {
|
||||||
@@ -640,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];
|
||||||
@@ -655,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);
|
||||||
@@ -679,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 {
|
||||||
@@ -697,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 {
|
||||||
@@ -718,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,
|
||||||
@@ -729,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);
|
||||||
@@ -738,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>;
|
||||||
@@ -758,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 {
|
||||||
@@ -776,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 {
|
||||||
@@ -794,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) {
|
||||||
@@ -804,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);
|
||||||
@@ -824,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,
|
||||||
@@ -839,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);
|
||||||
@@ -848,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>;
|
||||||
@@ -866,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) {
|
||||||
@@ -888,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,9 +1,10 @@
|
|||||||
// 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";
|
||||||
|
|
||||||
type configFromJSON<T> = (...args: Parameters<typeof classes.Config.fromJSON>) => T;
|
type configFromJSON<T> = (...args: Parameters<typeof classes.Config.fromJSON>) => T;
|
||||||
type configFromFile<T> = (...args: Parameters<typeof classes.Config.fromFile>) => Promise<T>;
|
type configFromFile<T> = (...args: Parameters<typeof classes.Config.fromFile>) => Promise<T>;
|
||||||
@@ -30,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;
|
||||||
@@ -42,6 +46,7 @@ export interface ControllerConfigFields {
|
|||||||
"controller.allow_plugin_updates": boolean;
|
"controller.allow_plugin_updates": boolean;
|
||||||
"controller.allow_plugin_install": boolean;
|
"controller.allow_plugin_install": boolean;
|
||||||
[key: `${string}.load_plugin`]: boolean;
|
[key: `${string}.load_plugin`]: boolean;
|
||||||
|
[key: `${string}.version`]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +56,7 @@ export interface ControllerConfigFields {
|
|||||||
export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||||
declare static fromJSON: configFromJSON<ControllerConfig>;
|
declare static fromJSON: configFromJSON<ControllerConfig>;
|
||||||
declare static fromFile: configFromFile<ControllerConfig>;
|
declare static fromFile: configFromFile<ControllerConfig>;
|
||||||
|
static pluginMigrations = new SyncHook<classes.ConfigSchema>();
|
||||||
static migrations(config: Static<typeof this.jsonSchema>) {
|
static migrations(config: Static<typeof this.jsonSchema>) {
|
||||||
if (config.hasOwnProperty("controller.external_address")) {
|
if (config.hasOwnProperty("controller.external_address")) {
|
||||||
config["controller.public_url"] = config["controller.external_address"];
|
config["controller.public_url"] = config["controller.external_address"];
|
||||||
@@ -148,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}.`);
|
||||||
@@ -240,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.",
|
||||||
@@ -334,6 +368,7 @@ export interface HostConfigFields {
|
|||||||
"host.allow_plugin_updates": boolean;
|
"host.allow_plugin_updates": boolean;
|
||||||
"host.allow_plugin_install": boolean;
|
"host.allow_plugin_install": boolean;
|
||||||
[key: `${string}.load_plugin`]: boolean;
|
[key: `${string}.load_plugin`]: boolean;
|
||||||
|
[key: `${string}.version`]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -343,6 +378,7 @@ export interface HostConfigFields {
|
|||||||
export class HostConfig extends classes.Config<HostConfigFields> {
|
export class HostConfig extends classes.Config<HostConfigFields> {
|
||||||
declare static fromJSON: configFromJSON<HostConfig>;
|
declare static fromJSON: configFromJSON<HostConfig>;
|
||||||
declare static fromFile: configFromFile<HostConfig>;
|
declare static fromFile: configFromFile<HostConfig>;
|
||||||
|
static pluginMigrations = new SyncHook<classes.ConfigSchema>();
|
||||||
static fieldDefinitions: classes.ConfigDefs<HostConfigFields> = {
|
static fieldDefinitions: classes.ConfigDefs<HostConfigFields> = {
|
||||||
"host.version": {
|
"host.version": {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -408,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": {
|
||||||
@@ -466,8 +502,10 @@ 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;
|
||||||
|
|
||||||
"factorio.version": string;
|
"factorio.version": string;
|
||||||
"factorio.executable_path": string | null;
|
"factorio.executable_path": string | null;
|
||||||
@@ -476,6 +514,8 @@ export interface InstanceConfigFields {
|
|||||||
"factorio.host_assigned_game_port": number | null;
|
"factorio.host_assigned_game_port": number | null;
|
||||||
"factorio.rcon_port": number | null;
|
"factorio.rcon_port": number | null;
|
||||||
"factorio.rcon_password": string | null;
|
"factorio.rcon_password": string | null;
|
||||||
|
"factorio.enable_lua_udp": boolean;
|
||||||
|
"factorio.lua_udp_port": number | null;
|
||||||
"factorio.player_online_autosave_slots": number;
|
"factorio.player_online_autosave_slots": number;
|
||||||
"factorio.mod_pack_id": number | null;
|
"factorio.mod_pack_id": number | null;
|
||||||
"factorio.enable_save_patching": boolean;
|
"factorio.enable_save_patching": boolean;
|
||||||
@@ -500,6 +540,7 @@ export interface InstanceConfigFields {
|
|||||||
export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||||
declare static fromJSON: configFromJSON<InstanceConfig>;
|
declare static fromJSON: configFromJSON<InstanceConfig>;
|
||||||
declare static fromFile: configFromFile<InstanceConfig>;
|
declare static fromFile: configFromFile<InstanceConfig>;
|
||||||
|
static pluginMigrations = new SyncHook<classes.ConfigSchema>();
|
||||||
static migrations(config: Static<typeof this.jsonSchema>) {
|
static migrations(config: Static<typeof this.jsonSchema>) {
|
||||||
function boolToEnableDisable(name: string) {
|
function boolToEnableDisable(name: string) {
|
||||||
if (config.hasOwnProperty(name) && typeof config[name] === "boolean") {
|
if (config.hasOwnProperty(name) && typeof config[name] === "boolean") {
|
||||||
@@ -536,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",
|
||||||
@@ -599,6 +648,21 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
|||||||
type: "string",
|
type: "string",
|
||||||
optional: true,
|
optional: true,
|
||||||
},
|
},
|
||||||
|
"factorio.enable_lua_udp": {
|
||||||
|
description:
|
||||||
|
"Enable UDP messaging between the Factorio server and the host. " +
|
||||||
|
"Required by plugins using UDP. Requires Factorio 2.1.10 or later.",
|
||||||
|
restartRequired: true, // Because of the cli option "--enable-lua-udp"
|
||||||
|
type: "boolean",
|
||||||
|
initialValue: false,
|
||||||
|
},
|
||||||
|
"factorio.lua_udp_port": {
|
||||||
|
description: "UDP port for the Factorio server to receive Lua UDP messages on, uses a random port if null",
|
||||||
|
restartRequired: true,
|
||||||
|
type: "number",
|
||||||
|
optional: true,
|
||||||
|
validator: validators.optional(validators.greaterThanEqualZero),
|
||||||
|
},
|
||||||
"factorio.player_online_autosave_slots": {
|
"factorio.player_online_autosave_slots": {
|
||||||
description:
|
description:
|
||||||
"Rename autosaves where players have been online since the previous autosave into a separate " +
|
"Rename autosaves where players have been online since the previous autosave into a separate " +
|
||||||
@@ -762,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>
|
||||||
) {
|
) {
|
||||||
@@ -788,7 +852,12 @@ export function addPluginFieldDefinitions(
|
|||||||
fieldDef.dependsOn.push("factorio.enable_script_commands");
|
fieldDef.dependsOn.push("factorio.enable_script_commands");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scriptCommands || savePatching) {
|
const luaUdp = pluginInfo.features.includes("LuaUdp");
|
||||||
|
if (luaUdp) {
|
||||||
|
fieldDef.dependsOn.push("factorio.enable_lua_udp");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scriptCommands || savePatching || luaUdp) {
|
||||||
fieldDef.validator = (enabled, config) => {
|
fieldDef.validator = (enabled, config) => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return;
|
return;
|
||||||
@@ -799,12 +868,23 @@ export function addPluginFieldDefinitions(
|
|||||||
if (scriptCommands && !config.get("factorio.enable_script_commands")) {
|
if (scriptCommands && !config.get("factorio.enable_script_commands")) {
|
||||||
throw new Error(`Plugin ${pluginInfo.name} requires script commands`);
|
throw new Error(`Plugin ${pluginInfo.name} requires script commands`);
|
||||||
}
|
}
|
||||||
|
if (luaUdp && !config.get("factorio.enable_lua_udp")) {
|
||||||
|
throw new Error(`Plugin ${pluginInfo.name} requires Lua UDP`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Config.fieldDefinitions[`${pluginInfo.name}.load_plugin`] = fieldDef;
|
Config.fieldDefinitions[`${pluginInfo.name}.load_plugin`] = fieldDef;
|
||||||
|
|
||||||
|
// Version of the plugin the config was last loaded with, see Config.pluginVersions
|
||||||
|
Config.fieldDefinitions[`${pluginInfo.name}.version`] = {
|
||||||
|
type: "string",
|
||||||
|
initialValue: "",
|
||||||
|
readonly: ["controller", "host"],
|
||||||
|
hidden: true,
|
||||||
|
};
|
||||||
|
|
||||||
// Merge in other fields from the plugin
|
// Merge in other fields from the plugin
|
||||||
const fields = pluginInfo[kind];
|
const fields = pluginInfo[kind];
|
||||||
if (fields) {
|
if (fields) {
|
||||||
@@ -818,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) {
|
||||||
@@ -827,5 +907,10 @@ export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginW
|
|||||||
if (pluginInfo.instanceEntrypoint) {
|
if (pluginInfo.instanceEntrypoint) {
|
||||||
addPluginFieldDefinitions(pluginInfo, "instanceConfigFields", InstanceConfig);
|
addPluginFieldDefinitions(pluginInfo, "instanceConfigFields", InstanceConfig);
|
||||||
}
|
}
|
||||||
|
if (pluginInfo.ctlConfigFields) {
|
||||||
|
validateFieldNames(pluginInfo.name, pluginInfo.ctlConfigFields);
|
||||||
|
Object.assign(CtlConfig.fieldDefinitions, pluginInfo.ctlConfigFields);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import JSZip from "jszip";
|
import path from "path";
|
||||||
import { Type, Static } from "@sinclair/typebox";
|
import { Type, Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
import * as libHash from "../hash.js";
|
import * as libHash from "../hash.js";
|
||||||
import * as libSchema from "../schema.js";
|
import * as libSchema from "../schema.js";
|
||||||
import { findRoot } from "../zip_ops.js";
|
import { ZipArchive, findRoot } from "../zip_ops.js";
|
||||||
import { ModRecord } from "./ModPack.js";
|
import { ModRecord } from "./ModPack.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -352,18 +352,15 @@ export default class ModInfo {
|
|||||||
|
|
||||||
static async fromModFile(modPath: string) {
|
static async fromModFile(modPath: string) {
|
||||||
let modInfo: Static<typeof ModInfo.jsonSchema>;
|
let modInfo: Static<typeof ModInfo.jsonSchema>;
|
||||||
{
|
let zip = await ZipArchive.fromFile(modPath);
|
||||||
// XXX: JSZip needs the whole archive loaded in memory to work.
|
try {
|
||||||
// This is clearly untenable and will be replaced later.
|
let content = await zip.readFile(path.posix.join(findRoot(zip), "info.json"));
|
||||||
let zip = await JSZip.loadAsync(await fs.readFile(modPath));
|
if (content === null) {
|
||||||
let root = zip.folder(findRoot(zip))!;
|
|
||||||
|
|
||||||
let infoFile = root.file("info.json");
|
|
||||||
if (!infoFile) {
|
|
||||||
throw new Error("Mod contains no info.json file");
|
throw new Error("Mod contains no info.json file");
|
||||||
}
|
}
|
||||||
|
modInfo = JSON.parse(content.toString("utf8"));
|
||||||
modInfo = JSON.parse(await infoFile.async("string"));
|
} finally {
|
||||||
|
zip.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
let valid = this.validateInfo(modInfo);
|
let valid = this.validateInfo(modInfo);
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Type, Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free text note attached to a resource in the cluster.
|
||||||
|
*
|
||||||
|
* Notes are keyed by the type of resource and the id of the resource within
|
||||||
|
* that type. Core resource types are "host", "instance", "user", "role",
|
||||||
|
* "mod_pack", "mod" and "save", plugins may define their own.
|
||||||
|
*/
|
||||||
|
export default class Note {
|
||||||
|
constructor(
|
||||||
|
/** Type of resource this note is attached to. */
|
||||||
|
public resourceType: string,
|
||||||
|
/** Id of the resource within its type, as a string. */
|
||||||
|
public resourceId: string,
|
||||||
|
/** Text of the note. */
|
||||||
|
public content: string,
|
||||||
|
/** Name of the user that last changed the note. */
|
||||||
|
public updatedBy = "",
|
||||||
|
/** Millisecond Unix timestamp this entry was last updated at. */
|
||||||
|
public updatedAtMs = 0,
|
||||||
|
/** True if this note has been removed. */
|
||||||
|
public isDeleted = false,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/** Unique key for this note, resource type and id joined with a slash. */
|
||||||
|
get id() {
|
||||||
|
return Note.idFor(this.resourceType, this.resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static idFor(resourceType: string, resourceId: string | number) {
|
||||||
|
return `${resourceType}/${resourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
"resourceType": Type.String(),
|
||||||
|
"resourceId": Type.String(),
|
||||||
|
"content": Type.String(),
|
||||||
|
"updatedBy": Type.Optional(Type.String()),
|
||||||
|
"updatedAtMs": Type.Optional(Type.Number()),
|
||||||
|
"isDeleted": Type.Optional(Type.Boolean()),
|
||||||
|
});
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(
|
||||||
|
json.resourceType,
|
||||||
|
json.resourceId,
|
||||||
|
json.content,
|
||||||
|
json.updatedBy,
|
||||||
|
json.updatedAtMs,
|
||||||
|
json.isDeleted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
const json = {
|
||||||
|
resourceType: this.resourceType,
|
||||||
|
resourceId: this.resourceId,
|
||||||
|
content: this.content,
|
||||||
|
} as Static<typeof Note.jsonSchema>;
|
||||||
|
|
||||||
|
if (this.updatedBy !== "") {
|
||||||
|
json.updatedBy = this.updatedBy;
|
||||||
|
}
|
||||||
|
if (this.updatedAtMs !== 0) {
|
||||||
|
json.updatedAtMs = this.updatedAtMs;
|
||||||
|
}
|
||||||
|
if (this.isDeleted !== false) {
|
||||||
|
json.isDeleted = this.isDeleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Static, Type } from "@sinclair/typebox";
|
import { Static, Type } from "@sinclair/typebox";
|
||||||
import { permissions as globalPermissions } from "../permissions.js";
|
import { permissions as globalPermissions } from "../permissions.js";
|
||||||
|
import { SyncHook } from "../SyncHook.js";
|
||||||
|
|
||||||
interface RoleCollection {
|
interface RoleCollection {
|
||||||
get(id: number): Role;
|
get(id: number): Role;
|
||||||
@@ -31,6 +32,9 @@ export default class Role {
|
|||||||
is_deleted: Type.Optional(Type.Boolean()),
|
is_deleted: Type.Optional(Type.Boolean()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Migrations attached by plugins, run on roles loaded by the controller */
|
||||||
|
static pluginMigrations = new SyncHook<Static<typeof Role.jsonSchema>>();
|
||||||
|
|
||||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
return new this(
|
return new this(
|
||||||
json.id,
|
json.id,
|
||||||
|
|||||||
Loaded 100 of 344 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user